| New file |
| | |
| | | /* |
| | | 录音 |
| | | 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)); |
| | | }; |
| | | }; |
| | | }; |
| | | |
| | | })); |