/* 录音 https://github.com/xiangyuecn/Recorder */ (function(factory){ var browser=typeof window=="object" && !!window.document; var win=browser?window:Object; //非浏览器环境,Recorder挂载在Object下面 factory(win,browser); //umd returnExports.js if(typeof(define)=='function' && define.amd){ define(function(){ return win.Recorder; }); }; if(typeof(module)=='object' && module.exports){ module.exports=win.Recorder; }; }(function(Export,isBrowser){ "use strict"; var NOOP=function(){}; var IsNum=function(v){return typeof v=="number"}; var Recorder=function(set){ return new initFn(set); }; var LM=Recorder.LM="2024-04-09 19:15"; 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[RecTxt];//重复加载js if(WRec2&&WRec2.LM==LM){ WRec2.CLog(WRec2.i18n.$T("K8zP::重复导入{1}",0,RecTxt),3); return; }; //是否已经打开了全局的麦克风录音,所有工作都已经准备好了,就等接收音频数据了 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(){ 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时尝试创建新的非全局对象并返回,失败时依旧返回全局的;成功时返回新的,注意用完必须自己调用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; if(!ctx||ctx.state=="closed"){ //不能反复构造,低版本number of hardware contexts reached maximum (6) ctx=Recorder.Ctx=new AC(); 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;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已处理到的索引 offset:0.0 已处理到的index对应的pcm中的偏移的下一个位置 //可定义,指定的一个滤波配置:默认使用Recorder.IIRFilter低通滤波(可有效抑制混叠产生的杂音,新采样率大于pcm采样率的75%时不默认滤波),如果提供了配置但fn为null时将不滤波;sr为此滤波函数对应的初始化采样率,当采样率和pcmSampleRate参数不一致时将重新设为默认函数 filter:null||{fn:fn(sample),sr:pcmSampleRate} //仅作为返回值 frameNext:null||[Int16,...] 下一帧的部分数据,frameSize设置了的时候才可能会有 sampleRate:16000 结果的采样率,<=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 filter=prevChunkInfo.filter; if(filter&&filter.fn&&filter.sr!=pcmSampleRate){ filter=null; CLog($T("d48C::{1}的filter采样率变了,重设滤波",0,Txt),3); }; if(!filter){//采样率差距比较大才开启低通滤波,最高频率用新采样率频率的3/4 var freq=newSampleRate>pcmSampleRate*3/4?0: newSampleRate/2 *3/4; filter={fn:freq?Recorder.IIRFilter(true,pcmSampleRate,freq):0}; }; filter.sr=pcmSampleRate; 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 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.floor(size/step); }else{//新采样高于录音采样不处理,省去了插值处理 step=1; newSampleRate=pcmSampleRate; }; size+=frameNext.length; var res=new Int16Array(size); var idx=0; //添加上一次不够一帧的剩余数据 for(var i=0;i0x7FFF) val=0x7FFF; else if(val<-0x8000) val=-0x8000; //Int16越界处理 res[idx]=val; idx++; i+=step;//抽样 }; offset=Math.max(0, 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 ,filter:filter ,frameNext:frameNext ,sampleRate:newSampleRate ,data:res }; }; /*IIR低通、高通滤波,移植自:https://gitee.com/52jian/digital-audio-filter AudioFilter.java useLowPass: true或false,true为低通滤波,false为高通滤波 sampleRate: 待处理pcm的采样率 freq: 截止频率Hz,最大频率为sampleRate/2,低通时会切掉高于此频率的声音,高通时会切掉低于此频率的声音,注意滤波并非100%的切掉不需要的声音,而是减弱频率对应的声音,离截止频率越远对应声音减弱越厉害,离截止频率越近声音就几乎无衰减 返回的是一个函数,用此函数对pcm的每个采样值按顺序进行处理即可(不同pcm不可共用);注意此函数返回值可能会越界超过Int16范围,自行限制一下即可:Math.min(Math.max(val,-0x8000),0x7FFF) 可重新赋值一个函数,来改变Recorder的默认行为,比如SampleData中的低通滤波*/ Recorder.IIRFilter=function(useLowPass, sampleRate, freq){ 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中所有采样的绝对值的平局值 返回值:-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){ 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 //更多参考: 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方法实时生成的音质更加,因为天然避免了首尾的静默 //不支持实时编码的录音格式不可以提供此回调(wav格式不支持,因为wav文件头中需要提供文件最终长度),提供了将在open时直接走fail逻辑 }; for(var k in set){ o[k]=set[k]; }; This.set=o; var vB=o[bitRateTxt],vS=o[sampleRateTxt]; //校验配置参数 if(vB&&!IsNum(vB) || vS&&!IsNum(vS)){ This.CLog($T.G("IllegalArgs-1",[$T("VtS4::{1}和{2}必须是数值",0,sampleRateTxt,bitRateTxt)]),1,set); }; o[bitRateTxt]=+vB||16; o[sampleRateTxt]=+vS||16000; This.state=0;//运行状态,0未录音 1录音中 2暂停 3等待ctx激活 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; } } //当前实例用到的AudioContext,可能是全局的,也可能是独享的 ,_streamCtx:function(){ var m=this._streamStore().Stream; return m&&m._c; } //打开录音资源True(),False(msg,isUserNotAllow),需要调用close。注意:此方法是异步的;一般使用时打开,用完立即关闭;可重复调用,可用来测试是否能录音;open和start至少有一个应当在用户操作(触摸、点击等)下进行调用,原因参考runningContext配置 ,open:function(True,False){ var This=this,set=This.set,streamStore=This._streamStore(),newCtx=0; True=True||NOOP; var failCall=function(errMsg,isUserNotAllow){ isUserNotAllow=!!isUserNotAllow; This.CLog($T("5tWi::录音open失败:")+errMsg+",isUserNotAllow:"+isUserNotAllow,1); if(newCtx)Recorder.CloseNewCtx(newCtx); False&&False(errMsg,isUserNotAllow); }; This._streamTag=getUserMediaTxt; var ok=function(){ This.CLog("open ok, id:"+This.id+" stream:"+This._streamTag); 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=$T("dFm8::open被取消"); if(lockOpen==Lock.O){ //无新的open,已经调用了close进行取消,此处应让上次的close明确生效 This.close(); }else{ err=$T("VtJO::open被中断"); }; failCall(err); return true; }; }; //环境配置检查 if(!isBrowser){ failCall($T.G("NonBrowser-1",["open"])+$T("EMJq::,可尝试使用RecordApp解决方案")+"("+GitUrl+"/tree/master/app-support-sample)"); return; }; var checkMsg=This.envCheck({envName:"H5",canProcess:true}); if(checkMsg){ failCall($T("A5bm::不能录音:")+checkMsg); return; }; //***********已直接提供了音频流************ if(set.sourceStream){ This._streamTag="set.sourceStream"; if(!Recorder.GetContext()){ failCall($T("1iU7::不支持此浏览器从流中获取录音")); return; }; Disconnect(streamStore);//可能已open过,直接先尝试断开 var stream=This.Stream=set.sourceStream; stream._RC=set.runningContext; stream._call={}; try{ Connect(streamStore); }catch(e){ Disconnect(streamStore); failCall($T("BTW2::从流中打开录音失败:")+e.message); return; } ok(); return; }; //***********打开麦克风得到全局的音频流************ var codeFail=function(code,msg){ try{//跨域的优先检测一下 window.top.a; }catch(e){ failCall($T("Nclz::无权录音(跨域,请尝试给iframe添加麦克风访问策略,如{1})",0,'allow="camera;microphone"')); return; }; if(/Permission|Allow/i.test(code)){ failCall($T("gyO5::用户拒绝了录音权限"),true); }else if(window.isSecureContext===false){ failCall($T("oWNo::浏览器禁止不安全页面录音,可开启https解决")); }else if(/Found/i.test(code)){//可能是非安全环境导致的没有设备 failCall(msg+$T("jBa9::,无可用麦克风")); }else{ failCall(msg); }; }; //如果已打开并且有效就不要再打开了 if(Recorder.IsOpen()){ ok(); return; }; if(!Recorder.Support()){ codeFail("",$T("COxc::此浏览器不支持录音")); return; }; //尽量先创建好ctx,不然异步下创建可能不是running状态 var ctx=set.runningContext; if(!ctx)ctx=newCtx=Recorder.GetContext(true); //请求权限,如果从未授权,一般浏览器会弹出权限请求弹框 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; stream._c=ctx; stream._RC=set.runningContext; if(lockFail())return; if(Recorder.IsOpen()){ if(oldStream)This.CLog($T("upb8::发现同时多次调用open"),1); Connect(streamStore,1); ok(); }else{ failCall($T("Q1GA::录音功能无效:无音频流")); }; },100); }; var f2=function(e){ var code=e.name||e.message||e.code+":"+e; This.CLog($T("xEQR::请求录音权限错误"),1,e); codeFail(code,$T("bDOG::无法录音:")+code); }; var trackSet=set.audioTrackSet||{}; trackSet[sampleRateTxt]=ctx[sampleRateTxt];//必须指明采样率,不然手机上MediaRecorder采样率16k var mSet={audio:trackSet}; try{ var pro=Recorder.Scope[getUserMediaTxt](mSet,f1,f2); }catch(e){//不能设置trackSet就算了 This.CLog(getUserMediaTxt,3,e); mSet={audio:true}; pro=Recorder.Scope[getUserMediaTxt](mSet,f1,f2); }; This.CLog(getUserMediaTxt+"("+JSON.stringify(mSet)+") "+CtxState(ctx) +$T("RiWe::,未配置noiseSuppression和echoCancellation时浏览器可能会自动打开降噪和回声消除,移动端可能会降低系统播放音量(关闭录音后可恢复),请参阅文档中audioTrackSet配置") +"("+GitUrl+") LM:"+LM+" UA:"+navigator.userAgent); 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 sTag=" stream:"+This._streamTag; var Lock=streamStore.Sync; This._O=0; if(This._O_!=Lock.O){ //唯一资源Stream的控制权已交给新对象,这里不能关闭。此处在每次都弹权限的浏览器内可能存在泄漏,新对象被拒绝权限可能不会调用close,忽略这种不处理 This.CLog($T("hWVz::close被忽略(因为同时open了多个rec,只有最后一个会真正close)")+sTag,3); call(); return; }; Lock.C++;//获得控制权 Disconnect(streamStore); This.CLog("close,"+sTag); 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._setSrcSR(pcmSampleRate); This._streamTag="mock"; return This; } ,_setSrcSR:function(sampleRate){ var This=this,set=This.set; 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?" "+$T("UHvm::忽略")+": "+setSr:""), setSr?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]),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===true){ //开启了异步模式,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(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 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){ 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)); }; }; }; if(WRec2){ CLog($T("8HO5::覆盖导入{1}",0,RecTxt),1); WRec2.Destroy(); }; Export[RecTxt]=Recorder; }));