/* 录音 Recorder扩展,实时播放录音片段文件,把片段文件转换成MediaStream流 https://github.com/xiangyuecn/Recorder BufferStreamPlayer可以通过input方法一次性输入整个音频文件,或者实时输入音频片段文件,然后播放出来;输入支持格式:pcm、wav、mp3等浏览器支持的音频格式,非pcm格式会自动解码成pcm(播放音质效果比pcm、wav格式差点);输入前输入后都可进行处理要播放的音频,比如:混音、变速、变调;输入的音频会写入到内部的MediaStream流中,完成将连续的音频片段文件转换成流。 BufferStreamPlayer可以用于: 1. Recorder onProcess等实时处理中,将实时处理好的音频片段转直接换成MediaStream,此流可以作为WebRTC的local流发送到对方,或播放出来; 2. 接收到的音频片段文件的实时播放,比如:WebSocket接收到的录音片段文件播放、WebRTC remote流(Recorder支持对这种流进行实时处理)实时处理后的播放; 3. 单个音频文件的实时播放处理,比如:播放一段音频,并同时进行可视化绘制(其实自己解码+播放绘制比直接调用这个更有趣,但这个省事、配套功能多点)。 在线测试例子: https://xiangyuecn.gitee.io/recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=teach.realtime.decode_buffer_stream_player 调用示例: var stream=Recorder.BufferStreamPlayer(set) //创建好后第一件事就是start打开流,打开后就会开始播放input输入的音频,set具体配置看下面源码;注意:start需要在用户操作(触摸、点击等)时进行调用,原因参考runningContext配置 stream.start(()=>{ stream.currentTime;//当前已播放的时长,单位ms,数值变化时会有onUpdateTime事件 stream.duration;//已输入的全部数据总时长,单位ms,数值变化时会有onUpdateTime事件;实时模式下意义不大,会比实际播放的长,因为实时播放时卡了就会丢弃部分数据不播放 stream.isStop;//是否已停止,调用了stop方法时会设为true stream.isPause;//是否已暂停,调用了pause方法时会设为true stream.isPlayEnd;//已输入的数据是否播放到了结尾(没有可播放的数据了),input后又会变成false;可代表正在缓冲中或播放结束,状态变更时会有onPlayEnd事件 //如果不要默认的播放,可以设置set.play为false,这种情况下只拿到MediaStream来用 stream.getMediaStream() //通过getMediaStream方法得到MediaStream流,此流可以作为WebRTC的local流发送到对方,或者直接拿来赋值给audio.srcObject来播放(和赋值audio.src作用一致);未start时调用此方法将会抛异常 stream.getAudioSrc() //【已过时】超低版本浏览器中得到MediaStream流的字符串播放地址,可赋值给audio标签的src,直接播放音频;未start时调用此方法将会抛异常;新版本浏览器已停止支持将MediaStream转换成url字符串,调用本方法新浏览器会抛异常,因此在不需要兼容不支持srcObject的超低版本浏览器时,请直接使用getMediaStream然后赋值给auido.srcObject来播放 },(errMsg)=>{ //start失败,无法播放 }); //随时都能调用input,会等到start成功后播放出来,不停的调用input,就能持续的播放出声音了,需要暂停播放就不要调用input就行了 stream.input(anyData); //anyData数据格式 和更多说明,请阅读下面的input方法源码注释 //暂停播放,暂停后:实时模式下会丢弃所有input输入的数据(resume时只播放新input的数据),非实时模式下所有input输入的数据会保留到resume时继续播放 stream.pause(); //恢复播放,实时模式下只会从最新input的数据开始播放,非实时模式下会从暂停的位置继续播放 stream.resume(); //不要播放了就调用stop停止播放,关闭所有资源 stream.stop(); 注意:已知Firefox的AudioBuffer没法动态修改数据,所以对于带有这种特性的浏览器将采用先缓冲后再播放(类似assets/runtime-codes/fragment.playbuffer.js),音质会相对差一点;其他浏览器测试Android、IOS、Chrome无此问题;start方法中有一大段代码给浏览器做了特性检测并进行兼容处理。 */ (function(factory){ var browser=typeof window=="object" && !!window.document; var win=browser?window:Object; //非浏览器环境,Recorder挂载在Object下面 var rec=win.Recorder,ni=rec.i18n; factory(rec,ni,ni.$T,browser); }(function(Recorder,i18n,$T,isBrowser){ "use strict"; var BufferStreamPlayer=function(set){ return new fn(set); }; var BufferStreamPlayerTxt="BufferStreamPlayer"; var fn=function(set){ var This=this; var o={ play:true //要播放声音,设为false不播放,只提供MediaStream ,realtime:true /*默认为true实时模式,设为false为非实时模式 实时模式:设为 true 或 {maxDelay:300,discardAll:false}配置对象 如果有新的input输入数据,但之前输入的数据还未播放完的时长不超过maxDelay时(缓冲播放延迟默认限制在300ms内),如果积压的数据量过大则积压的数据将会被直接丢弃,少量积压会和新数据一起加速播放,最终达到尽快播放新输入的数据的目的;这在网络不流畅卡顿时会发挥很大作用,可有效降低播放延迟;出现加速播放时声音听起来会比较怪异,可配置discardAll=true来关闭此特性,少量积压的数据也直接丢弃,不会加速播放;如果你的音频数据块超过200ms,需要调大maxDelay(取值100-800ms) 非实时模式:设为 false 连续完整的播放完所有input输入的数据,之前输入的还未播放完又有新input输入会加入队列排队播放,比如用于:一次性同时输入几段音频完整播放 */ //,onInputError:fn(errMsg, inputIndex) //当input输入出错时回调,参数为input第几次调用和错误消息 //,onUpdateTime:fn() //已播放时长、总时长更新回调(stop、pause、resume后一定会回调),this.currentTime为已播放时长,this.duration为已输入的全部数据总时长(实时模式下意义不大,会比实际播放的长),单位都是ms //,onPlayEnd:fn() //没有可播放的数据时回调(stop后一定会回调),已输入的数据已全部播放完了,可代表正在缓冲中或播放结束;之后如果继续input输入了新数据,播放完后会再次回调,因此会多次回调;非实时模式一次性输入了数据时,此回调相当于播放完成,可以stop掉,重新创建对象来input数据可达到循环播放效果 //,decode:false //input输入的数据在调用transform之前是否要进行一次音频解码成pcm [Int16,...] //mp3、wav等都可以设为true、或设为{fadeInOut:true}配置对象,会自动解码成pcm;默认会开启fadeInOut对解码的pcm首尾进行淡入淡出处理,减少爆音(wav等解码后和原始pcm一致的音频,可以把fadeInOut设为false) //transform:fn(inputData,sampleRate,True,False) //将input输入的data(如果开启了decode将是解码后的pcm)转换处理成要播放的pcm数据;如果没有解码也没有提供本方法,input的data必须是[Int16,...]并且设置set.sampleRate //inputData:any input方法输入的任意格式数据,只要这个转换函数支持处理;如果开启了decode,此数据为input输入的数据解码后的pcm [Int16,...] //sampleRate:123 如果设置了decode为解码后的采样率,否则为set.sampleRate || null //True(pcm,sampleRate) 回调处理好的pcm数据([Int16,...])和pcm的采样率 //False(errMsg) 处理失败回调 //sampleRate:16000 //可选input输入的数据默认的采样率,当没有设置解码也没有提供transform时应当明确设置采样率 //runningContext:AudioContext //可选提供一个state为running状态的AudioContext对象(ctx),默认会在start时自动创建一个新的ctx,这个配置的作用请参阅Recorder的runningContext配置 }; for(var k in set){ o[k]=set[k]; }; This.set=set=o; if(!set.onInputError){ set.onInputError=function(err,n){ CLog(err,1); }; } }; fn.prototype=BufferStreamPlayer.prototype={ /**【已过时】获取MediaStream的audio播放地址,新版浏览器、未start将会抛异常**/ getAudioSrc:function(){ CLog($T("0XYC::getAudioSrc方法已过时:请直接使用getMediaStream然后赋值给audio.srcObject,仅允许在不支持srcObject的浏览器中调用本方法赋值给audio.src以做兼容"),3); if(!this._src){ //新版chrome调用createObjectURL会直接抛异常了 https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL#using_object_urls_for_media_streams this._src=(window.URL||webkitURL).createObjectURL(this.getMediaStream()); } return this._src; } /**获取MediaStream流对象,未start将会抛异常**/ ,getMediaStream:function(){ if(!this._dest){ throw new Error(NoStartMsg()); } return this._dest.stream; } /**打开音频流,打开后就会开始播放input输入的音频;注意:start需要在用户操作(触摸、点击等)时进行调用,原因参考runningContext配置 * True() 打开成功回调 * False(errMsg) 打开失败回调**/ ,start:function(True,False){ var falseCall=function(msg,noClear){ var next=!checkStop(); if(!noClear)This._clear(); CLog(msg,1); next&&False&&False(msg); }; var checkStop=function(){ if(This.isStop){ CLog($T("6DDt::start被stop终止"),3); return true; }; }; var This=this,set=This.set,__abTest=This.__abTest; if(This._Tc!=null){ falseCall($T("I4h4::{1}多次start",0,BufferStreamPlayerTxt),1); return; } if(!isBrowser){ falseCall($T.G("NonBrowser-1",[BufferStreamPlayerTxt])); return; } This._Tc=0;//currentTime 对应的采样数 This._Td=0;//duration 对应的采样数 This.currentTime=0;//当前已播放的时长,单位ms This.duration=0;//已输入的全部数据总时长,单位ms;实时模式下意义不大,会比实际播放的长,因为实时播放时卡了就会丢弃部分数据不播放 This.isStop=0;//是否已停止 This.isPause=0;//是否已暂停 This.isPlayEnd=0;//已输入的数据是否播放到了结尾(没有可播放的数据了),input后又会变成false;可代表正在缓冲中或播放结束 This.inputN=0;//第n次调用input This.inputQueueIdx=0;//input调用队列当前已处理到的位置 This.inputQueue=[];//input调用队列,用于纠正执行顺序 This.bufferSampleRate=0;//audioBuffer的采样率,首次input后就会固定下来 This.audioBuffer=0; This.pcmBuffer=[[],[]];//未推入audioBuffer的pcm数据缓冲 var fail=function(msg){ falseCall($T("P6Gs::浏览器不支持打开{1}",0,BufferStreamPlayerTxt)+(msg?": "+msg:"")); }; var ctx=set.runningContext || Recorder.GetContext(true); This._ctx=ctx; var sVal=ctx.state,spEnd=Recorder.CtxSpEnd(sVal); !__abTest&&CLog("start... ctx.state="+sVal+( spEnd?$T("JwDm::(注意:ctx不是running状态,start需要在用户操作(触摸、点击等)时进行调用,否则会尝试进行ctx.resume,可能会产生兼容性问题(仅iOS),请参阅文档中runningContext配置)"):"" )); var support=1; if(!ctx || !ctx.createMediaStreamDestination){ support=0; }else{ var source=ctx.createBufferSource(); if(!source.start || source.onended===undefined){ support=0;//createBufferSource版本太低,难兼容 } }; if(!support){ fail(""); return; }; var end=function(){ if(checkStop())return; //创建MediaStream var dest=ctx.createMediaStreamDestination(); dest.channelCount=1; This._dest=dest; !__abTest&&CLog("start ok"); True&&True(); This._inputProcess();//处理未完成start前的input调用 This._updateTime();//更新时间 //定时在没有input输入时,将未写入buffer的数据写进去 if(!badAB){ This._writeInt=setInterval(function(){ This._writeBuffer(); },100); }else{ CLog($T("qx6X::此浏览器的AudioBuffer实现不支持动态特性,采用兼容模式"),3); This._writeInt=setInterval(function(){ This._writeBad(); },10);//定时调用进行数据写入播放 } }; var abTest=function(){ //浏览器实现检测,已知Firefox的AudioBuffer没法在_writeBuffer中动态修改数据;检测方法:直接新开一个,输入一段测试数据,看看能不能拿到流中的数据 var testStream=BufferStreamPlayer({ play:false,sampleRate:8000,runningContext:ctx }); testStream.__abTest=1; var testRec; testStream.start(function(){ testRec=Recorder({ type:"unknown" ,sourceStream:testStream.getMediaStream() ,runningContext:ctx ,onProcess:function(buffers){ var bf=buffers[buffers.length-1],all0=1; for(var i=0;i500)){//已停止或者延迟确认成功 This._PNs=0; This.isPlayEnd=1; call&&call(); This._updateTime(1); }else if(!startTime){//刚检测到的没有数据了,开始延迟确认 This._PNs=Date.now(); }; }; }; } //有数据播放时,取消已到结尾状态 ,_playLive:function(){ var This=this; This.isPlayEnd=0; This._PNs=0; } //时间更新时触发回调,没有更新时不会触发回调 ,_updateTime:function(must){ var This=this,sampleRate=This.bufferSampleRate||9e9,call=This.set.onUpdateTime; This.currentTime=Math.round(This._Tc/sampleRate*1000); This.duration=Math.round(This._Td/sampleRate*1000); var s=""+This.currentTime+This.duration; if(must || This._UTs!=s){ This._UTs=s; call&&call(); } } /**输入任意格式的音频数据,未完成start前调用会等到start成功后生效 anyData: any 具体类型取决于: set.decode为false时: 未提供set.transform,数据必须是pcm[Int16,...],此时的set必须提供sampleRate; 提供了set.transform,数据为transform方法支持的任意格式。 set.decode为true时: 数据必须是ArrayBuffer,会自动解码成pcm[Int16,...];注意输入的每一片数据都应该是完整的一个音频片段文件,否则可能会解码失败;注意ArrayBuffer对象是Transferable object,参与解码后此对象将不可用,因为内存数据已被转移到了解码线程,可通过 stream.input(arrayBuffer.slice(0)) 形式复制一份再解码就没有这个问题了。 关于anyData的二进制长度: 如果是提供的pcm、wav格式数据,数据长度对播放无太大影响,很短的数据也能很好的连续播放。 如果是提供的mp3这种必须解码才能获得pcm的数据,数据应当尽量长点,测试发现片段有300ms以上解码后能很好的连续播放,低于100ms解码后可能会有明显的杂音,更低的可能会解码失败;当片段确实太小时,可以将本来会多次input调用的数据缓冲起来,等数据量达到了300ms再来调用一次input,能比较显著的改善播放音质。 **/ ,input:function(anyData){ var This=this,set=This.set; var inputN=++This.inputN; if(!This.inputQueue){ throw new Error(NoStartMsg()); } var decSet=set.decode; if(decSet){ //先解码 DecodeAudio(anyData, function(data){ if(!This.inputQueue)return;//stop了 if(decSet.fadeInOut==null || decSet.fadeInOut){ FadeInOut(data.data, data.sampleRate);//解码后的数据进行一下淡入淡出处理,减少爆音 } This._input2(inputN, data.data, data.sampleRate); },function(err){ This._inputErr(err, inputN); }); }else{ This._input2(inputN, anyData, set.sampleRate); } } //transform处理 ,_input2:function(inputN, anyData, sampleRate){ var This=this,set=This.set; if(set.transform){ set.transform(anyData, sampleRate, function(pcm, sampleRate2){ if(!This.inputQueue)return;//stop了 sampleRate=sampleRate2||sampleRate; This._input3(inputN, pcm, sampleRate); },function(err){ This._inputErr(err, inputN); }); }else{ This._input3(inputN, anyData, sampleRate); } } //转换好的pcm加入input队列,纠正调用顺序,未start时等待 ,_input3:function(inputN, pcm, sampleRate){ var This=this; if(!pcm || !pcm.subarray){ This._inputErr($T("ZfGG::input调用失败:非pcm[Int16,...]输入时,必须解码或者使用transform转换"), inputN); return; } if(!sampleRate){ This._inputErr($T("N4ke::input调用失败:未提供sampleRate"), inputN); return; } if(This.bufferSampleRate && This.bufferSampleRate!=sampleRate){ This._inputErr($T("IHZd::input调用失败:data的sampleRate={1}和之前的={2}不同",0,sampleRate,This.bufferSampleRate), inputN); return; } if(!This.bufferSampleRate){ This.bufferSampleRate=sampleRate;//首次处理后,固定下来,后续的每次输入都是相同的 } //加入队列,纠正input执行顺序,解码、transform均有可能会导致顺序不一致 This.inputQueue[inputN]=pcm; if(This._dest){//已start,可以开始处理队列 This._inputProcess(); } } ,_inputErr:function(errMsg, inputN){ this.inputQueue[inputN]=1;//出错了,队列里面也要占个位 this.set.onInputError(errMsg, inputN); } //处理input队列 ,_inputProcess:function(){ var This=this; if(!This.bufferSampleRate){ return; } var queue=This.inputQueue; for(var i=This.inputQueueIdx+1;i0){ //开头加上少了的延迟 This.audioBufferIdx=Math.max(This.audioBufferIdx, d150Size); } realMode=false;//切换成顺序播放 break; } //堆积的太多,配置为全丢弃 if(realMode.discardAll){ if(dSize>dMax*1.333){//超过400ms,取200ms正常播放,300ms中位数 pcm0=This._cutPcm0(Math.round(dMax*0.666-wnSize)); } realMode=false;//切换成顺序播放 break; } //堆积的太多,要加速播放了,最多播放积压最后3秒的量,超过的直接丢弃 pcm0=This._cutPcm0(3*sampleRate-wnSize); speed=1.6;//倍速,重采样 //计算要截取出来量 pcmSize=Math.min(maxSize, Math.floor((pcm0.length+pcm1.length)/speed)); break; } if(!realMode){ //*******按顺序取数据播放********* //计算要截取出来量 pcmSize=Math.min(maxSize, pcm0.length+pcm1.length); } if(!pcmSize){ return; } //截取数据并写入到audioBuffer中 This.audioBufferIdx=This._subWrite(buffer,pcmSize,This.audioBufferIdx,speed); } /****************兼容播放处理,播放音质略微差点****************/ ,_writeBad:function(){ var This=this,set=This.set; var buffer=This.audioBuffer; var sampleRate=This.bufferSampleRate; var ctx=This._ctx; //正在播放,5ms不能结束就等待播放完,定时器是10ms if(buffer){ var ms=buffer.length/sampleRate*1000; if(Date.now()-This._createBufferTimedMax*1.333){//超过400ms,取200ms正常播放,300ms中位数 pcm0=This._cutPcm0(Math.round(dMax*0.666)); } realMode=false;//切换成顺序播放 break; } //堆积的太多,要加速播放了,最多播放积压最后3秒的量,超过的直接丢弃 pcm0=This._cutPcm0(3*sampleRate); speed=1.6;//倍速,重采样 //计算要截取出来量 pcmSize=Math.min(maxSize, Math.floor((pcm0.length+pcm1.length)/speed)); break; } if(!realMode){ //*******按顺序取数据播放********* //计算要截取出来量 pcmSize=Math.min(maxSize, pcm0.length+pcm1.length); } if(!pcmSize){ return; } //新建buffer,一次性完整播放当前的数据 buffer=ctx.createBuffer(1,pcmSize,sampleRate); //截取数据并写入到audioBuffer中 This._subWrite(buffer,pcmSize,0,speed); //首尾进行1ms的淡入淡出 大幅减弱爆音 FadeInOut(buffer.getChannelData(0), sampleRate); var source=ctx.createBufferSource(); source.channelCount=1; source.buffer=buffer; source.connect(This._dest); if(set.play){//播放出声音 source.connect(ctx.destination); } source.start();//古董 source.noteOn(0) 不支持onended 放弃支持 This.bufferSource=source; This.audioBuffer=buffer; This._createBufferTime=Date.now(); } ,_cutPcm0:function(pcmNs){//保留堆积的数据到指定的时长数量 var pcms=this.pcmBuffer,pcm0=pcms[0]; if(pcm0.length>pcmNs){//丢弃超过秒数的 pcm0=pcm0.subarray(pcm0.length-pcmNs); pcms[0]=pcm0; } return pcm0; } ,_subPause:function(){//暂停了,就不要消费掉缓冲数据了,等待resume再来消费 var This=this; if(!This.isPause){ return 0; }; if(This.set.realtime){//实时模式,丢弃所有未消费的数据,resume时从最新input的数据开始播放 This.pcmBuffer=[[],[]]; }; return 1; } ,_subWrite:function(buffer, pcmSize, offset, speed){ var This=this; var pcms=This.pcmBuffer; var pcm0=pcms[0],pcm1=pcms[1]; //截取数据 var pcm=new Int16Array(pcmSize); var i=0,n=0; for(var j=0;n=pcm0.length){//堆积的消耗完了 pcm0=new Int16Array(0); for(j=0,i=0;n=pcm1.length){ pcm1=new Int16Array(0); }else{ pcm1=pcm1.subarray(i); } pcms[1]=pcm1; }else{ pcm0=pcm0.subarray(i); } pcms[0]=pcm0; //写入到audioBuffer中 var channel=buffer.getChannelData(0); for(var i=0;i