/* 录音 https://github.com/xiangyuecn/Recorder */ (function(factory){ "use strict"; var browser=false, doc=typeof window=="object" && window.document, docEL=doc && doc.documentElement; //判断是否是浏览器环境 var bwSet=Object["Recorder-Core-IsBrowser"]; //有些环境会模拟全局变量,如果误判可以强制指定是否是浏览器 if(bwSet!=null){ browser=!!bwSet; }else if(docEL && docEL.style){ //用基础html功能检测是否是浏览器环境 try{ var css=doc.createElement("div").style; css.cssText="margin:6px !important"; browser=css.marginTop=="6px"; }catch(e){} }; var Export=browser?window:Object; //非浏览器环境,Recorder挂载在Object下面 var ExA0=Object["Recorder-Core-Alias"], ExAlias=ExA0||"Recorder"; //允许配置全局变量名 factory(Export,ExAlias,browser); var Recorder=Export[ExAlias]; Object[ExA0||"Recorder-Core-Export"]=Recorder; //260708固定挂载在Object下面导出 //umd returnExports.js if(typeof(define)=='function' && define.amd){ define(function(){ return Recorder }); }; if(typeof(module)=='object' && module.exports){ module.exports=Recorder; }; }(function(Export,ExAlias,isBrowser){ "use strict"; var NOOP=function(){}; var IsNum=function(v){return typeof v=="number"}; var ToJson=function(v){return JSON.stringify(v)}; var Recorder=function(set){ return new initFn(set); }; var LM=Recorder.LM="2026-07-08 20:16"; var GitUrl="https://github.com/xiangyuecn/Recorder"; var RecTxt="Recorder"; var getUserMediaTxt="getUserMedia"; var srcSampleRateTxt="srcSampleRate"; var sampleRateTxt="sampleRate"; var bitRateTxt="bitRate"; var CatchTxt="catch"; var WRec2=Export[ExAlias];//重复加载js if(WRec2&&WRec2.LM==LM){ WRec2.CLog(WRec2.i18n.$T("K8zP::重复导入{1}",0,RecTxt+(RecTxt==ExAlias?"":"@"+ExAlias)),3); return; }; Recorder.IsBrowser=isBrowser; //当前环境是否是浏览器 Recorder.LoadTime=Date.now(); //js加载时间 //是否已经打开了全局的麦克风录音,所有工作都已经准备好了,就等接收音频数据了 Recorder.IsOpen=function(){ var stream=Recorder.Stream; if(stream){ var tracks=Tracks_(stream), 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(){ if(!isBrowser) return false; 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。tryNew=false时返回全局的Recorder.Ctx,Ctx.state可能是closed,仅限用于解码等操作。tryNew=true时会尝试创建新的ctx(不支持close的老浏览器依旧返回全局的),注意用完必须自己调用CloseNewCtx(ctx)关闭;注意:非用户操作(触摸、点击等)时调用返回的ctx.state可能是suspended状态,需要在用户操作时调用ctx.resume恢复成running状态,参考rec的runningContext配置 Recorder.GetContext=function(tryNew){ if(!isBrowser) return null; var AC=window.AudioContext; if(!AC){ AC=window.webkitAudioContext; }; if(!AC){ return null; }; var ctx=Recorder.Ctx, isNew=0; if(!ctx){ //241020版本后不再保留打开状态的ctx,原因是iOS不全部关闭时新的ctx有可能不正常 //不能反复构造,低版本number of hardware contexts reached maximum (6) ctx=Recorder.Ctx=new AC(); isNew=1; Recorder.NewCtxs=Recorder.NewCtxs||[]; Recorder.BindDestroy("Ctx",function(){ var ctx=Recorder.Ctx; if(ctx&&ctx.close){//能关掉就关掉,关不掉就保留着 CloseCtx(ctx); Recorder.Ctx=0; }; var arr=Recorder.NewCtxs; Recorder.NewCtxs=[]; for(var i=0;i1)s=1; //Math.max(-1,Math.min(1,s)) s=s<0?s*0x8000:s*0x7FFF; pcm[j]=s; sum+=s<0?-s:s; //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=$T("ZGlf::。由于{1}内部1秒375次回调,在移动端可能会有性能问题导致回调丢失录音变短,PC端无影响,暂不建议开启{1}。",0,audioWorklet); var oldScript=function(){ isWorklet=stream.isWorklet=false; _Disconn_n(stream); CLog($T("7TU0::Connect采用老的{1},",0,scriptProcessor) +i18n.get(Recorder[ConnectEnableWorklet]? $T("JwCL::但已设置{1}尝试启用{2}",2) :$T("VGjB::可设置{1}尝试启用{2}",2) ,[RecTxt+"."+ConnectEnableWorklet+"=true",audioWorklet] )+webMTips+oldIsBest,3); var process=stream._p=oldFn.call(ctx,bufferSize,1,1);//单声道,省的数据处理复杂 mediaConn(process); process.onaudioprocess=function(e){ var arr=e.inputBuffer.getChannelData(0); onReceive(arr, ctxSR); }; }; //===================连接方式二========================= 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; $C.log("$RA kill call"); } }; $C.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){$C.error("'+recAudioWorklet+' Reg Error",e)}'; clazz=clazz.replace(/\$C\./g,"console.");//一些编译器会文本替换日志函数 //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($T("MxX1::{1}未返回任何音频,恢复使用{2}",0,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, ctxSR); }else if(!isWorklet){ CLog($T("XUap::{1}多余回调",0,audioWorklet),3); }; }; CLog($T("yOta::Connect采用{1},设置{2}可恢复老式{3}",0,audioWorklet,RecTxt+"."+ConnectEnableWorklet+"=false",scriptProcessor)+webMTips+oldIsBest,3); }; //如果start时的resume和下面的构造node同时进行,将会导致部分浏览器崩溃 (STATUS_ACCESS_VIOLATION),源码assets中 ztest_chrome_bug_AudioWorkletNode.html 可测试。所以,将所有代码套到resume里面(不管catch),避免出现这个问题 var ctxOK=function(){ 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 Error",1,e); awNext()&&oldScript(); }); }; ResumeCtx(ctx,function(){ return awNext() } ,ctxOK,ctxOK); }; //===================连接方式一========================= 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?"":$T("VwPd::(此浏览器不支持{1})",0,MRWebMPCM); if(!isWebM || !supportMR){ connWorklet(); //被禁用 或 不支持MediaRecorder 或 不支持webm+pcm return; } var mrNext=function(){//可以继续,没有调用断开 return isWebM && stream._ra; }; stream._ra=function(){ //start时会调用,只要没有收到数据就断定MediaRecorder有问题,降级处理 if(badInt!==""){//没有回调过数据 clearTimeout(badInt); badInt=setTimeout(function(){ //badInt=0; 保留给nodeAlive继续判断 if(mrNext()){ CLog($T("vHnb::{1}未返回任何音频,降级使用{2}",0,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={}; 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, webmData.webmSR); }else if(!isWebM){ CLog($T("O9P7::{1}多余回调",0,MediaRecorderTxt),3); }; }; reader.readAsArrayBuffer(e.data); }; try{ mr.start(~~(bufferSize/48));//按48k时的回调间隔 CLog($T("LMEm::Connect采用{1},设置{2}可恢复使用{3}或老式{4}",0,MRWebMPCM,RecTxt+"."+ConnectEnableWebM+"=false",audioWorklet,scriptProcessor)); }catch(e){ //存在视频流时可能会抛异常 CLog("mr start err",1,e); connWorklet(); }; }; 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){ try{ stream._r.stop() }catch(e){ CLog("mr stop err",1,e) } 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._RC && stream._c){//提供的runningContext不处理 Recorder.CloseNewCtx(stream._c); }; stream._RC=null; stream._c=null; if(stream._d){ StopS_(stream._d.stream); stream._d=null; }; if(stream._p){ stream._p.disconnect(); stream._p.onaudioprocess=stream._p=null; }; _Disconn_n(stream); _Disconn_r(stream); if(isGlobal){//全局的时候,要把流关掉(麦克风),直接提供的流不处理 StopS_(stream); }; }; streamStore.Stream=0; }; //关闭一个音频流 var StopS_=Recorder.StopS_=function(stream){ var tracks=Tracks_(stream); for(var i=0;i=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已处理到的索引;比如每次都是单个pcm需要连续处理时,可每次调用前重置成0,pcmDatas仅需传入`[pcm]`固定一个元素 offset:0.0 已处理到的index对应的pcm中的偏移的下一个位置(提升采样率时为结果的pcm) raisePrev:null 提升采样率时的前一个pcm结果采样值 downPrev:null 降低采样率时的前一个pcm未添加的一个元素 null|{v:123,at:0.1} resizeCount:0 性能计数,内部调整输出缓冲区的次数,>0调整次数 0未发生调整 -1缓冲区发生溢出(-1需重设为0才会继续计数) //可定义,指定的一个滤波配置:默认使用Recorder.IIRFilter低通滤波(可有效抑制混叠产生的杂音,为一阶IIR速度快但能力一般),如果提供了配置但fn为null时将不滤波;sr、srn为此滤波函数对应的初始化采样率,当采样率和参数的不一致时将重新设为默认函数 filter:null||{fn:fn(sample),sr:pcmSampleRate,srn:newSampleRate} //仅作为返回值 frameNext:null||[Int16,...] 下一帧的部分数据,frameSize设置了的时候才可能会有 sampleRate:16000 结果的采样率=newSampleRate,老版本<=newSampleRate data:[Int16,...] 转换后的PCM结果;如果是连续转换,并且pcmDatas中并没有新数据时,data的长度可能为0 } */ Recorder.SampleData=function(pcmDatas,pcmSampleRate,newSampleRate,prevChunkInfo,option){ var Txt="SampleData"; prevChunkInfo||(prevChunkInfo={}); var index=prevChunkInfo.index||0; var offset=prevChunkInfo.offset||0; var raisePrev=prevChunkInfo.raisePrev||0; var downPrev=prevChunkInfo.downPrev; var filter=prevChunkInfo.filter; if(filter&&filter.fn&&(filter.sr&&filter.sr!=pcmSampleRate || filter.srn&&filter.srn!=newSampleRate)){ filter=null; CLog($T("d48C::{1}的filter采样率变了,重设滤波",0,Txt),3); }; if(!filter){ //尝试开启低通滤波 if(newSampleRatepcmSampleRate){ //提升采样率,最高频率用原始采样率频率,可能为null filter={fn:Recorder.IIRFilter(true,newSampleRate,pcmSampleRate/2)}; }else{ filter={fn:null}; }; }; filter.sr=pcmSampleRate; filter.srn=newSampleRate; var filterFn=filter.fn; var frameNext=prevChunkInfo.frameNext||[]; option||(option={}); var frameSize=option.frameSize||1; if(option.frameType){ frameSize=option.frameType=="mp3"?1152:1; }; var useSum=option._sum, _sum=0; //内部用的,sum不考虑配置了frame var nLen=pcmDatas.length; if(index>nLen+1){ CLog($T("tlbC::{1}似乎传入了未重置chunk {2}",0,Txt,index+">"+nLen),3); }; var size=0; for(var i=index;i1){//新采样低于录音采样,进行抽样 size=Math.max(0,size-offset); size=size/step; var sizeF=(size%1)*step; size=Math.ceil(size); //有小数点 代表存在对应的索引 要+1 if(step%1 && sizeF>0 && sizeF<1) size--; //非整数倍刚好是最后一个位置时,依赖下一个值,留给下一次处理 if(downPrev) size++; //上次留了一个 }else if(step<1){//新采样高于录音采样,插值处理 var raiseStep=1/step; size=size+offset; size=Math.floor(size*raiseStep); }; size+=frameNext.length; var res=new Int16Array(size), resize=0, reCount=0; var idx=0; //添加上一次不够一帧的剩余数据 for(var i=0;i1)oVal=1; //Math.max(-1,Math.min(1,oVal)) oVal=oVal<0?oVal*0x8000:oVal*0x7FFF; } res[idx]=oVal; idx++; }; offset=0; }else if(step<1){ //提升采样率 var idx1=idx+i, idx1_r=idx1, prev=raisePrev; for(var i0=0;i01)oVal=1; //Math.max(-1,Math.min(1,oVal)) oVal=oVal<0?oVal*0x8000:oVal*0x7FFF; } var pos=~~idx1; //Math.floor(idx1) 内联优化 idx1=idx1_r+(i0+1)*raiseStep; //乘法减少 idx1+=raiseStep 浮点数累加误差 var end=~~idx1; //Math.floor(idx1) //简单的从prev平滑填充到cur,有效减少转换引入的杂音 var n=(oVal-prev)/(end-pos); for(var j=1;pos0x7FFF) s=0x7FFF; else if(s<-0x8000) s=-0x8000; //Int16越界处理 if(useSum) _sum+=s<0?-s:s; //Math.abs(s) res[pos]=s; idx++; }; prev=raisePrev=oVal; i=offset+(i0+1)*raiseStep; //乘法减少 i+=raiseStep 浮点数累加误差 } offset=i%1; }else{ //降低采样率 var iAdd=0,vNext=0; for(var i0=0,i2=0;i01)oVal=1; //Math.max(-1,Math.min(1,oVal)) oVal=oVal<0?oVal*0x8000:oVal*0x7FFF; } if(F){//IIRFilter代码内置,比函数调用快4倍 Fx=oVal; Fy=F.b0 * Fx + F.b1 * F.x1 + F.b0 * F.x2 - F.a1 * F.y1 - F.a2 * F.y2; F.x2 = F.x1; F.x1 = Fx; F.y2 = F.y1; F.y1 = Fy; }else{ Fy=filterFn?filterFn(oVal):oVal; } } var val=vNext; vNext=Fy; var addVal=true; if(i2==0){ i0--; if(!downPrev) continue; //首次只计算o[0],因为每次都要拿后面一个点来计算插值 //添加上次插值缺少后一个点的计算值 iAdd--; //下面会++,需要还原,减少一次if判断 val=downPrev.v; val=val+(vNext-val)*downPrev.at; downPrev=null; }else{ var before = ~~i;//Math.floor(i) 内联优化 if(i0!=before)continue; //只进行滤波计算 if(before!=i){ //正整数直接用val不需要计算插值 //https://www.cnblogs.com/xiaoqi/p/6993912.html //浮点数i在两个索引之间,采用线性插值 +当前点的小数部分的增量,音质比直接简单抽样好些 var after = before+1; //Math.ceil(i) 已排除整数 恒定+1 var atPoint = i - before; if(after>=il){ //后个点越界了,交给下次处理 addVal=false; downPrev={v:val,at:atPoint}; }else{ val=val+(vNext-val)*atPoint; }; }; }; if(addVal){ if(val>0x7FFF) val=0x7FFF; else if(val<-0x8000) val=-0x8000; //Int16越界处理 if(useSum) _sum+=val<0?-val:val;//Math.abs(val) if(idx==size){ //step是浮点数时,size计算不准可能缺1个,下面消除累加误差后未出现 if(!resize)resize=size; reCount++; size++; var tmp=new Int16Array(res.length+1); tmp.set(res); res=tmp; }; res[idx]=val; idx++; }; iAdd++; i=offset+iAdd*step; //抽样,乘法减少 i+=step 浮点数累加误差 }; offset=Math.max(0, i-il); //不太可能出现负数 }}; if(idxsize){ reCount=-1; //数据溢出,不太可能出现 }; if(resize || idx!=size){ //最后一个数据位置不对,存在size计算错误 CLog(Txt+" idx:"+idx+" != size:"+(resize?resize+" resize:"+size:size),3); }; //帧处理 frameNext=null; var frameNextSize=size%frameSize; if(frameNextSize>0){ var u8Pos=(size-frameNextSize)*2; frameNext=new Int16Array(res.buffer.slice(u8Pos)); res=new Int16Array(res.buffer.slice(0,u8Pos)); }; var obj={ index:index ,offset:offset ,raisePrev:raisePrev ,downPrev:downPrev ,resizeCount:prevChunkInfo.resizeCount==-1||reCount==-1?-1 :(prevChunkInfo.resizeCount||0)+reCount ,filter:filter ,frameNext:frameNext ,sampleRate:newSampleRate ,data:res }; if(useSum) obj._sum=_sum; return obj; }; /*IIR低通、高通滤波,移植自:https://gitee.com/52jian/digital-audio-filter AudioFilter.java useLowPass: true或false,true为低通滤波,false为高通滤波 sampleRate: 待处理pcm的采样率 freq: 截止频率Hz,最大频率为sampleRate/2,低通时会切掉高于此频率的声音,高通时会切掉低于此频率的声音,注意滤波并非100%的切掉不需要的声音,而是减弱频率对应的声音,离截止频率越远对应声音减弱越厉害,离截止频率越近声音就几乎无衰减 返回的是null,代表不支持此条件下进行滤波 返回的是一个函数,用此函数对pcm的每个采样值按顺序进行处理即可(不同pcm不可共用);注意此函数返回值可能会越界超过Int16范围,自行限制一下即可:Math.min(Math.max(val,-0x8000),0x7FFF) 可重新赋值一个函数,来改变Recorder的默认行为,比如SampleData中的低通滤波*/ Recorder.IIRFilter=Recorder.IIRFilter_Default=function(useLowPass, sampleRate, freq){ if(useLowPass){ //此算法是一阶IIR,滤波能力一般,但速度快,频率调低一点会干净点,比不滤波音质强很多 //if(freq*2>sampleRate*3/4) return null; //采样率差距比较大才开启低通滤波(老版本) freq=Math.max(freq*3/4, freq-Math.abs(sampleRate/2-freq)/2); //最高频率用目标频率的3/4,差距不大时减去频率差值的一半 } var ov = 2 * Math.PI * freq / sampleRate; var sn = Math.sin(ov); var cs = Math.cos(ov); var alpha = sn / 2; var a0 = 1 + alpha; var a1 = (-2 * cs) / a0; var a2 = (1 - alpha) / a0; if(useLowPass){ var b0 = (1 - cs) / 2 / a0; var b1 = (1 - cs) / a0; }else{ var b0 = (1 + cs) / 2 / a0; var b1 = -(1 + cs) / a0; } var x1=0,x2=0,y=0,y1=0,y2=0; var fn=function(x){ y = b0 * x + b1 * x1 + b0 * x2 - a1 * y1 - a2 * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; return y; }; fn.Embed={x1:0,x2:0,y1:0,y2:0,b0:b0,b1:b1,a1:a1,a2:a2}; return fn; }; /*计算音量百分比的一个方法 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中所有采样的绝对值的平局值 roundOff: 默认false结果会四舍五入成整数,传true会返回小数部分 返回值:-100~0 (最大值0dB,最小值-100代替-∞) 采样值0x7FFF=0dB 2=-84.2dB 1=-90.3dB 0为-Infinity取-100dB */ Recorder.PowerDBFS=function(maxSample, roundOff){ 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, roundOff?val:Math.round(val)); }; //带时间的日志输出,可设为一个空函数来屏蔽日志输出 //CLog(msg,errOrLogMsg, logMsg...) err为数字时代表日志类型1:error 2:log默认 3:warn,否则当做内容输出,第一个参数不能是对象因为要拼接时间,后面可以接无数个输出参数 Recorder.CLog=function(msg,err){ if(typeof console!="object")return; 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,cwe=Recorder.CLog; var i=2,fn=cwe.log||console.log; if(IsNum(err)){ fn=err==1?cwe.error||console.error:err==3?cwe.warn||console.warn:fn; }else{ i=1; }; for(;i1?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){ var This=this; 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会失败 //,runningContext:AudioContext //可选提供一个state为running状态的AudioContext对象(ctx);默认会在rec.open时自动创建一个新的ctx,无用户操作(触摸、点击等)时调用rec.open的ctx.state可能为suspended,会在rec.start时尝试进行ctx.resume,如果也无用户操作ctx.resume可能不会恢复成running状态(目前仅iOS上有此兼容性问题),导致无法去读取媒体流,这时请提前在用户操作时调用Recorder.GetContext(true)来得到一个running状态AudioContext(用完需调用CloseNewCtx(ctx)关闭) //,audioTrackSet:{ deviceId:"",groupId:"", autoGainControl:true, echoCancellation:true, noiseSuppression:true } //普通麦克风录音时getUserMedia方法的audio配置参数,比如指定设备id,回声消除、降噪开关;注意:提供的任何配置值都不一定会生效 //由于麦克风是全局共享的,所以新配置后需要close掉以前的再重新open //同样可配置videoTrackSet,更多参考: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints //,disableEnvInFix:false 内部参数,禁用设备卡顿时音频输入丢失补偿功能 //,allowUpsample:false 当源采样率this.srcSampleRatesampleRate){ if(set.allowUpsample){ msg="upsample!!"; }else{ //不允许提升采样率时 重设set值 msg="!set.allowUpsample: "+setSr; set[sampleRateTxt]=setSr=sampleRate; } } This[srcSampleRateTxt]=sampleRate; This.CLog(srcSampleRateTxt+": "+sampleRate+" set."+sampleRateTxt+": "+setSr+" "+msg, msg?3:0); } ,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] && typeof Int8Array=="function" && !new Int8Array(new Int32Array([1]).buffer)[0]){ Traffic(tag); //如果开启了流量统计,这里将发送一个图片请求 errMsg=$T("Essp::不支持{1}架构",0,tag); }; //编码器检查环境下配置是否可用 if(!errMsg){ var type=set.type,hasFn=This[type+"_envCheck"]; if(set.takeoffEncodeChunk){//需要实时编码返回数据,此时需要检查环境是否有实时特性、和是否可实时编码 if(!hasFn){ errMsg=$T("2XBl::{1}类型不支持设置takeoffEncodeChunk",0,type)+(This[type]?"":$T("LG7e::(未加载编码器)")); }else if(!envInfo.canProcess){ errMsg=$T("7uMV::{1}环境不支持实时处理",0,envInfo.envName); }; }; if(!errMsg && hasFn){//编码器已实现环境检查 errMsg=This[type+"_envCheck"](envInfo,set); }; }; 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;//数据大小 if(mockEnvInfo){ This._streamTag="env$"+mockEnvInfo.envName; }; This.state=1;//运行状态,0未录音 1录音中 2暂停 3等待ctx激活 This.envInLast=0;//envIn接收到最后录音内容的时间 This.envInFirst=0;//envIn接收到的首个录音内容的录制时间 This.envInFix=0;//补偿的总时间 This.envInFixTs=[];//补偿计数列表 //engineCtx需要提前确定最终的采样率 This._setSrcSR(sampleRate); 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; if(This.state!=1){ if(!This.state)This.CLog("envIn at state=0",3); return; }; 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;i3000){ 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+"]"+i18n.get(fixOpen?$T("4Kfd::补偿{1}ms",1):$T("bM5i::未补偿{1}ms",1),[addTime]) +" | " +$T("bM6i::提示:在onProcess中打印buffers等二进制数据、部分WebView在后台运行受限,均可能导致卡顿"),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;i10 && This.envInFirst-now>1000){ //1秒后开始onProcess性能监测 This.CLog(procTxt+$T("2ghS::低性能,耗时{1}ms",0,slowT),3); }; if(asyncBegin){ //开启了异步模式,onProcess已接管buffers新数据,立即清空,避免出现未处理的数据 var hasClear=0; for(var i=bufferFirstIdx;i"+res.length,Date.now()-t1)); setTimeout(function(){ t1=Date.now(); This[set.type](res,function(blob,mime){ ok(blob,mime,duration); },function(msg){ err(msg); }); }); } }; //=======从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]32 bit",3); } if(trackSR<8000 || track0.bitDepth!=32 || track0.channels<1 || !/(\b|_)PCM\b/i.test(track0.codec)){ scope.bytes=[];//格式非预期 无法处理,清空缓冲数据 if(!scope.bad)CLog("WebM Track Unexpected",3,scope); scope.bad=1; return -1; } //循环读取Cluster内的SimpleBlock var datas=[],dataLen=0; while(position[0]1){//多声道,提取一个声道 var arr2=[]; for(var i=0;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;i2arr.length)return; for(var i2=0;i2args.length){ v="{?}"; CLog("i18n["+key+"] no {"+a+"}: "+val,3) } return b?"":v; }); } /**返回一个国际化文本,返回的文本取决于i18n.lang。 调用参数:T("key:[lang]:中文{1}","[lang]:英文{1}",...,0,"args1","args2"),除了key:,其他都是可选的,文本如果在语言实例中不存在会push进去,第一个文本无lang时默认zh,第二个无lang时默认en,文本中用{1-n}来插入args 第一个args前面这个参数必须是0;也可以不提供args,这个参数填args的数量,此时不返回文本,只返回key,再用i18n.get提供参数获取文本 本函数调用,第一个文本需中文,调用尽量简单,不要换行,方便后续自动提取翻译列表 args如果旧的参数位置发生了变更,应当使用新的key,让旧的翻译失效,增加args无需更换key key的生成使用随机字符串,不同源码里面可以搞个不同前缀: s="";L=4; for(var i=0,n;i0)return key; return i18n.v_G(key,args); } }; var $T=i18n.$T; $T.G=i18n.get; //预定义文本 $T("NonBrowser-1::非浏览器环境,不支持{1}",1); $T("IllegalArgs-1::参数错误:{1}",1); $T("NeedImport-2::调用{1}需要先导入{2}",2); $T("NotSupport-1::不支持:{1}",1); //=====End i18n===== //流量统计用1像素图片地址,设置为空将不参与统计 Recorder.TrafficImgUrl="//ia.51.la/go1?id=20469973&pvFlag=1"; var Traffic=Recorder.Traffic=function(report){ if(!isBrowser)return; report=report?"/"+RecTxt+"/Report/"+report:""; var imgUrl=Recorder.TrafficImgUrl; if(imgUrl){ try{ 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(), delay=document.readyState=="complete"? 0 : 15000-(Date.now()-Recorder.LoadTime); if(delay>0) setTimeout(function(){ img.src=imgUrl },delay); else img.src=imgUrl; //避免head中调用时img阻塞onload CLog("Traffic Analysis Image: "+(report||RecTxt+".TrafficImgUrl="+Recorder.TrafficImgUrl)); }; }catch(e){} }; }; if(WRec2){ CLog($T("8HO5::覆盖导入{1}",0,RecTxt+(RecTxt==ExAlias?"":"@"+ExAlias)),1); WRec2.Destroy&&WRec2.Destroy(); Recorder.Export_Overwrite=WRec2; //如果是别的同名库冲突,可以通过这个属性找回 }; Export[ExAlias]=Recorder; }));