From d7c837ba32c0e6f37982a5da0e91547f75738476 Mon Sep 17 00:00:00 2001 From: "yanyuxiyangzk@126.com" Date: Sat, 13 Apr 2024 14:21:13 +0800 Subject: [PATCH] =?UTF-8?q?funsar=E8=AF=86=E5=88=AB=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- html/static/index.html | 87 ++ html/static/main.js | 566 +++++++++++++ html/static/pcm.js | 96 +++ html/static/recorder-core.js | 1493 ++++++++++++++++++++++++++++++++++ html/static/wav.js | 86 ++ html/static/wsconnecter.js | 119 +++ 6 files changed, 2447 insertions(+) create mode 100644 html/static/index.html create mode 100644 html/static/main.js create mode 100644 html/static/pcm.js create mode 100644 html/static/recorder-core.js create mode 100644 html/static/wav.js create mode 100644 html/static/wsconnecter.js diff --git a/html/static/index.html b/html/static/index.html new file mode 100644 index 0000000..5e4177c --- /dev/null +++ b/html/static/index.html @@ -0,0 +1,87 @@ + + + + + + 语音识别 + + + + + + + + + +

FunASR Demo

+

这里是FunASR开源项目体验demo,集成了VAD、ASR与标点等工业级别的模型,支持长音频离线文件转写,实时语音识别等,开源项目地址:https://github.com/alibaba-damo-academy/FunASR

+ +
+ +
+ asr服务器地址(必填): +
+ +
+ +
+
+
+ 选择录音模式:
+ +    + + +
+ +
+
+ 选择asr模型模式:
+ +    +    + + +
+ + +
+
+ 热词设置(一行一个关键字,空格隔开权重,如"阿里巴巴 20"): +
+ + + +
+ +
+ 语音识别结果显示: +
+ + +
+
请点击开始
+
+ + + + +
+ + +
+
+ + + + + + + + + diff --git a/html/static/main.js b/html/static/main.js new file mode 100644 index 0000000..aaac050 --- /dev/null +++ b/html/static/main.js @@ -0,0 +1,566 @@ +/** + * Copyright FunASR (https://github.com/alibaba-damo-academy/FunASR). All Rights + * Reserved. MIT License (https://opensource.org/licenses/MIT) + */ +/* 2022-2023 by zhaoming,mali aihealthx.com */ + + +// 连接; 定义socket连接类对象与语音对象 +var wsconnecter = new WebSocketConnectMethod({msgHandle:getJsonMessage,stateHandle:getConnState}); +var audioBlob; + +// 录音; 定义录音对象,wav格式 +var rec = Recorder({ + type:"pcm", + bitRate:16, + sampleRate:16000, + onProcess:recProcess +}); + + + + +var sampleBuf=new Int16Array(); +// 定义按钮响应事件 +var btnStart = document.getElementById('btnStart'); +btnStart.onclick = record; +var btnStop = document.getElementById('btnStop'); +btnStop.onclick = stop; +btnStop.disabled = true; +btnStart.disabled = true; + +btnConnect= document.getElementById('btnConnect'); +btnConnect.onclick = start; + +var awsslink= document.getElementById('wsslink'); + + +var rec_text=""; // for online rec asr result +var offline_text=""; // for offline rec asr result +var info_div = document.getElementById('info_div'); + +var upfile = document.getElementById('upfile'); + + + +var isfilemode=false; // if it is in file mode +var file_ext=""; +var file_sample_rate=16000; //for wav file sample rate +var file_data_array; // array to save file data + +var totalsend=0; + + +var now_ipaddress=window.location.href; +now_ipaddress=now_ipaddress.replace("https://","wss://"); +now_ipaddress=now_ipaddress.replace("static/index.html",""); +var localport=window.location.port; +now_ipaddress=now_ipaddress.replace(localport,"10095"); +document.getElementById('wssip').value=now_ipaddress; +addresschange(); +function addresschange() +{ + + var Uri = document.getElementById('wssip').value; + document.getElementById('info_wslink').innerHTML="点此处手工授权(IOS手机)"; + Uri=Uri.replace(/wss/g,"https"); + console.log("addresschange uri=",Uri); + + awsslink.onclick=function(){ + window.open(Uri, '_blank'); + } + +} + +upfile.onclick=function() +{ + btnStart.disabled = true; + btnStop.disabled = true; + btnConnect.disabled=false; + +} + +// from https://github.com/xiangyuecn/Recorder/tree/master +var readWavInfo=function(bytes){ + //读取wav文件头,统一成44字节的头 + if(bytes.byteLength<44){ + return null; + }; + var wavView=bytes; + var eq=function(p,s){ + for(var i=0;i=chunk_size){ + + sendBuf=sampleBuf.slice(0,chunk_size); + totalsend=totalsend+sampleBuf.length; + sampleBuf=sampleBuf.slice(chunk_size,sampleBuf.length); + wsconnecter.wsSend(sendBuf); + + + } + + stop(); + + + +} + + +function on_recoder_mode_change() +{ + var item = null; + var obj = document.getElementsByName("recoder_mode"); + for (var i = 0; i < obj.length; i++) { //遍历Radio + if (obj[i].checked) { + item = obj[i].value; + break; + } + + + } + if(item=="mic") + { + document.getElementById("mic_mode_div").style.display = 'block'; + document.getElementById("rec_mode_div").style.display = 'none'; + + + btnStart.disabled = true; + btnStop.disabled = true; + btnConnect.disabled=false; + isfilemode=false; + } + else + { + document.getElementById("mic_mode_div").style.display = 'none'; + document.getElementById("rec_mode_div").style.display = 'block'; + + btnStart.disabled = true; + btnStop.disabled = true; + btnConnect.disabled=true; + isfilemode=true; + info_div.innerHTML='请点击选择文件'; + + + } +} + + +function getHotwords(){ + + var obj = document.getElementById("varHot"); + + if(typeof(obj) == 'undefined' || obj==null || obj.value.length<=0){ + return null; + } + let val = obj.value.toString(); + + console.log("hotwords="+val); + let items = val.split(/[(\r\n)\r\n]+/); //split by \r\n + var jsonresult = {}; + const regexNum = /^[0-9]*$/; // test number + for (item of items) { + + let result = item.split(" "); + if(result.length>=2 && regexNum.test(result[result.length-1])) + { + var wordstr=""; + for(var i=0;i0){ + wsconnecter.wsSend(sampleBuf); + console.log("sampleBuf.length"+sampleBuf.length); + sampleBuf=new Int16Array(); + } + wsconnecter.wsSend( JSON.stringify(request) ); + + + + + + + // 控件状态更新 + + isRec = false; + info_div.innerHTML="发送完数据,请等候,正在识别..."; + + if(isfilemode==false){ + btnStop.disabled = true; + btnStart.disabled = true; + btnConnect.disabled=true; + //wait 3s for asr result + setTimeout(function(){ + console.log("call stop ws!"); + wsconnecter.wsStop(); + btnConnect.disabled=false; + info_div.innerHTML="请点击连接";}, 3000 ); + + + + rec.stop(function(blob,duration){ + + console.log(blob); + var audioBlob = Recorder.pcm2wav(data = {sampleRate:16000, bitRate:16, blob:blob}, + function(theblob,duration){ + console.log(theblob); + var audio_record = document.getElementById('audio_record'); + audio_record.src = (window.URL||webkitURL).createObjectURL(theblob); + audio_record.controls=true; + //audio_record.play(); + + + } ,function(msg){ + console.log(msg); + } + ); + + + + },function(errMsg){ + console.log("errMsg: " + errMsg); + }); + } + // 停止连接 + + + +} + +function clear() { + + var varArea=document.getElementById('varArea'); + + varArea.value=""; + rec_text=""; + offline_text=""; + +} + + +function recProcess( buffer, powerLevel, bufferDuration, bufferSampleRate,newBufferIdx,asyncEnd ) { + if ( isRec === true ) { + var data_48k = buffer[buffer.length-1]; + + var array_48k = new Array(data_48k); + var data_16k=Recorder.SampleData(array_48k,bufferSampleRate,16000).data; + + sampleBuf = Int16Array.from([...sampleBuf, ...data_16k]); + var chunk_size=960; // for asr chunk_size [5, 10, 5] + info_div.innerHTML=""+bufferDuration/1000+"s"; + while(sampleBuf.length>=chunk_size){ + sendBuf=sampleBuf.slice(0,chunk_size); + sampleBuf=sampleBuf.slice(chunk_size,sampleBuf.length); + wsconnecter.wsSend(sendBuf); + + + + } + + + + } +} \ No newline at end of file diff --git a/html/static/pcm.js b/html/static/pcm.js new file mode 100644 index 0000000..51c1efe --- /dev/null +++ b/html/static/pcm.js @@ -0,0 +1,96 @@ +/* +pcm编码器+编码引擎 +https://github.com/xiangyuecn/Recorder + +编码原理:本编码器输出的pcm格式数据其实就是Recorder中的buffers原始数据(经过了重新采样),16位时为LE小端模式(Little Endian),并未经过任何编码处理 + +编码的代码和wav.js区别不大,pcm加上一个44字节wav头即成wav文件;所以要播放pcm就很简单了,直接转成wav文件来播放,已提供转换函数 Recorder.pcm2wav +*/ +(function(){ +"use strict"; + +Recorder.prototype.enc_pcm={ + stable:true + ,testmsg:"pcm为未封装的原始音频数据,pcm数据文件无法直接播放;支持位数8位、16位(填在比特率里面),采样率取值无限制" +}; +Recorder.prototype.pcm=function(res,True,False){ + var This=this,set=This.set + ,size=res.length + ,bitRate=set.bitRate==8?8:16; + + var buffer=new ArrayBuffer(size*(bitRate/8)); + var data=new DataView(buffer); + var offset=0; + + // 写入采样数据 + if(bitRate==8) { + for(var i=0;i>8)+128; + data.setInt8(offset,val,true); + }; + }else{ + 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中的偏移的下一个位置 + + //仅作为返回值 + 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;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;i0){ + 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(;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){ + 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;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+"]"+(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;i10 && 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"+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]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;i2>8)+128; + data.setInt8(offset,val,true); + }; + }else{ + for (var i=0;i