/
recorder-core.js
1849 lines (1655 loc) · 66.7 KB
/
recorder-core.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
录音
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<arr.length;i++)CloseCtx(arr[i]);
});
};
if(tryNew && ctx.close){//没法关闭的不允许再创建
try{
ctx=new AC();
Recorder.NewCtxs.push(ctx);
}catch(e){
CLog("GetContext tryNew Error",1,e);
}
};
return ctx;
};
//关闭新创建的AudioContext,如果是全局的不关闭
Recorder.CloseNewCtx=function(ctx){
if(ctx && ctx!=Recorder.Ctx){
CloseCtx(ctx);
var arr=Recorder.NewCtxs||[],L=arr.length;
for(var i=0;i<arr.length;i++){
if(arr[i]==ctx){ arr.splice(i,1); break; }
}
CLog($T("mSxV::剩{1}个GetContext未close",0,L+"-1="+arr.length),arr.length?3:0);
}
};
var CloseCtx=function(ctx){
if(ctx && ctx.close){
ctx._isC=1;
try{ ctx.close() }catch(e){ CLog("ctx close err",1,e) }
}
};
//当AudioContext的状态是suspended时,调用resume恢复状态,但如果没有用户操作resume可能没有回调,封装解决此回调问题;check(count)返回true继续尝试resume,返回false终止任务(不回调False)
var ResumeCtx=Recorder.ResumeCtx=function(ctx,check,True,False){
var isEnd=0,isBind=0,isLsSC=0,runC=0,EL="EventListener",Tag="ResumeCtx ";
var end=function(err,ok){
if(isBind){ bind() }
if(!isEnd){ isEnd=1; //回调结果
err&&False(err,runC);
ok&&True(runC);
}
if(ok){ //监听后续状态变化
if(!ctx._LsSC && ctx["add"+EL]) ctx["add"+EL]("statechange",run);
ctx._LsSC=1; isLsSC=1;
}
};
var bind=function(add){
if(add && isBind) return; isBind=add?1:0;
var types=["focus","mousedown","mouseup","touchstart","touchend"];
for(var i=0;i<types.length;i++)
window[(add?"add":"remove")+EL](types[i],run,true);
};
var run=function(){
var sVal=ctx.state,spEnd=CtxSpEnd(sVal);
if(!isEnd && !check(spEnd?++runC:runC))return end(); //终止,不回调
if(spEnd){
if(isLsSC)CLog(Tag+"sc "+sVal,3);
bind(1); //绑定用户事件尝试恢复
ctx.resume().then(function(){ //resume回调不可靠
if(isLsSC)CLog(Tag+"sc "+ctx.state);
end(0,1);
})[CatchTxt](function(e){ //出错且无法恢复
CLog(Tag+"error",1,e);
if(!CtxSpEnd(ctx.state)){
end(e.message||"error");
}
});
}else if(sVal=="closed"){
if(isLsSC && !ctx._isC)CLog(Tag+"sc "+sVal,1); //无法恢复,打个日志
end("ctx closed");
}else{ end(0,1) }; //running 或老的无state
};
run();
};
var CtxSpEnd=Recorder.CtxSpEnd=function(v){
return v=="suspended"||v=="interrupted"; //后面这个仅iOS有
};
var CtxState=function(ctx){
var v=ctx.state,msg="ctx.state="+v;
if(CtxSpEnd(v))msg+=$T("nMIy::(注意:ctx不是running状态,rec.open和start至少要有一个在用户操作(触摸、点击等)时进行调用,否则将在rec.start时尝试进行ctx.resume,可能会产生兼容性问题(仅iOS),请参阅文档中runningContext配置)");
return msg;
};
/*是否启用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 stream=streamStore.Stream;
var ctx=stream._RC || stream._c || Recorder.GetContext(true);//2023-06 尽量创建新的ctx,免得Safari再次连接无回调
stream._c=ctx;
var mediaConn=function(node){
var media=stream._m=ctx.createMediaStreamSource(stream);
var ctxDest=ctx.destination,cmsdTxt="createMediaStreamDestination";
if(ctx[cmsdTxt]){
ctxDest=stream._d=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=$T("ZGlf::。由于{1}内部1秒375次回调,在移动端可能会有性能问题导致回调丢失录音变短,PC端无影响,暂不建议开启{1}。",0,audioWorklet);
var oldScript=function(){
isWorklet=stream.isWorklet=false;
_Disconn_n(stream);
CLog($T("7TU0::Connect采用老的{1},",0,scriptProcessor)
+i18n.get(Recorder[ConnectEnableWorklet]?
$T("JwCL::但已设置{1}尝试启用{2}",2)
:$T("VGjB::可设置{1}尝试启用{2}",2)
,[RecTxt+"."+ConnectEnableWorklet+"=true",audioWorklet]
)+webMTips+oldIsBest,3);
var process=stream._p=oldFn.call(ctx,bufferSize,1,1);//单声道,省的数据处理复杂
mediaConn(process);
process.onaudioprocess=function(e){
var arr=e.inputBuffer.getChannelData(0);
onReceive(arr);
};
};
//===================连接方式二=========================
var connWorklet=function(){
//尝试开启AudioWorklet处理
isWebM=stream.isWebM=false;
_Disconn_r(stream);
isWorklet=stream.isWorklet=!oldFn || Recorder[ConnectEnableWorklet];
var AwNode=window.AudioWorkletNode;
if(!(isWorklet && ctx[audioWorklet] && AwNode)){
oldScript();//被禁用 或 不支持,直接使用老的
return;
};
var clazzUrl=function(){
var xf=function(f){return f.toString().replace(/^function|DEL_/g,"").replace(/\$RA/g,recAudioWorklet)};
var clazz='class '+RecProc+' extends AudioWorkletProcessor{';
clazz+="constructor "+xf(function(option){
DEL_super(option);
var This=this,bufferSize=option.processorOptions.bufferSize;
This.bufferSize=bufferSize;
This.buffer=new Float32Array(bufferSize*2);//乱给size搞乱缓冲区不管
This.pos=0;
This.port.onmessage=function(e){
if(e.data.kill){
This.kill=true;
$C.log("$RA kill call");
}
};
$C.log("$RA .ctor call", option);
});
//https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletProcessor/process 每次回调128个采样数据,1秒375次回调,高频导致移动端性能问题,结果就是回调次数缺斤少两,进而导致丢失数据,PC端似乎没有性能问题
clazz+="process "+xf(function(input,b,c){//需要等到ctx激活后才会有回调
var This=this,bufferSize=This.bufferSize;
var buffer=This.buffer,pos=This.pos;
input=(input[0]||[])[0]||[];
if(input.length){
buffer.set(input,pos);
pos+=input.length;
var len=~~(pos/bufferSize)*bufferSize;
if(len){
this.port.postMessage({ val: buffer.slice(0,len) });
var more=buffer.subarray(len,pos);
buffer=new Float32Array(bufferSize*2);
buffer.set(more);
pos=more.length;
This.buffer=buffer;
}
This.pos=pos;
}
return !This.kill;
});
clazz+='}'
+'try{'
+'registerProcessor("'+RecProc+'", '+RecProc+')'
+'}catch(e){$C.error("'+recAudioWorklet+' Reg Error",e)}';
clazz=clazz.replace(/\$C\./g,"console.");//一些编译器会文本替换日志函数
//URL.createObjectURL 本地有些浏览器会报 Not allowed to load local resource,直接用dataurl
return "data:text/javascript;base64,"+btoa(unescape(encodeURIComponent(clazz)));
};
var awNext=function(){//可以继续,没有调用断开
return isWorklet && stream._na;
};
var nodeAlive=stream._na=function(){
//start时会调用,只要没有收到数据就断定AudioWorklet有问题,恢复用老的
if(badInt!==""){//没有回调过数据
clearTimeout(badInt);
badInt=setTimeout(function(){
badInt=0;
if(awNext()){
CLog($T("MxX1::{1}未返回任何音频,恢复使用{2}",0,audioWorklet,scriptProcessor),3);
oldFn&&oldScript();//未来没有老的,可能是误判
};
},500);
};
};
var createNode=function(){
if(!awNext())return;
var node=stream._n=new AwNode(ctx, RecProc, {
processorOptions:{bufferSize:bufferSize}
});
mediaConn(node);
node.port.onmessage=function(e){
if(badInt){
clearTimeout(badInt);badInt="";
};
if(awNext()){
onReceive(e.data.val);
}else if(!isWorklet){
CLog($T("XUap::{1}多余回调",0,audioWorklet),3);
};
};
CLog($T("yOta::Connect采用{1},设置{2}可恢复老式{3}",0,audioWorklet,RecTxt+"."+ConnectEnableWorklet+"=false",scriptProcessor)+webMTips+oldIsBest,3);
};
//如果start时的resume和下面的构造node同时进行,将会导致部分浏览器崩溃 (STATUS_ACCESS_VIOLATION),源码assets中 ztest_chrome_bug_AudioWorkletNode.html 可测试。所以,将所有代码套到resume里面(不管catch),避免出现这个问题
var ctxOK=function(){
if(!awNext())return;
if(ctx[RecProc]){
createNode();
return;
};
var url=clazzUrl();
ctx[audioWorklet].addModule(url).then(function(e){
if(!awNext())return;
ctx[RecProc]=1;
createNode();
if(badInt){//重新计时
nodeAlive();
};
})[CatchTxt](function(e){ //fix 关键字,保证catch压缩时保持字符串形式
CLog(audioWorklet+".addModule Error",1,e);
awNext()&&oldScript();
});
};
ResumeCtx(ctx,function(){ return awNext() } ,ctxOK,ctxOK);
};
//===================连接方式一=========================
var connWebM=function(){
//尝试开启MediaRecorder录制webm+pcm处理
var MR=window[MediaRecorderTxt];
var onData="ondataavailable";
var webmType="audio/webm; codecs=pcm";
isWebM=stream.isWebM=Recorder[ConnectEnableWebM];
var supportMR=MR && (onData in MR.prototype) && MR.isTypeSupported(webmType);
webMTips=supportMR?"":$T("VwPd::(此浏览器不支持{1})",0,MRWebMPCM);
if(!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($T("vHnb::{1}未返回任何音频,降级使用{2}",0,MediaRecorderTxt,audioWorklet),3);
connWorklet();
};
},500);
};
};
var mrSet=Object.assign({mimeType:webmType}, Recorder.ConnectWebMOptions);
var mr=stream._r=new MR(stream, mrSet);
var webmData=stream._rd={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($T("O9P7::{1}多余回调",0,MediaRecorderTxt),3);
};
};
reader.readAsArrayBuffer(e.data);
};
mr.start(~~(bufferSize/48));//按48k时的回调间隔
CLog($T("LMEm::Connect采用{1},设置{2}可恢复使用{3}或老式{4}",0,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){
try{ stream._r.stop() }catch(e){ CLog("mr stop err",1,e) }
stream._r=null;
};
};
var Disconnect=function(streamStore){
streamStore=streamStore||Recorder;
var isGlobal=streamStore==Recorder;
var stream=streamStore.Stream;
if(stream){
if(stream._m){
stream._m.disconnect();
stream._m=null;
};
if(!stream._RC && stream._c){//提供的runningContext不处理
Recorder.CloseNewCtx(stream._c);
};
stream._RC=null; stream._c=null;
if(stream._d){
StopS_(stream._d.stream);
stream._d=null;
};
if(stream._p){
stream._p.disconnect();
stream._p.onaudioprocess=stream._p=null;
};
_Disconn_n(stream);
_Disconn_r(stream);
if(isGlobal){//全局的时候,要把流关掉(麦克风),直接提供的流不处理
StopS_(stream);
};
};
streamStore.Stream=0;
};
//关闭一个音频流
var StopS_=Recorder.StopS_=function(stream){
var tracks=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();
};
/*对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中的偏移的下一个位置
//可定义,指定的一个滤波配置:默认使用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;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;
var F=filterFn&&filterFn.Embed,F1=0,F2=0,Fx=0,Fy=0;//低通滤波后的数据
for(var i0=0,i2=0;i0<il;i0++,i2++){
if(i2<il){
if(F){//IIRFilter代码内置,比函数调用快4倍
Fx=o[i2];
Fy=F.b0 * Fx + F.b1 * F.x1 + F.b0 * F.x2 - F.a1 * F.y1 - F.a2 * F.y2;
F.x2 = F.x1; F.x1 = Fx; F.y2 = F.y1; F.y1 = Fy;
}else{ Fy=filterFn?filterFn(o[i2]):o[i2]; }
}
F1=F2; F2=Fy;
if(i2==0){ i0--; continue; } //首次只计算o[0]
//res[idx]=o[Math.round(i)]; 直接简单抽样
//https://www.cnblogs.com/xiaoqi/p/6993912.html
//当前点=当前点+到后面一个点之间的增量,音质比直接简单抽样好些
var before = Math.floor(i);
if(i0!=before)continue;
var after = Math.ceil(i);
var atPoint = i - before;
var beforeVal=F1;
var afterVal=after<il ? F2 : beforeVal; //后个点越界了,忽略不计
var val=beforeVal+(afterVal-beforeVal)*atPoint;
if(val>0x7FFF) 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(;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){
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压缩时保持字符串形式