1493 lines
51 KiB
JavaScript
1493 lines
51 KiB
JavaScript
/*
|
||
录音
|
||
https://github.com/xiangyuecn/Recorder
|
||
*/
|
||
(function(factory){
|
||
factory(window);
|
||
//umd returnExports.js
|
||
if(typeof(define)=='function' && define.amd){
|
||
define(function(){
|
||
return Recorder;
|
||
});
|
||
};
|
||
if(typeof(module)=='object' && module.exports){
|
||
module.exports=Recorder;
|
||
};
|
||
}(function(window){
|
||
"use strict";
|
||
|
||
var NOOP=function(){};
|
||
|
||
var Recorder=function(set){
|
||
return new initFn(set);
|
||
};
|
||
Recorder.LM="2023-02-01 18:05";
|
||
var RecTxt="Recorder";
|
||
var getUserMediaTxt="getUserMedia";
|
||
var srcSampleRateTxt="srcSampleRate";
|
||
var sampleRateTxt="sampleRate";
|
||
var CatchTxt="catch";
|
||
|
||
|
||
//是否已经打开了全局的麦克风录音,所有工作都已经准备好了,就等接收音频数据了
|
||
Recorder.IsOpen=function(){
|
||
var stream=Recorder.Stream;
|
||
if(stream){
|
||
var tracks=stream.getTracks&&stream.getTracks()||stream.audioTracks||[];
|
||
var track=tracks[0];
|
||
if(track){
|
||
var state=track.readyState;
|
||
return state=="live"||state==track.LIVE;
|
||
};
|
||
};
|
||
return false;
|
||
};
|
||
/*H5录音时的AudioContext缓冲大小。会影响H5录音时的onProcess调用速率,相对于AudioContext.sampleRate=48000时,4096接近12帧/s,调节此参数可生成比较流畅的回调动画。
|
||
取值256, 512, 1024, 2048, 4096, 8192, or 16384
|
||
注意,取值不能过低,2048开始不同浏览器可能回调速率跟不上造成音质问题。
|
||
一般无需调整,调整后需要先close掉已打开的录音,再open时才会生效。
|
||
*/
|
||
Recorder.BufferSize=4096;
|
||
//销毁已持有的所有全局资源,当要彻底移除Recorder时需要显式的调用此方法
|
||
Recorder.Destroy=function(){
|
||
CLog(RecTxt+" Destroy");
|
||
Disconnect();//断开可能存在的全局Stream、资源
|
||
|
||
for(var k in DestroyList){
|
||
DestroyList[k]();
|
||
};
|
||
};
|
||
var DestroyList={};
|
||
//登记一个需要销毁全局资源的处理方法
|
||
Recorder.BindDestroy=function(key,call){
|
||
DestroyList[key]=call;
|
||
};
|
||
//判断浏览器是否支持录音,随时可以调用。注意:仅仅是检测浏览器支持情况,不会判断和调起用户授权,不会判断是否支持特定格式录音。
|
||
Recorder.Support=function(){
|
||
var scope=navigator.mediaDevices||{};
|
||
if(!scope[getUserMediaTxt]){
|
||
scope=navigator;
|
||
scope[getUserMediaTxt]||(scope[getUserMediaTxt]=scope.webkitGetUserMedia||scope.mozGetUserMedia||scope.msGetUserMedia);
|
||
};
|
||
if(!scope[getUserMediaTxt]){
|
||
return false;
|
||
};
|
||
Recorder.Scope=scope;
|
||
|
||
if(!Recorder.GetContext()){
|
||
return false;
|
||
};
|
||
return true;
|
||
};
|
||
//获取全局的AudioContext对象,如果浏览器不支持将返回null
|
||
Recorder.GetContext=function(){
|
||
var AC=window.AudioContext;
|
||
if(!AC){
|
||
AC=window.webkitAudioContext;
|
||
};
|
||
if(!AC){
|
||
return null;
|
||
};
|
||
|
||
if(!Recorder.Ctx||Recorder.Ctx.state=="closed"){
|
||
//不能反复构造,低版本number of hardware contexts reached maximum (6)
|
||
Recorder.Ctx=new AC();
|
||
|
||
Recorder.BindDestroy("Ctx",function(){
|
||
var ctx=Recorder.Ctx;
|
||
if(ctx&&ctx.close){//能关掉就关掉,关不掉就保留着
|
||
ctx.close();
|
||
Recorder.Ctx=0;
|
||
};
|
||
});
|
||
};
|
||
return Recorder.Ctx;
|
||
};
|
||
|
||
|
||
/*是否启用MediaRecorder.WebM.PCM来进行音频采集连接(如果浏览器支持的话),默认启用,禁用或者不支持时将使用AudioWorklet或ScriptProcessor来连接;MediaRecorder采集到的音频数据比其他方式更好,几乎不存在丢帧现象,所以音质明显会好很多,建议保持开启*/
|
||
var ConnectEnableWebM="ConnectEnableWebM";
|
||
Recorder[ConnectEnableWebM]=true;
|
||
|
||
/*是否启用AudioWorklet特性来进行音频采集连接(如果浏览器支持的话),默认禁用,禁用或不支持时将使用过时的ScriptProcessor来连接(如果方法还在的话),当前AudioWorklet的实现在移动端没有ScriptProcessor稳健;ConnectEnableWebM如果启用并且有效时,本参数将不起作用*/
|
||
var ConnectEnableWorklet="ConnectEnableWorklet";
|
||
Recorder[ConnectEnableWorklet]=false;
|
||
|
||
/*初始化H5音频采集连接。如果自行提供了sourceStream将只进行一次简单的连接处理。如果是普通麦克风录音,此时的Stream是全局的,Safari上断开后就无法再次进行连接使用,表现为静音,因此使用全部使用全局处理避免调用到disconnect;全局处理也有利于屏蔽底层细节,start时无需再调用底层接口,提升兼容、可靠性。*/
|
||
var Connect=function(streamStore,isUserMedia){
|
||
var bufferSize=streamStore.BufferSize||Recorder.BufferSize;
|
||
|
||
var ctx=Recorder.Ctx,stream=streamStore.Stream;
|
||
var mediaConn=function(node){
|
||
var media=stream._m=ctx.createMediaStreamSource(stream);
|
||
var ctxDest=ctx.destination,cmsdTxt="createMediaStreamDestination";
|
||
if(ctx[cmsdTxt]){
|
||
ctxDest=ctx[cmsdTxt]();
|
||
};
|
||
media.connect(node);
|
||
node.connect(ctxDest);
|
||
}
|
||
var isWebM,isWorklet,badInt,webMTips="";
|
||
var calls=stream._call;
|
||
|
||
//浏览器回传的音频数据处理
|
||
var onReceive=function(float32Arr){
|
||
for(var k0 in calls){//has item
|
||
var size=float32Arr.length;
|
||
|
||
var pcm=new Int16Array(size);
|
||
var sum=0;
|
||
for(var j=0;j<size;j++){//floatTo16BitPCM
|
||
var s=Math.max(-1,Math.min(1,float32Arr[j]));
|
||
s=s<0?s*0x8000:s*0x7FFF;
|
||
pcm[j]=s;
|
||
sum+=Math.abs(s);
|
||
};
|
||
|
||
for(var k in calls){
|
||
calls[k](pcm,sum);
|
||
};
|
||
|
||
return;
|
||
};
|
||
};
|
||
|
||
var scriptProcessor="ScriptProcessor";//一堆字符串名字,有利于压缩js
|
||
var audioWorklet="audioWorklet";
|
||
var recAudioWorklet=RecTxt+" "+audioWorklet;
|
||
var RecProc="RecProc";
|
||
var MediaRecorderTxt="MediaRecorder";
|
||
var MRWebMPCM=MediaRecorderTxt+".WebM.PCM";
|
||
|
||
|
||
//===================连接方式三=========================
|
||
//古董级别的 ScriptProcessor 处理,目前所有浏览器均兼容,虽然是过时的方法,但更稳健,移动端性能比AudioWorklet强
|
||
var oldFn=ctx.createScriptProcessor||ctx.createJavaScriptNode;
|
||
var oldIsBest="。由于"+audioWorklet+"内部1秒375次回调,在移动端可能会有性能问题导致回调丢失录音变短,PC端无影响,暂不建议开启"+audioWorklet+"。";
|
||
var oldScript=function(){
|
||
isWorklet=stream.isWorklet=false;
|
||
_Disconn_n(stream);
|
||
CLog("Connect采用老的"+scriptProcessor+","+(Recorder[ConnectEnableWorklet]?"但已":"可")+"设置"+RecTxt+"."+ConnectEnableWorklet+"=true尝试启用"+audioWorklet+webMTips+oldIsBest,3);
|
||
|
||
var process=stream._p=oldFn.call(ctx,bufferSize,1,1);//单声道,省的数据处理复杂
|
||
mediaConn(process);
|
||
|
||
var _DsetTxt="_D220626",_Dset=Recorder[_DsetTxt];if(_Dset)CLog("Use "+RecTxt+"."+_DsetTxt,3);
|
||
process.onaudioprocess=function(e){
|
||
var arr=e.inputBuffer.getChannelData(0);
|
||
if(_Dset){//临时调试用的参数,未来会被删除
|
||
arr=new Float32Array(arr);//块是共享的,必须复制出来
|
||
setTimeout(function(){ onReceive(arr) });//立即退出回调,试图减少对浏览器录音的影响
|
||
}else{
|
||
onReceive(arr);
|
||
};
|
||
};
|
||
};
|
||
|
||
|
||
//===================连接方式二=========================
|
||
var connWorklet=function(){
|
||
//尝试开启AudioWorklet处理
|
||
isWebM=stream.isWebM=false;
|
||
_Disconn_r(stream);
|
||
|
||
isWorklet=stream.isWorklet=!oldFn || Recorder[ConnectEnableWorklet];
|
||
var AwNode=window.AudioWorkletNode;
|
||
if(!(isWorklet && ctx[audioWorklet] && AwNode)){
|
||
oldScript();//被禁用 或 不支持,直接使用老的
|
||
return;
|
||
};
|
||
var clazzUrl=function(){
|
||
var xf=function(f){return f.toString().replace(/^function|DEL_/g,"").replace(/\$RA/g,recAudioWorklet)};
|
||
var clazz='class '+RecProc+' extends AudioWorkletProcessor{';
|
||
clazz+="constructor "+xf(function(option){
|
||
DEL_super(option);
|
||
var This=this,bufferSize=option.processorOptions.bufferSize;
|
||
This.bufferSize=bufferSize;
|
||
This.buffer=new Float32Array(bufferSize*2);//乱给size搞乱缓冲区不管
|
||
This.pos=0;
|
||
This.port.onmessage=function(e){
|
||
if(e.data.kill){
|
||
This.kill=true;
|
||
console.log("$RA kill call");
|
||
}
|
||
};
|
||
console.log("$RA .ctor call", option);
|
||
});
|
||
|
||
//https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletProcessor/process 每次回调128个采样数据,1秒375次回调,高频导致移动端性能问题,结果就是回调次数缺斤少两,进而导致丢失数据,PC端似乎没有性能问题
|
||
clazz+="process "+xf(function(input,b,c){//需要等到ctx激活后才会有回调
|
||
var This=this,bufferSize=This.bufferSize;
|
||
var buffer=This.buffer,pos=This.pos;
|
||
input=(input[0]||[])[0]||[];
|
||
if(input.length){
|
||
buffer.set(input,pos);
|
||
pos+=input.length;
|
||
|
||
var len=~~(pos/bufferSize)*bufferSize;
|
||
if(len){
|
||
this.port.postMessage({ val: buffer.slice(0,len) });
|
||
|
||
var more=buffer.subarray(len,pos);
|
||
buffer=new Float32Array(bufferSize*2);
|
||
buffer.set(more);
|
||
pos=more.length;
|
||
This.buffer=buffer;
|
||
}
|
||
This.pos=pos;
|
||
}
|
||
return !This.kill;
|
||
});
|
||
clazz+='}'
|
||
+'try{'
|
||
+'registerProcessor("'+RecProc+'", '+RecProc+')'
|
||
+'}catch(e){'
|
||
+'console.error("'+recAudioWorklet+'注册失败",e)'
|
||
+'}';
|
||
//URL.createObjectURL 本地有些浏览器会报 Not allowed to load local resource,直接用dataurl
|
||
return "data:text/javascript;base64,"+btoa(unescape(encodeURIComponent(clazz)));
|
||
};
|
||
|
||
var awNext=function(){//可以继续,没有调用断开
|
||
return isWorklet && stream._na;
|
||
};
|
||
var nodeAlive=stream._na=function(){
|
||
//start时会调用,只要没有收到数据就断定AudioWorklet有问题,恢复用老的
|
||
if(badInt!==""){//没有回调过数据
|
||
clearTimeout(badInt);
|
||
badInt=setTimeout(function(){
|
||
badInt=0;
|
||
if(awNext()){
|
||
CLog(audioWorklet+"未返回任何音频,恢复使用"+scriptProcessor,3);
|
||
oldFn&&oldScript();//未来没有老的,可能是误判
|
||
};
|
||
},500);
|
||
};
|
||
};
|
||
var createNode=function(){
|
||
if(!awNext())return;
|
||
var node=stream._n=new AwNode(ctx, RecProc, {
|
||
processorOptions:{bufferSize:bufferSize}
|
||
});
|
||
mediaConn(node);
|
||
node.port.onmessage=function(e){
|
||
if(badInt){
|
||
clearTimeout(badInt);badInt="";
|
||
};
|
||
if(awNext()){
|
||
onReceive(e.data.val);
|
||
}else if(!isWorklet){
|
||
CLog(audioWorklet+"多余回调",3);
|
||
};
|
||
};
|
||
CLog("Connect采用"+audioWorklet+",设置"+RecTxt+"."+ConnectEnableWorklet+"=false可恢复老式"+scriptProcessor+webMTips+oldIsBest,3);
|
||
};
|
||
|
||
//如果start时的resume和下面的构造node同时进行,将会导致部分浏览器崩溃,源码assets中 ztest_chrome_bug_AudioWorkletNode.html 可测试。所以,将所有代码套到resume里面(不管catch),避免出现这个问题
|
||
ctx.resume()[calls&&"finally"](function(){//注释掉这行 观摩浏览器崩溃 STATUS_ACCESS_VIOLATION
|
||
if(!awNext())return;
|
||
if(ctx[RecProc]){
|
||
createNode();
|
||
return;
|
||
};
|
||
var url=clazzUrl();
|
||
ctx[audioWorklet].addModule(url).then(function(e){
|
||
if(!awNext())return;
|
||
ctx[RecProc]=1;
|
||
createNode();
|
||
if(badInt){//重新计时
|
||
nodeAlive();
|
||
};
|
||
})[CatchTxt](function(e){ //fix 关键字,保证catch压缩时保持字符串形式
|
||
CLog(audioWorklet+".addModule失败",1,e);
|
||
awNext()&&oldScript();
|
||
});
|
||
});
|
||
};
|
||
|
||
|
||
//===================连接方式一=========================
|
||
var connWebM=function(){
|
||
//尝试开启MediaRecorder录制webm+pcm处理
|
||
var MR=window[MediaRecorderTxt];
|
||
var onData="ondataavailable";
|
||
var webmType="audio/webm; codecs=pcm";
|
||
isWebM=stream.isWebM=Recorder[ConnectEnableWebM];
|
||
|
||
var supportMR=MR && (onData in MR.prototype) && MR.isTypeSupported(webmType);
|
||
webMTips=supportMR?"":"(此浏览器不支持"+MRWebMPCM+")";
|
||
if(!isUserMedia || !isWebM || !supportMR){
|
||
connWorklet(); //非麦克风录音(MediaRecorder采样率不可控) 或 被禁用 或 不支持MediaRecorder 或 不支持webm+pcm
|
||
return;
|
||
}
|
||
|
||
var mrNext=function(){//可以继续,没有调用断开
|
||
return isWebM && stream._ra;
|
||
};
|
||
var mrAlive=stream._ra=function(){
|
||
//start时会调用,只要没有收到数据就断定MediaRecorder有问题,降级处理
|
||
if(badInt!==""){//没有回调过数据
|
||
clearTimeout(badInt);
|
||
badInt=setTimeout(function(){
|
||
//badInt=0; 保留给nodeAlive继续判断
|
||
if(mrNext()){
|
||
CLog(MediaRecorderTxt+"未返回任何音频,降级使用"+audioWorklet,3);
|
||
connWorklet();
|
||
};
|
||
},500);
|
||
};
|
||
};
|
||
|
||
var mrSet=Object.assign({mimeType:webmType}, Recorder.ConnectWebMOptions);
|
||
var mr=stream._r=new MR(stream, mrSet);
|
||
var webmData=stream._rd={sampleRate:ctx[sampleRateTxt]};
|
||
mr[onData]=function(e){
|
||
//提取webm中的pcm数据,提取失败就等着badInt超时降级处理
|
||
var reader=new FileReader();
|
||
reader.onloadend=function(){
|
||
if(mrNext()){
|
||
var f32arr=WebM_Extract(new Uint8Array(reader.result),webmData);
|
||
if(!f32arr)return;
|
||
if(f32arr==-1){//无法提取,立即降级
|
||
connWorklet();
|
||
return;
|
||
};
|
||
|
||
if(badInt){
|
||
clearTimeout(badInt);badInt="";
|
||
};
|
||
onReceive(f32arr);
|
||
}else if(!isWebM){
|
||
CLog(MediaRecorderTxt+"多余回调",3);
|
||
};
|
||
};
|
||
reader.readAsArrayBuffer(e.data);
|
||
};
|
||
mr.start(~~(bufferSize/48));//按48k时的回调间隔
|
||
CLog("Connect采用"+MRWebMPCM+",设置"+RecTxt+"."+ConnectEnableWebM+"=false可恢复使用"+audioWorklet+"或老式"+scriptProcessor);
|
||
};
|
||
|
||
connWebM();
|
||
};
|
||
var ConnAlive=function(stream){
|
||
if(stream._na) stream._na(); //检查AudioWorklet连接是否有效,无效就回滚到老的ScriptProcessor
|
||
if(stream._ra) stream._ra(); //检查MediaRecorder连接是否有效,无效就降级处理
|
||
};
|
||
var _Disconn_n=function(stream){
|
||
stream._na=null;
|
||
if(stream._n){
|
||
stream._n.port.postMessage({kill:true});
|
||
stream._n.disconnect();
|
||
stream._n=null;
|
||
};
|
||
};
|
||
var _Disconn_r=function(stream){
|
||
stream._ra=null;
|
||
if(stream._r){
|
||
stream._r.stop();
|
||
stream._r=null;
|
||
};
|
||
};
|
||
var Disconnect=function(streamStore){
|
||
streamStore=streamStore||Recorder;
|
||
var isGlobal=streamStore==Recorder;
|
||
|
||
var stream=streamStore.Stream;
|
||
if(stream){
|
||
if(stream._m){
|
||
stream._m.disconnect();
|
||
stream._m=null;
|
||
};
|
||
if(stream._p){
|
||
stream._p.disconnect();
|
||
stream._p.onaudioprocess=stream._p=null;
|
||
};
|
||
_Disconn_n(stream);
|
||
_Disconn_r(stream);
|
||
|
||
if(isGlobal){//全局的时候,要把流关掉(麦克风),直接提供的流不处理
|
||
var tracks=stream.getTracks&&stream.getTracks()||stream.audioTracks||[];
|
||
for(var i=0;i<tracks.length;i++){
|
||
var track=tracks[i];
|
||
track.stop&&track.stop();
|
||
};
|
||
stream.stop&&stream.stop();
|
||
};
|
||
};
|
||
streamStore.Stream=0;
|
||
};
|
||
|
||
/*对pcm数据的采样率进行转换
|
||
pcmDatas: [[Int16,...]] pcm片段列表
|
||
pcmSampleRate:48000 pcm数据的采样率
|
||
newSampleRate:16000 需要转换成的采样率,newSampleRate>=pcmSampleRate时不会进行任何处理,小于时会进行重新采样
|
||
prevChunkInfo:{} 可选,上次调用时的返回值,用于连续转换,本次调用将从上次结束位置开始进行处理。或可自行定义一个ChunkInfo从pcmDatas指定的位置开始进行转换
|
||
option:{ 可选,配置项
|
||
frameSize:123456 帧大小,每帧的PCM Int16的数量,采样率转换后的pcm长度为frameSize的整数倍,用于连续转换。目前仅在mp3格式时才有用,frameSize取值为1152,这样编码出来的mp3时长和pcm的时长完全一致,否则会因为mp3最后一帧录音不够填满时添加填充数据导致mp3的时长变长。
|
||
frameType:"" 帧类型,一般为rec.set.type,提供此参数时无需提供frameSize,会自动使用最佳的值给frameSize赋值,目前仅支持mp3=1152(MPEG1 Layer3的每帧采采样数),其他类型=1。
|
||
以上两个参数用于连续转换时使用,最多使用一个,不提供时不进行帧的特殊处理,提供时必须同时提供prevChunkInfo才有作用。最后一段数据处理时无需提供帧大小以便输出最后一丁点残留数据。
|
||
}
|
||
|
||
返回ChunkInfo:{
|
||
//可定义,从指定位置开始转换到结尾
|
||
index:0 pcmDatas已处理到的索引
|
||
offset:0.0 已处理到的index对应的pcm中的偏移的下一个位置
|
||
|
||
//仅作为返回值
|
||
frameNext:null||[Int16,...] 下一帧的部分数据,frameSize设置了的时候才可能会有
|
||
sampleRate:16000 结果的采样率,<=newSampleRate
|
||
data:[Int16,...] 转换后的PCM结果;如果是连续转换,并且pcmDatas中并没有新数据时,data的长度可能为0
|
||
}
|
||
*/
|
||
Recorder.SampleData=function(pcmDatas,pcmSampleRate,newSampleRate,prevChunkInfo,option){
|
||
prevChunkInfo||(prevChunkInfo={});
|
||
var index=prevChunkInfo.index||0;
|
||
var offset=prevChunkInfo.offset||0;
|
||
|
||
var frameNext=prevChunkInfo.frameNext||[];
|
||
option||(option={});
|
||
var frameSize=option.frameSize||1;
|
||
if(option.frameType){
|
||
frameSize=option.frameType=="mp3"?1152:1;
|
||
};
|
||
|
||
var nLen=pcmDatas.length;
|
||
if(index>nLen+1){
|
||
CLog("SampleData似乎传入了未重置chunk "+index+">"+nLen,3);
|
||
};
|
||
var size=0;
|
||
for(var i=index;i<nLen;i++){
|
||
size+=pcmDatas[i].length;
|
||
};
|
||
size=Math.max(0,size-Math.floor(offset));
|
||
|
||
//采样 https://www.cnblogs.com/blqw/p/3782420.html
|
||
var step=pcmSampleRate/newSampleRate;
|
||
if(step>1){//新采样低于录音采样,进行抽样
|
||
size=Math.floor(size/step);
|
||
}else{//新采样高于录音采样不处理,省去了插值处理
|
||
step=1;
|
||
newSampleRate=pcmSampleRate;
|
||
};
|
||
|
||
size+=frameNext.length;
|
||
var res=new Int16Array(size);
|
||
var idx=0;
|
||
//添加上一次不够一帧的剩余数据
|
||
for(var i=0;i<frameNext.length;i++){
|
||
res[idx]=frameNext[i];
|
||
idx++;
|
||
};
|
||
//处理数据
|
||
for (;index<nLen;index++) {
|
||
var o=pcmDatas[index];
|
||
var i=offset,il=o.length;
|
||
while(i<il){
|
||
//res[idx]=o[Math.round(i)]; 直接简单抽样
|
||
|
||
//https://www.cnblogs.com/xiaoqi/p/6993912.html
|
||
//当前点=当前点+到后面一个点之间的增量,音质比直接简单抽样好些
|
||
var before = Math.floor(i);
|
||
var after = Math.ceil(i);
|
||
var atPoint = i - before;
|
||
|
||
var beforeVal=o[before];
|
||
var afterVal=after<il ? o[after]
|
||
: (//后个点越界了,查找下一个数组
|
||
(pcmDatas[index+1]||[beforeVal])[0]||0
|
||
);
|
||
res[idx]=beforeVal+(afterVal-beforeVal)*atPoint;
|
||
|
||
idx++;
|
||
i+=step;//抽样
|
||
};
|
||
offset=i-il;
|
||
};
|
||
//帧处理
|
||
frameNext=null;
|
||
var frameNextSize=res.length%frameSize;
|
||
if(frameNextSize>0){
|
||
var u8Pos=(res.length-frameNextSize)*2;
|
||
frameNext=new Int16Array(res.buffer.slice(u8Pos));
|
||
res=new Int16Array(res.buffer.slice(0,u8Pos));
|
||
};
|
||
|
||
return {
|
||
index:index
|
||
,offset:offset
|
||
|
||
,frameNext:frameNext
|
||
,sampleRate:newSampleRate
|
||
,data:res
|
||
};
|
||
};
|
||
|
||
|
||
/*计算音量百分比的一个方法
|
||
pcmAbsSum: pcm Int16所有采样的绝对值的和
|
||
pcmLength: pcm长度
|
||
返回值:0-100,主要当做百分比用
|
||
注意:这个不是分贝,因此没用volume当做名称*/
|
||
Recorder.PowerLevel=function(pcmAbsSum,pcmLength){
|
||
/*计算音量 https://blog.csdn.net/jody1989/article/details/73480259
|
||
更高灵敏度算法:
|
||
限定最大感应值10000
|
||
线性曲线:低音量不友好
|
||
power/10000*100
|
||
对数曲线:低音量友好,但需限定最低感应值
|
||
(1+Math.log10(power/10000))*100
|
||
*/
|
||
var power=(pcmAbsSum/pcmLength) || 0;//NaN
|
||
var level;
|
||
if(power<1251){//1250的结果10%,更小的音量采用线性取值
|
||
level=Math.round(power/1250*10);
|
||
}else{
|
||
level=Math.round(Math.min(100,Math.max(0,(1+Math.log(power/10000)/Math.log(10))*100)));
|
||
};
|
||
return level;
|
||
};
|
||
|
||
/*计算音量,单位dBFS(满刻度相对电平)
|
||
maxSample: 为16位pcm采样的绝对值中最大的一个(计算峰值音量),或者为pcm中所有采样的绝对值的平局值
|
||
返回值:-100~0 (最大值0dB,最小值-100代替-∞)
|
||
*/
|
||
Recorder.PowerDBFS=function(maxSample){
|
||
var val=Math.max(0.1, maxSample||0),Pref=0x7FFF;
|
||
val=Math.min(val,Pref);
|
||
//https://www.logiclocmusic.com/can-you-tell-the-decibel/
|
||
//https://blog.csdn.net/qq_17256689/article/details/120442510
|
||
val=20*Math.log(val/Pref)/Math.log(10);
|
||
return Math.max(-100,Math.round(val));
|
||
};
|
||
|
||
|
||
|
||
|
||
//带时间的日志输出,可设为一个空函数来屏蔽日志输出
|
||
//CLog(msg,errOrLogMsg, logMsg...) err为数字时代表日志类型1:error 2:log默认 3:warn,否则当做内容输出,第一个参数不能是对象因为要拼接时间,后面可以接无数个输出参数
|
||
Recorder.CLog=function(msg,err){
|
||
var now=new Date();
|
||
var t=("0"+now.getMinutes()).substr(-2)
|
||
+":"+("0"+now.getSeconds()).substr(-2)
|
||
+"."+("00"+now.getMilliseconds()).substr(-3);
|
||
var recID=this&&this.envIn&&this.envCheck&&this.id;
|
||
var arr=["["+t+" "+RecTxt+(recID?":"+recID:"")+"]"+msg];
|
||
var a=arguments,console=window.console||{};
|
||
var i=2,fn=console.log;
|
||
if(typeof(err)=="number"){
|
||
fn=err==1?console.error:err==3?console.warn:fn;
|
||
}else{
|
||
i=1;
|
||
};
|
||
for(;i<a.length;i++){
|
||
arr.push(a[i]);
|
||
};
|
||
if(IsLoser){//古董浏览器,仅保证基本的可执行不代码异常
|
||
fn&&fn("[IsLoser]"+arr[0],arr.length>1?arr:"");
|
||
}else{
|
||
fn.apply(console,arr);
|
||
};
|
||
};
|
||
var CLog=function(){ Recorder.CLog.apply(this,arguments); };
|
||
var IsLoser=true;try{IsLoser=!console.log.apply;}catch(e){};
|
||
|
||
|
||
|
||
|
||
var ID=0;
|
||
function initFn(set){
|
||
this.id=++ID;
|
||
|
||
//如果开启了流量统计,这里将发送一个图片请求
|
||
Traffic();
|
||
|
||
|
||
var o={
|
||
type:"mp3" //输出类型:mp3,wav,wav输出文件尺寸超大不推荐使用,但mp3编码支持会导致js文件超大,如果不需支持mp3可以使js文件大幅减小
|
||
,bitRate:16 //比特率 wav:16或8位,MP3:8kbps 1k/s,8kbps 2k/s 录音文件很小
|
||
|
||
,sampleRate:16000 //采样率,wav格式大小=sampleRate*时间;mp3此项对低比特率有影响,高比特率几乎无影响。
|
||
//wav任意值,mp3取值范围:48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000
|
||
//采样率参考https://www.cnblogs.com/devin87/p/mp3-recorder.html
|
||
|
||
,onProcess:NOOP //fn(buffers,powerLevel,bufferDuration,bufferSampleRate,newBufferIdx,asyncEnd) buffers=[[Int16,...],...]:缓冲的PCM数据,为从开始录音到现在的所有pcm片段;powerLevel:当前缓冲的音量级别0-100,bufferDuration:已缓冲时长,bufferSampleRate:缓冲使用的采样率(当type支持边录边转码(Worker)时,此采样率和设置的采样率相同,否则不一定相同);newBufferIdx:本次回调新增的buffer起始索引;asyncEnd:fn() 如果onProcess是异步的(返回值为true时),处理完成时需要调用此回调,如果不是异步的请忽略此参数,此方法回调时必须是真异步(不能真异步时需用setTimeout包裹)。onProcess返回值:如果返回true代表开启异步模式,在某些大量运算的场合异步是必须的,必须在异步处理完成时调用asyncEnd(不能真异步时需用setTimeout包裹),在onProcess执行后新增的buffer会全部替换成空数组,因此本回调开头应立即将newBufferIdx到本次回调结尾位置的buffer全部保存到另外一个数组内,处理完成后写回buffers中本次回调的结尾位置。
|
||
|
||
//*******高级设置******
|
||
//,sourceStream:MediaStream Object
|
||
//可选直接提供一个媒体流,从这个流中录制、实时处理音频数据(当前Recorder实例独享此流);不提供时为普通的麦克风录音,由getUserMedia提供音频流(所有Recorder实例共享同一个流)
|
||
//比如:audio、video标签dom节点的captureStream方法(实验特性,不同浏览器支持程度不高)返回的流;WebRTC中的remote流;自己创建的流等
|
||
//注意:流内必须至少存在一条音轨(Audio Track),比如audio标签必须等待到可以开始播放后才会有音轨,否则open会失败
|
||
|
||
//,audioTrackSet:{ deviceId:"",groupId:"", autoGainControl:true, echoCancellation:true, noiseSuppression:true }
|
||
//普通麦克风录音时getUserMedia方法的audio配置参数,比如指定设备id,回声消除、降噪开关;注意:提供的任何配置值都不一定会生效
|
||
//由于麦克风是全局共享的,所以新配置后需要close掉以前的再重新open
|
||
//更多参考: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints
|
||
|
||
//,disableEnvInFix:false 内部参数,禁用设备卡顿时音频输入丢失补偿功能
|
||
|
||
//,takeoffEncodeChunk:NOOP //fn(chunkBytes) chunkBytes=[Uint8,...]:实时编码环境下接管编码器输出,当编码器实时编码出一块有效的二进制音频数据时实时回调此方法;参数为二进制的Uint8Array,就是编码出来的音频数据片段,所有的chunkBytes拼接在一起即为完整音频。本实现的想法最初由QQ2543775048提出
|
||
//当提供此回调方法时,将接管编码器的数据输出,编码器内部将放弃存储生成的音频数据;环境要求比较苛刻:如果当前环境不支持实时编码处理,将在open时直接走fail逻辑
|
||
//因此提供此回调后调用stop方法将无法获得有效的音频数据,因为编码器内没有音频数据,因此stop时返回的blob将是一个字节长度为0的blob
|
||
//目前只有mp3格式实现了实时编码,在支持实时处理的环境中将会实时的将编码出来的mp3片段通过此方法回调,所有的chunkBytes拼接到一起即为完整的mp3,此种拼接的结果比mock方法实时生成的音质更加,因为天然避免了首尾的静默
|
||
//目前除mp3外其他格式不可以提供此回调,提供了将在open时直接走fail逻辑
|
||
};
|
||
|
||
for(var k in set){
|
||
o[k]=set[k];
|
||
};
|
||
this.set=o;
|
||
|
||
this._S=9;//stop同步锁,stop可以阻止open过程中还未运行的start
|
||
this.Sync={O:9,C:9};//和Recorder.Sync一致,只不过这个是非全局的,仅用来简化代码逻辑,无实际作用
|
||
};
|
||
//同步锁,控制对Stream的竞争;用于close时中断异步的open;一个对象open如果变化了都要阻止close,Stream的控制权交个新的对象
|
||
Recorder.Sync={/*open*/O:9,/*close*/C:9};
|
||
|
||
Recorder.prototype=initFn.prototype={
|
||
CLog:CLog
|
||
|
||
//流相关的数据存储在哪个对象里面;如果提供了sourceStream,数据直接存储在当前对象中,否则存储在全局
|
||
,_streamStore:function(){
|
||
if(this.set.sourceStream){
|
||
return this;
|
||
}else{
|
||
return Recorder;
|
||
}
|
||
}
|
||
|
||
//打开录音资源True(),False(msg,isUserNotAllow),需要调用close。注意:此方法是异步的;一般使用时打开,用完立即关闭;可重复调用,可用来测试是否能录音
|
||
,open:function(True,False){
|
||
var This=this,streamStore=This._streamStore();
|
||
True=True||NOOP;
|
||
var failCall=function(errMsg,isUserNotAllow){
|
||
isUserNotAllow=!!isUserNotAllow;
|
||
This.CLog("录音open失败:"+errMsg+",isUserNotAllow:"+isUserNotAllow,1);
|
||
False&&False(errMsg,isUserNotAllow);
|
||
};
|
||
|
||
var ok=function(){
|
||
This.CLog("open ok id:"+This.id);
|
||
True();
|
||
|
||
This._SO=0;//解除stop对open中的start调用的阻止
|
||
};
|
||
|
||
|
||
//同步锁
|
||
var Lock=streamStore.Sync;
|
||
var lockOpen=++Lock.O,lockClose=Lock.C;
|
||
This._O=This._O_=lockOpen;//记住当前的open,如果变化了要阻止close,这里假定了新对象已取代当前对象并且不再使用
|
||
This._SO=This._S;//记住open过程中的stop,中途任何stop调用后都不能继续open中的start
|
||
var lockFail=function(){
|
||
//允许多次open,但不允许任何一次close,或者自身已经调用了关闭
|
||
if(lockClose!=Lock.C || !This._O){
|
||
var err="open被取消";
|
||
if(lockOpen==Lock.O){
|
||
//无新的open,已经调用了close进行取消,此处应让上次的close明确生效
|
||
This.close();
|
||
}else{
|
||
err="open被中断";
|
||
};
|
||
failCall(err);
|
||
return true;
|
||
};
|
||
};
|
||
|
||
//环境配置检查
|
||
var checkMsg=This.envCheck({envName:"H5",canProcess:true});
|
||
if(checkMsg){
|
||
failCall("不能录音:"+checkMsg);
|
||
return;
|
||
};
|
||
|
||
|
||
//***********已直接提供了音频流************
|
||
if(This.set.sourceStream){
|
||
if(!Recorder.GetContext()){
|
||
failCall("不支持此浏览器从流中获取录音");
|
||
return;
|
||
};
|
||
|
||
Disconnect(streamStore);//可能已open过,直接先尝试断开
|
||
This.Stream=This.set.sourceStream;
|
||
This.Stream._call={};
|
||
|
||
try{
|
||
Connect(streamStore);
|
||
}catch(e){
|
||
failCall("从流中打开录音失败:"+e.message);
|
||
return;
|
||
}
|
||
ok();
|
||
return;
|
||
};
|
||
|
||
|
||
//***********打开麦克风得到全局的音频流************
|
||
var codeFail=function(code,msg){
|
||
try{//跨域的优先检测一下
|
||
window.top.a;
|
||
}catch(e){
|
||
failCall('无权录音(跨域,请尝试给iframe添加麦克风访问策略,如allow="camera;microphone")');
|
||
return;
|
||
};
|
||
|
||
if(/Permission|Allow/i.test(code)){
|
||
failCall("用户拒绝了录音权限",true);
|
||
}else if(window.isSecureContext===false){
|
||
failCall("浏览器禁止不安全页面录音,可开启https解决");
|
||
}else if(/Found/i.test(code)){//可能是非安全环境导致的没有设备
|
||
failCall(msg+",无可用麦克风");
|
||
}else{
|
||
failCall(msg);
|
||
};
|
||
};
|
||
|
||
|
||
//如果已打开并且有效就不要再打开了
|
||
if(Recorder.IsOpen()){
|
||
ok();
|
||
return;
|
||
};
|
||
if(!Recorder.Support()){
|
||
codeFail("","此浏览器不支持录音");
|
||
return;
|
||
};
|
||
|
||
//请求权限,如果从未授权,一般浏览器会弹出权限请求弹框
|
||
var f1=function(stream){
|
||
//https://github.com/xiangyuecn/Recorder/issues/14 获取到的track.readyState!="live",刚刚回调时可能是正常的,但过一下可能就被关掉了,原因不明。延迟一下保证真异步。对正常浏览器不影响
|
||
setTimeout(function(){
|
||
stream._call={};
|
||
var oldStream=Recorder.Stream;
|
||
if(oldStream){
|
||
Disconnect(); //直接断开已存在的,旧的Connect未完成会自动终止
|
||
stream._call=oldStream._call;
|
||
};
|
||
Recorder.Stream=stream;
|
||
if(lockFail())return;
|
||
|
||
if(Recorder.IsOpen()){
|
||
if(oldStream)This.CLog("发现同时多次调用open",1);
|
||
|
||
Connect(streamStore,1);
|
||
ok();
|
||
}else{
|
||
failCall("录音功能无效:无音频流");
|
||
};
|
||
},100);
|
||
};
|
||
var f2=function(e){
|
||
var code=e.name||e.message||e.code+":"+e;
|
||
This.CLog("请求录音权限错误",1,e);
|
||
|
||
codeFail(code,"无法录音:"+code);
|
||
};
|
||
|
||
var trackSet={
|
||
noiseSuppression:false //默认禁用降噪,原声录制,免得移动端表现怪异(包括系统播放声音变小)
|
||
,echoCancellation:false //回声消除
|
||
};
|
||
var trackSet2=This.set.audioTrackSet;
|
||
for(var k in trackSet2)trackSet[k]=trackSet2[k];
|
||
trackSet.sampleRate=Recorder.Ctx.sampleRate;//必须指明采样率,不然手机上MediaRecorder采样率16k
|
||
|
||
try{
|
||
var pro=Recorder.Scope[getUserMediaTxt]({audio:trackSet},f1,f2);
|
||
}catch(e){//不能设置trackSet就算了
|
||
This.CLog(getUserMediaTxt,3,e);
|
||
pro=Recorder.Scope[getUserMediaTxt]({audio:true},f1,f2);
|
||
};
|
||
if(pro&&pro.then){
|
||
pro.then(f1)[CatchTxt](f2); //fix 关键字,保证catch压缩时保持字符串形式
|
||
};
|
||
}
|
||
//关闭释放录音资源
|
||
,close:function(call){
|
||
call=call||NOOP;
|
||
|
||
var This=this,streamStore=This._streamStore();
|
||
This._stop();
|
||
|
||
var Lock=streamStore.Sync;
|
||
This._O=0;
|
||
if(This._O_!=Lock.O){
|
||
//唯一资源Stream的控制权已交给新对象,这里不能关闭。此处在每次都弹权限的浏览器内可能存在泄漏,新对象被拒绝权限可能不会调用close,忽略这种不处理
|
||
This.CLog("close被忽略(因为同时open了多个rec,只有最后一个会真正close)",3);
|
||
call();
|
||
return;
|
||
};
|
||
Lock.C++;//获得控制权
|
||
|
||
Disconnect(streamStore);
|
||
|
||
This.CLog("close");
|
||
call();
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
/*模拟一段录音数据,后面可以调用stop进行编码,需提供pcm数据[1,2,3...],pcm的采样率*/
|
||
,mock:function(pcmData,pcmSampleRate){
|
||
var This=this;
|
||
This._stop();//清理掉已有的资源
|
||
|
||
This.isMock=1;
|
||
This.mockEnvInfo=null;
|
||
This.buffers=[pcmData];
|
||
This.recSize=pcmData.length;
|
||
This[srcSampleRateTxt]=pcmSampleRate;
|
||
return This;
|
||
}
|
||
,envCheck:function(envInfo){//平台环境下的可用性检查,任何时候都可以调用检查,返回errMsg:""正常,"失败原因"
|
||
//envInfo={envName:"H5",canProcess:true}
|
||
var errMsg,This=this,set=This.set;
|
||
|
||
//检测CPU的数字字节序,TypedArray字节序是个迷,直接拒绝罕见的大端模式,因为找不到这种CPU进行测试
|
||
var tag="CPU_BE";
|
||
if(!errMsg && !Recorder[tag] && window.Int8Array && !new Int8Array(new Int32Array([1]).buffer)[0]){
|
||
Traffic(tag); //如果开启了流量统计,这里将发送一个图片请求
|
||
errMsg="不支持"+tag+"架构";
|
||
};
|
||
|
||
//编码器检查环境下配置是否可用
|
||
if(!errMsg){
|
||
var type=set.type;
|
||
if(This[type+"_envCheck"]){//编码器已实现环境检查
|
||
errMsg=This[type+"_envCheck"](envInfo,set);
|
||
}else{//未实现检查的手动检查配置是否有效
|
||
if(set.takeoffEncodeChunk){
|
||
errMsg=type+"类型"+(This[type]?"":"(未加载编码器)")+"不支持设置takeoffEncodeChunk";
|
||
};
|
||
};
|
||
};
|
||
|
||
return errMsg||"";
|
||
}
|
||
,envStart:function(mockEnvInfo,sampleRate){//平台环境相关的start调用
|
||
var This=this,set=This.set;
|
||
This.isMock=mockEnvInfo?1:0;//非H5环境需要启用mock,并提供envCheck需要的环境信息
|
||
This.mockEnvInfo=mockEnvInfo;
|
||
This.buffers=[];//数据缓冲
|
||
This.recSize=0;//数据大小
|
||
|
||
This.envInLast=0;//envIn接收到最后录音内容的时间
|
||
This.envInFirst=0;//envIn接收到的首个录音内容的录制时间
|
||
This.envInFix=0;//补偿的总时间
|
||
This.envInFixTs=[];//补偿计数列表
|
||
|
||
//engineCtx需要提前确定最终的采样率
|
||
var setSr=set[sampleRateTxt];
|
||
if(setSr>sampleRate){
|
||
set[sampleRateTxt]=sampleRate;
|
||
}else{ setSr=0 }
|
||
This[srcSampleRateTxt]=sampleRate;
|
||
This.CLog(srcSampleRateTxt+": "+sampleRate+" set."+sampleRateTxt+": "+set[sampleRateTxt]+(setSr?" 忽略"+setSr:""), setSr?3:0);
|
||
|
||
This.engineCtx=0;
|
||
//此类型有边录边转码(Worker)支持
|
||
if(This[set.type+"_start"]){
|
||
var engineCtx=This.engineCtx=This[set.type+"_start"](set);
|
||
if(engineCtx){
|
||
engineCtx.pcmDatas=[];
|
||
engineCtx.pcmSize=0;
|
||
};
|
||
};
|
||
}
|
||
,envResume:function(){//和平台环境无关的恢复录音
|
||
//重新开始计数
|
||
this.envInFixTs=[];
|
||
}
|
||
,envIn:function(pcm,sum){//和平台环境无关的pcm[Int16]输入
|
||
var This=this,set=This.set,engineCtx=This.engineCtx;
|
||
var bufferSampleRate=This[srcSampleRateTxt];
|
||
var size=pcm.length;
|
||
var powerLevel=Recorder.PowerLevel(sum,size);
|
||
|
||
var buffers=This.buffers;
|
||
var bufferFirstIdx=buffers.length;//之前的buffer都是经过onProcess处理好的,不允许再修改
|
||
buffers.push(pcm);
|
||
|
||
//有engineCtx时会被覆盖,这里保存一份
|
||
var buffersThis=buffers;
|
||
var bufferFirstIdxThis=bufferFirstIdx;
|
||
|
||
//卡顿丢失补偿:因为设备很卡的时候导致H5接收到的数据量不够造成播放时候变速,结果比实际的时长要短,此处保证了不会变短,但不能修复丢失的音频数据造成音质变差。当前算法采用输入时间侦测下一帧是否需要添加补偿帧,需要(6次输入||超过1秒)以上才会开始侦测,如果滑动窗口内丢失超过1/3就会进行补偿
|
||
var now=Date.now();
|
||
var pcmTime=Math.round(size/bufferSampleRate*1000);
|
||
This.envInLast=now;
|
||
if(This.buffers.length==1){//记下首个录音数据的录制时间
|
||
This.envInFirst=now-pcmTime;
|
||
};
|
||
var envInFixTs=This.envInFixTs;
|
||
envInFixTs.splice(0,0,{t:now,d:pcmTime});
|
||
//保留3秒的计数滑动窗口,另外超过3秒的停顿不补偿
|
||
var tsInStart=now,tsPcm=0;
|
||
for(var i=0;i<envInFixTs.length;i++){
|
||
var o=envInFixTs[i];
|
||
if(now-o.t>3000){
|
||
envInFixTs.length=i;
|
||
break;
|
||
};
|
||
tsInStart=o.t;
|
||
tsPcm+=o.d;
|
||
};
|
||
//达到需要的数据量,开始侦测是否需要补偿
|
||
var tsInPrev=envInFixTs[1];
|
||
var tsIn=now-tsInStart;
|
||
var lost=tsIn-tsPcm;
|
||
if( lost>tsIn/3 && (tsInPrev&&tsIn>1000 || envInFixTs.length>=6) ){
|
||
//丢失过多,开始执行补偿
|
||
var addTime=now-tsInPrev.t-pcmTime;//距离上次输入丢失这么多ms
|
||
if(addTime>pcmTime/5){//丢失超过本帧的1/5
|
||
var fixOpen=!set.disableEnvInFix;
|
||
This.CLog("["+now+"]"+(fixOpen?"":"未")+"补偿"+addTime+"ms",3);
|
||
This.envInFix+=addTime;
|
||
|
||
//用静默进行补偿
|
||
if(fixOpen){
|
||
var addPcm=new Int16Array(addTime*bufferSampleRate/1000);
|
||
size+=addPcm.length;
|
||
buffers.push(addPcm);
|
||
};
|
||
};
|
||
};
|
||
|
||
|
||
var sizeOld=This.recSize,addSize=size;
|
||
var bufferSize=sizeOld+addSize;
|
||
This.recSize=bufferSize;//此值在onProcess后需要修正,可能新数据被修改
|
||
|
||
|
||
//此类型有边录边转码(Worker)支持,开启实时转码
|
||
if(engineCtx){
|
||
//转换成set的采样率
|
||
var chunkInfo=Recorder.SampleData(buffers,bufferSampleRate,set[sampleRateTxt],engineCtx.chunkInfo);
|
||
engineCtx.chunkInfo=chunkInfo;
|
||
|
||
sizeOld=engineCtx.pcmSize;
|
||
addSize=chunkInfo.data.length;
|
||
bufferSize=sizeOld+addSize;
|
||
engineCtx.pcmSize=bufferSize;//此值在onProcess后需要修正,可能新数据被修改
|
||
|
||
buffers=engineCtx.pcmDatas;
|
||
bufferFirstIdx=buffers.length;
|
||
buffers.push(chunkInfo.data);
|
||
bufferSampleRate=chunkInfo[sampleRateTxt];
|
||
};
|
||
|
||
var duration=Math.round(bufferSize/bufferSampleRate*1000);
|
||
var bufferNextIdx=buffers.length;
|
||
var bufferNextIdxThis=buffersThis.length;
|
||
|
||
//允许异步处理buffer数据
|
||
var asyncEnd=function(){
|
||
//重新计算size,异步的早已减去添加的,同步的需去掉本次添加的然后重新计算
|
||
var num=asyncBegin?0:-addSize;
|
||
var hasClear=buffers[0]==null;
|
||
for(var i=bufferFirstIdx;i<bufferNextIdx;i++){
|
||
var buffer=buffers[i];
|
||
if(buffer==null){//已被主动释放内存,比如长时间实时传输录音时
|
||
hasClear=1;
|
||
}else{
|
||
num+=buffer.length;
|
||
|
||
//推入后台边录边转码
|
||
if(engineCtx&&buffer.length){
|
||
This[set.type+"_encode"](engineCtx,buffer);
|
||
};
|
||
};
|
||
};
|
||
|
||
//同步清理This.buffers,不管buffers到底清了多少个,buffersThis是使用不到的进行全清
|
||
if(hasClear && engineCtx){
|
||
var i=bufferFirstIdxThis;
|
||
if(buffersThis[0]){
|
||
i=0;
|
||
};
|
||
for(;i<bufferNextIdxThis;i++){
|
||
buffersThis[i]=null;
|
||
};
|
||
};
|
||
|
||
//统计修改后的size,如果异步发生clear要原样加回来,同步的无需操作
|
||
if(hasClear){
|
||
num=asyncBegin?addSize:0;
|
||
|
||
buffers[0]=null;//彻底被清理
|
||
};
|
||
if(engineCtx){
|
||
engineCtx.pcmSize+=num;
|
||
}else{
|
||
This.recSize+=num;
|
||
};
|
||
};
|
||
//实时回调处理数据,允许修改或替换上次回调以来新增的数据 ,但是不允许修改已处理过的,不允许增删第一维数组 ,允许将第二维数组任意修改替换成空数组也可以
|
||
var asyncBegin=0,procTxt="rec.set.onProcess";
|
||
try{
|
||
asyncBegin=set.onProcess(buffers,powerLevel,duration,bufferSampleRate,bufferFirstIdx,asyncEnd);
|
||
}catch(e){
|
||
//此错误显示不要用CLog,这样控制台内相同内容不会重复打印
|
||
console.error(procTxt+"回调出错是不允许的,需保证不会抛异常",e);
|
||
};
|
||
|
||
var slowT=Date.now()-now;
|
||
if(slowT>10 && This.envInFirst-now>1000){ //1秒后开始onProcess性能监测
|
||
This.CLog(procTxt+"低性能,耗时"+slowT+"ms",3);
|
||
};
|
||
|
||
if(asyncBegin===true){
|
||
//开启了异步模式,onProcess已接管buffers新数据,立即清空,避免出现未处理的数据
|
||
var hasClear=0;
|
||
for(var i=bufferFirstIdx;i<bufferNextIdx;i++){
|
||
if(buffers[i]==null){//已被主动释放内存,比如长时间实时传输录音时 ,但又要开启异步模式,此种情况是非法的
|
||
hasClear=1;
|
||
}else{
|
||
buffers[i]=new Int16Array(0);
|
||
};
|
||
};
|
||
|
||
if(hasClear){
|
||
This.CLog("未进入异步前不能清除buffers",3);
|
||
}else{
|
||
//还原size,异步结束后再统计仅修改后的size,如果发生clear要原样加回来
|
||
if(engineCtx){
|
||
engineCtx.pcmSize-=addSize;
|
||
}else{
|
||
This.recSize-=addSize;
|
||
};
|
||
};
|
||
}else{
|
||
asyncEnd();
|
||
};
|
||
}
|
||
|
||
|
||
|
||
|
||
//开始录音,需先调用open;只要open成功时,调用此方法是安全的,如果未open强行调用导致的内部错误将不会有任何提示,stop时自然能得到错误
|
||
,start:function(){
|
||
var This=this,ctx=Recorder.Ctx;
|
||
|
||
var isOpen=1;
|
||
if(This.set.sourceStream){//直接提供了流,仅判断是否调用了open
|
||
if(!This.Stream){
|
||
isOpen=0;
|
||
}
|
||
}else if(!Recorder.IsOpen()){//监测全局麦克风是否打开并且有效
|
||
isOpen=0;
|
||
};
|
||
if(!isOpen){
|
||
This.CLog("未open",1);
|
||
return;
|
||
};
|
||
This.CLog("开始录音");
|
||
|
||
This._stop();
|
||
This.state=3;//0未录音 1录音中 2暂停 3等待ctx激活
|
||
This.envStart(null, ctx[sampleRateTxt]);
|
||
|
||
//检查open过程中stop是否已经调用过
|
||
if(This._SO&&This._SO+1!=This._S){//上面调用过一次 _stop
|
||
//open未完成就调用了stop,此种情况终止start。也应尽量避免出现此情况
|
||
This.CLog("start被中断",3);
|
||
return;
|
||
};
|
||
This._SO=0;
|
||
|
||
var end=function(){
|
||
if(This.state==3){
|
||
This.state=1;
|
||
This.resume();
|
||
}
|
||
};
|
||
if(ctx.state=="suspended"){
|
||
var tag="AudioContext resume: ";
|
||
This.CLog(tag+"wait...");
|
||
ctx.resume().then(function(){
|
||
This.CLog(tag+ctx.state);
|
||
end();
|
||
})[CatchTxt](function(e){ //比较少见,可能对录音没有影响
|
||
This.CLog(tag+ctx.state+" 可能无法录音:"+e.message,1,e);
|
||
end();
|
||
});
|
||
}else{
|
||
end();
|
||
};
|
||
}
|
||
/*暂停录音*/
|
||
,pause:function(){
|
||
var This=this;
|
||
if(This.state){
|
||
This.state=2;
|
||
This.CLog("pause");
|
||
delete This._streamStore().Stream._call[This.id];
|
||
};
|
||
}
|
||
/*恢复录音*/
|
||
,resume:function(){
|
||
var This=this;
|
||
if(This.state){
|
||
This.state=1;
|
||
This.CLog("resume");
|
||
This.envResume();
|
||
|
||
var stream=This._streamStore().Stream;
|
||
stream._call[This.id]=function(pcm,sum){
|
||
if(This.state==1){
|
||
This.envIn(pcm,sum);
|
||
};
|
||
};
|
||
ConnAlive(stream);//AudioWorklet只会在ctx激活后运行
|
||
};
|
||
}
|
||
|
||
|
||
|
||
|
||
,_stop:function(keepEngine){
|
||
var This=this,set=This.set;
|
||
if(!This.isMock){
|
||
This._S++;
|
||
};
|
||
if(This.state){
|
||
This.pause();
|
||
This.state=0;
|
||
};
|
||
if(!keepEngine && This[set.type+"_stop"]){
|
||
This[set.type+"_stop"](This.engineCtx);
|
||
This.engineCtx=0;
|
||
};
|
||
}
|
||
/*
|
||
结束录音并返回录音数据blob对象
|
||
True(blob,duration) blob:录音数据audio/mp3|wav格式
|
||
duration:录音时长,单位毫秒
|
||
False(msg)
|
||
autoClose:false 可选,是否自动调用close,默认为false
|
||
*/
|
||
,stop:function(True,False,autoClose){
|
||
var This=this,set=This.set,t1;
|
||
var envInMS=This.envInLast-This.envInFirst, envInLen=envInMS&&This.buffers.length; //可能未start
|
||
This.CLog("stop 和start时差"+(envInMS?envInMS+"ms 补偿"+This.envInFix+"ms"+" envIn:"+envInLen+" fps:"+(envInLen/envInMS*1000).toFixed(1):"-"));
|
||
|
||
var end=function(){
|
||
This._stop();//彻底关掉engineCtx
|
||
if(autoClose){
|
||
This.close();
|
||
};
|
||
};
|
||
var err=function(msg){
|
||
This.CLog("结束录音失败:"+msg,1);
|
||
False&&False(msg);
|
||
end();
|
||
};
|
||
var ok=function(blob,duration){
|
||
This.CLog("结束录音 编码花"+(Date.now()-t1)+"ms 音频时长"+duration+"ms 文件大小"+blob.size+"b");
|
||
if(set.takeoffEncodeChunk){//接管了输出,此时blob长度为0
|
||
This.CLog("启用takeoffEncodeChunk后stop返回的blob长度为0不提供音频数据",3);
|
||
}else if(blob.size<Math.max(100,duration/2)){//1秒小于0.5k?
|
||
err("生成的"+set.type+"无效");
|
||
return;
|
||
};
|
||
True&&True(blob,duration);
|
||
end();
|
||
};
|
||
if(!This.isMock){
|
||
var isCtxWait=This.state==3;
|
||
if(!This.state || isCtxWait){
|
||
err("未开始录音"+(isCtxWait?",开始录音前无用户交互导致AudioContext未运行":""));
|
||
return;
|
||
};
|
||
This._stop(true);
|
||
};
|
||
var size=This.recSize;
|
||
if(!size){
|
||
err("未采集到录音");
|
||
return;
|
||
};
|
||
if(!This.buffers[0]){
|
||
err("音频buffers被释放");
|
||
return;
|
||
};
|
||
if(!This[set.type]){
|
||
err("未加载"+set.type+"编码器");
|
||
return;
|
||
};
|
||
|
||
//环境配置检查,此处仅针对mock调用,因为open已经检查过了
|
||
if(This.isMock){
|
||
var checkMsg=This.envCheck(This.mockEnvInfo||{envName:"mock",canProcess:false});//没有提供环境信息的mock时没有onProcess回调
|
||
if(checkMsg){
|
||
err("录音错误:"+checkMsg);
|
||
return;
|
||
};
|
||
};
|
||
|
||
//此类型有边录边转码(Worker)支持
|
||
var engineCtx=This.engineCtx;
|
||
if(This[set.type+"_complete"]&&engineCtx){
|
||
var duration=Math.round(engineCtx.pcmSize/set[sampleRateTxt]*1000);//采用后的数据长度和buffers的长度可能微小的不一致,是采样率连续转换的精度问题
|
||
|
||
t1=Date.now();
|
||
This[set.type+"_complete"](engineCtx,function(blob){
|
||
ok(blob,duration);
|
||
},err);
|
||
return;
|
||
};
|
||
|
||
//标准UI线程转码,调整采样率
|
||
t1=Date.now();
|
||
var chunk=Recorder.SampleData(This.buffers,This[srcSampleRateTxt],set[sampleRateTxt]);
|
||
|
||
set[sampleRateTxt]=chunk[sampleRateTxt];
|
||
var res=chunk.data;
|
||
var duration=Math.round(res.length/set[sampleRateTxt]*1000);
|
||
|
||
This.CLog("采样"+size+"->"+res.length+" 花:"+(Date.now()-t1)+"ms");
|
||
|
||
setTimeout(function(){
|
||
t1=Date.now();
|
||
This[set.type](res,function(blob){
|
||
ok(blob,duration);
|
||
},function(msg){
|
||
err(msg);
|
||
});
|
||
});
|
||
}
|
||
|
||
};
|
||
|
||
if(window[RecTxt]){
|
||
CLog("重复引入"+RecTxt,3);
|
||
window[RecTxt].Destroy();
|
||
};
|
||
window[RecTxt]=Recorder;
|
||
|
||
|
||
|
||
|
||
//=======从WebM字节流中提取pcm数据,提取成功返回Float32Array,失败返回null||-1=====
|
||
var WebM_Extract=function(inBytes, scope){
|
||
if(!scope.pos){
|
||
scope.pos=[0]; scope.tracks={}; scope.bytes=[];
|
||
};
|
||
var tracks=scope.tracks, position=[scope.pos[0]];
|
||
var endPos=function(){ scope.pos[0]=position[0] };
|
||
|
||
var sBL=scope.bytes.length;
|
||
var bytes=new Uint8Array(sBL+inBytes.length);
|
||
bytes.set(scope.bytes); bytes.set(inBytes,sBL);
|
||
scope.bytes=bytes;
|
||
|
||
//先读取文件头和Track信息
|
||
if(!scope._ht){
|
||
readMatroskaVInt(bytes, position);//EBML Header
|
||
readMatroskaBlock(bytes, position);//跳过EBML Header内容
|
||
if(!BytesEq(readMatroskaVInt(bytes, position), [0x18,0x53,0x80,0x67])){
|
||
return;//未识别到Segment
|
||
}
|
||
readMatroskaVInt(bytes, position);//跳过Segment长度值
|
||
while(position[0]<bytes.length){
|
||
var eid0=readMatroskaVInt(bytes, position);
|
||
var bytes0=readMatroskaBlock(bytes, position);
|
||
var pos0=[0],audioIdx=0;
|
||
if(!bytes0)return;//数据不全,等待缓冲
|
||
//Track完整数据,循环读取TrackEntry
|
||
if(BytesEq(eid0, [0x16,0x54,0xAE,0x6B])){
|
||
while(pos0[0]<bytes0.length){
|
||
var eid1=readMatroskaVInt(bytes0, pos0);
|
||
var bytes1=readMatroskaBlock(bytes0, pos0);
|
||
var pos1=[0],track={channels:0,sampleRate:0};
|
||
if(BytesEq(eid1, [0xAE])){//TrackEntry
|
||
while(pos1[0]<bytes1.length){
|
||
var eid2=readMatroskaVInt(bytes1, pos1);
|
||
var bytes2=readMatroskaBlock(bytes1, pos1);
|
||
var pos2=[0];
|
||
if(BytesEq(eid2, [0xD7])){//Track Number
|
||
var val=BytesInt(bytes2);
|
||
track.number=val;
|
||
tracks[val]=track;
|
||
}else if(BytesEq(eid2, [0x83])){//Track Type
|
||
var val=BytesInt(bytes2);
|
||
if(val==1) track.type="video";
|
||
else if(val==2) {
|
||
track.type="audio";
|
||
if(!audioIdx) scope.track0=track;
|
||
track.idx=audioIdx++;
|
||
}else track.type="Type-"+val;
|
||
}else if(BytesEq(eid2, [0x86])){//Track Codec
|
||
var str="";
|
||
for(var i=0;i<bytes2.length;i++){
|
||
str+=String.fromCharCode(bytes2[i]);
|
||
}
|
||
track.codec=str;
|
||
}else if(BytesEq(eid2, [0xE1])){
|
||
while(pos2[0]<bytes2.length){//循环读取 Audio 属性
|
||
var eid3=readMatroskaVInt(bytes2, pos2);
|
||
var bytes3=readMatroskaBlock(bytes2, pos2);
|
||
//采样率、位数、声道数
|
||
if(BytesEq(eid3, [0xB5])){
|
||
var val=0,arr=new Uint8Array(bytes3.reverse()).buffer;
|
||
if(bytes3.length==4) val=new Float32Array(arr)[0];
|
||
else if(bytes3.length==8) val=new Float64Array(arr)[0];
|
||
else CLog("WebM Track !Float",1,bytes3);
|
||
track[sampleRateTxt]=Math.round(val);
|
||
}else if(BytesEq(eid3, [0x62,0x64])) track.bitDepth=BytesInt(bytes3);
|
||
else if(BytesEq(eid3, [0x9F])) track.channels=BytesInt(bytes3);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
scope._ht=1;
|
||
CLog("WebM Tracks",tracks);
|
||
endPos();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
//校验音频参数信息,如果不符合代码要求,统统拒绝处理
|
||
var track0=scope.track0;
|
||
if(!track0)return;
|
||
if(track0.bitDepth==16 && /FLOAT/i.test(track0.codec)){
|
||
track0.bitDepth=32; //chrome v66 实际为浮点数
|
||
CLog("WebM 16改32位",3);
|
||
}
|
||
if(track0[sampleRateTxt]!=scope[sampleRateTxt] || track0.bitDepth!=32 || track0.channels<1 || !/(\b|_)PCM\b/i.test(track0.codec)){
|
||
scope.bytes=[];//格式非预期 无法处理,清空缓冲数据
|
||
if(!scope.bad)CLog("WebM Track非预期",3,scope);
|
||
scope.bad=1;
|
||
return -1;
|
||
}
|
||
|
||
//循环读取Cluster内的SimpleBlock
|
||
var datas=[],dataLen=0;
|
||
while(position[0]<bytes.length){
|
||
var eid1=readMatroskaVInt(bytes, position);
|
||
var bytes1=readMatroskaBlock(bytes, position);
|
||
if(!bytes1)break;//数据不全,等待缓冲
|
||
if(BytesEq(eid1, [0xA3])){//SimpleBlock完整数据
|
||
var trackNo=bytes1[0]&0xf;
|
||
var track=tracks[trackNo];
|
||
if(!track){//不可能没有,数据出错?
|
||
CLog("WebM !Track"+trackNo,1,tracks);
|
||
}else if(track.idx===0){
|
||
var u8arr=new Uint8Array(bytes1.length-4);
|
||
for(var i=4;i<bytes1.length;i++){
|
||
u8arr[i-4]=bytes1[i];
|
||
}
|
||
datas.push(u8arr); dataLen+=u8arr.length;
|
||
}
|
||
}
|
||
endPos();
|
||
}
|
||
|
||
if(dataLen){
|
||
var more=new Uint8Array(bytes.length-scope.pos[0]);
|
||
more.set(bytes.subarray(scope.pos[0]));
|
||
scope.bytes=more; //清理已读取了的缓冲数据
|
||
scope.pos[0]=0;
|
||
|
||
var u8arr=new Uint8Array(dataLen); //已获取的音频数据
|
||
for(var i=0,i2=0;i<datas.length;i++){
|
||
u8arr.set(datas[i],i2);
|
||
i2+=datas[i].length;
|
||
}
|
||
var arr=new Float32Array(u8arr.buffer);
|
||
|
||
if(track0.channels>1){//多声道,提取一个声道
|
||
var arr2=[];
|
||
for(var i=0;i<arr.length;){
|
||
arr2.push(arr[i]);
|
||
i+=track0.channels;
|
||
}
|
||
arr=new Float32Array(arr2);
|
||
};
|
||
return arr;
|
||
}
|
||
};
|
||
//两个字节数组内容是否相同
|
||
var BytesEq=function(bytes1,bytes2){
|
||
if(!bytes1 || bytes1.length!=bytes2.length) return false;
|
||
if(bytes1.length==1) return bytes1[0]==bytes2[0];
|
||
for(var i=0;i<bytes1.length;i++){
|
||
if(bytes1[i]!=bytes2[i]) return false;
|
||
}
|
||
return true;
|
||
};
|
||
//字节数组BE转成int数字
|
||
var BytesInt=function(bytes){
|
||
var s="";//0-8字节,js位运算只支持4字节
|
||
for(var i=0;i<bytes.length;i++){var n=bytes[i];s+=(n<16?"0":"")+n.toString(16)};
|
||
return parseInt(s,16)||0;
|
||
};
|
||
//读取一个可变长数值字节数组
|
||
var readMatroskaVInt=function(arr,pos,trim){
|
||
var i=pos[0];
|
||
if(i>=arr.length)return;
|
||
var b0=arr[i],b2=("0000000"+b0.toString(2)).substr(-8);
|
||
var m=/^(0*1)(\d*)$/.exec(b2);
|
||
if(!m)return;
|
||
var len=m[1].length, val=[];
|
||
if(i+len>arr.length)return;
|
||
for(var i2=0;i2<len;i2++){ val[i2]=arr[i]; i++; }
|
||
if(trim) val[0]=parseInt(m[2]||'0',2);
|
||
pos[0]=i;
|
||
return val;
|
||
};
|
||
//读取一个自带长度的内容字节数组
|
||
var readMatroskaBlock=function(arr,pos){
|
||
var lenVal=readMatroskaVInt(arr,pos,1);
|
||
if(!lenVal)return;
|
||
var len=BytesInt(lenVal);
|
||
var i=pos[0], val=[];
|
||
if(len<0x7FFFFFFF){ //超大值代表没有长度
|
||
if(i+len>arr.length)return;
|
||
for(var i2=0;i2<len;i2++){ val[i2]=arr[i]; i++; }
|
||
}
|
||
pos[0]=i;
|
||
return val;
|
||
};
|
||
//=====End WebM读取=====
|
||
|
||
|
||
|
||
|
||
//流量统计用1像素图片地址,设置为空将不参与统计
|
||
Recorder.TrafficImgUrl="//ia.51.la/go1?id=20469973&pvFlag=1";
|
||
var Traffic=Recorder.Traffic=function(report){
|
||
report=report?"/"+RecTxt+"/Report/"+report:"";
|
||
var imgUrl=Recorder.TrafficImgUrl;
|
||
if(imgUrl){
|
||
var data=Recorder.Traffic;
|
||
var m=/^(https?:..[^\/#]*\/?)[^#]*/i.exec(location.href)||[];
|
||
var host=(m[1]||"http://file/");
|
||
var idf=(m[0]||host)+report;
|
||
|
||
if(imgUrl.indexOf("//")==0){
|
||
//给url加上http前缀,如果是file协议下,不加前缀没法用
|
||
if(/^https:/i.test(idf)){
|
||
imgUrl="https:"+imgUrl;
|
||
}else{
|
||
imgUrl="http:"+imgUrl;
|
||
};
|
||
};
|
||
if(report){
|
||
imgUrl=imgUrl+"&cu="+encodeURIComponent(host+report);
|
||
};
|
||
|
||
if(!data[idf]){
|
||
data[idf]=1;
|
||
|
||
var img=new Image();
|
||
img.src=imgUrl;
|
||
CLog("Traffic Analysis Image: "+(report||RecTxt+".TrafficImgUrl="+Recorder.TrafficImgUrl));
|
||
};
|
||
};
|
||
};
|
||
|
||
})); |