Skip to content

Commit

Permalink
增加GetContext、PowerDBFS,Recorder.CLog可以禁用日志输出,尝试修复iPhone14专有的AudioCont…
Browse files Browse the repository at this point in the history
…ext.resume异常
  • Loading branch information
xiangyuecn committed Feb 1, 2023
1 parent 73fcf4b commit e61c596
Show file tree
Hide file tree
Showing 17 changed files with 365 additions and 59 deletions.
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@

# :open_book:Recorder用于html5录音

[](?Ref=Desc&Start)支持在大部分已实现`getUserMedia`的移动端、PC端浏览器麦克风录音、实时处理,主要包括:Chrome、Firefox、Safari、iOS 14.3+、Android WebView、腾讯Android X5内核(QQ、微信、小程序WebView)、大部分2021年后更新的Android手机自带浏览器;不支持:~~UC系内核(典型的支付宝),大部分未更新的老旧国产手机自带浏览器,低版本iOS(11.0-14.2)上除Safari外的其他任何形式的浏览器(含PWA、WebClip、任何App内网页)~~
[](?Ref=Desc&Start)支持在大部分已实现`getUserMedia`的移动端、PC端浏览器麦克风录音、实时处理,主要包括:Chrome、Firefox、Safari、iOS 14.3+、Android WebView、腾讯Android X5内核(QQ、微信、小程序WebView)、uni-app(App、H5)、大部分2021年后更新的Android手机自带浏览器;不支持:~~UC系内核(典型的支付宝),大部分未更新的老旧国产手机自带浏览器,低版本iOS(11.0-14.2)上除Safari外的其他任何形式的浏览器(含PWA、WebClip、任何App内网页)~~

支持对任意`MediaStream`进行音频录制、实时处理,包括:`getUserMedia返回的流``WebRTC中的remote流``audio、video标签的captureStream方法返回的流``自己创建的流` 等等。

提供多个插件功能支持:拥有丰富的音频可视化、变速变调处理、语音识别、音频流播放等;搭配上强大的实时处理支持,可用于各种网页应用:从简单的录音,到复杂的实时语音识别(ASR),甚至音频相关的游戏,都能从容应对。

音频文件的播放:可直接使用常规的`Audio HTML标签`来播放完整的音频文件,参考文档下面的【快速使用】部分,有播放例子;上传了的录音直接将音频链接赋值给`audio.src`即可播放;本地的`blob音频文件`可通过`URL.createObjectURL`来生成本地链接赋值给`audio.src`即可播放,或者将blob对象直接赋值给`audio.srcObject`(兼容性没有src高)。实时的音频片段文件播放,可以使用本库自带的`BufferStreamPlayer`插件来播放,简单高效,或者采用别的途径播放。
音频文件的上传和播放:可直接使用常规的`Audio HTML标签`来播放完整的音频文件,参考文档下面的【快速使用】部分,有上传和播放例子;上传了的录音直接将音频链接赋值给`audio.src`即可播放;本地的`blob音频文件`可通过`URL.createObjectURL`来生成本地链接赋值给`audio.src`即可播放,或者将blob对象直接赋值给`audio.srcObject`(兼容性没有src高)。实时的音频片段文件播放,可以使用本库自带的`BufferStreamPlayer`插件来播放,简单高效,或者采用别的途径播放。

**如需录音功能定制开发,网站、App、小程序、前端后端开发等需求,请加本文档下面的QQ群,联系群主(即作者),谢谢~**

Expand Down Expand Up @@ -56,7 +56,7 @@
> 对于不支持录音的浏览器,引入js和调用相关方法都不会产生异常(IE8+),会进入相关的fail回调;一般在open的时候就能检测到不支持或被用户拒绝了权限,可在用户开始录音之前提示浏览器不支持录音或授权。

> 如需在Hybrid App WebView内使用(支持iOS、Android),请参阅本文档下面的【快速使用】中附带的示例,参考示例代码给网页授予录音权限,或直接由App底层提供接口给H5调用([app-support-sample](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample)目录内有源码)。
> 如需在Hybrid App WebView内使用(支持iOS、Android,包括uni-app),请参阅本文档下面的【快速使用】中附带的示例,参考示例代码给网页授予录音权限,或直接由App底层提供接口给H5调用([app-support-sample](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample)目录内有源码)。

> *低版本iOS兼容、老旧国产手机自带浏览器上的使用限制等问题和兼容请参阅下面的知识库部分;打开录音后对音频播放的影响、录音中途来电话等问题也参阅下面的知识库。*
Expand Down Expand Up @@ -390,9 +390,18 @@ iOS 14.3+:新版本iOS WKWebView已支持H5录音,但作者还未测试,
iOS 11.0-14.2:纯粹的H5录音在iOS WebView中是不支持的,需要有Native层的支持,具体参考RecordApp中的[app-support-sample/demo_ios](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample/demo_ios),含iOS App源码。


[](?)

## 【附】UniApp - uni-app(App、H5)集成参考
只要是WebView环境,且能访问到window对象,就能直接使用Recorder录音。uni-app中的`renderjs`是直接运行在视图层WebView中的,因此可以通过在`renderjs`中加载Recorder来进行录音;支持App、H5,但不支持小程序(小程序可用web-view组件加载H5,或调用小程序自己的录音接口)。

注意在开发App平台的代码时,需在调用`rec.open`前,在原生层获取到录音权限;和上面的Android和iOS一样先配置好录音权限声明,再调用权限请求接口,在逻辑层中编写js权限处理代码(非renderjs层),参考:
- Android:直接调用`plus.android.requestPermissions(["android.permission.RECORD_AUDIO"],callback)`得到权限;
- iOS:通过反射`audioSession=plus.ios.importClass("AVAudioSession").sharedInstance()`,调用`status=audioSession.recordPermission()`来判断是否有权限:`status=1735552628 granted`为已获得权限;`status=1970168948 undetermined`为从未授权过,此时要调用`audioSession.requestRecordPermission(callback)`来请求权限(回调中从头再判断一遍权限);`status=其他值 eg:1684369017 denied`代表无权限。

除了请求权限这个差异外,App和H5没有区别。但App需注意的是,uni-app的逻辑层和视图层数据交互性能实在太拉跨了,大点的录音二进制数据传回给逻辑层可能会异常缓慢,就算用plus接口在renderjs中保存到本地文件,会发现plus接口的坑更多(他们框架对于二进制操作几乎没有任何性能可言)。

App端建议使用原生插件来录音,没有这些框架缺陷带来的性能问题,修改`RecordApp`对接原生插件来录音。作者已编译好了Android原生录音`.aar module 25KB`、iOS原生录音`.a library 200KB`,集成到项目的`nativeplugins`目录中;逻辑层中通过`uni.requireNativePlugin`来获取接口给`RecordApp`调用,RecordApp会自动识别App和网页环境,App中走原生录音,网页中走H5录音;此原生插件暂未开源,如需请加上面的QQ群联系群主付费购买。



Expand Down Expand Up @@ -555,9 +564,9 @@ set={
只要open成功后,调用此方法是安全的,如果未open强行调用导致的内部错误将不会有任何提示,stop时自然能得到错误;另外open操作可能需要花费比较长时间,如果中途调用了stop,open完成时(同步)的任何start调用将会被自动阻止,也是不会有提示的。

### 【方法】rec.stop(success,fail,autoClose)
结束录音并返回录音数据`blob对象`,拿到blob对象就可以为所欲为了,不限于立即播放、上传
结束录音并返回录音数据`blob文件对象`,拿到blob文件对象就可以为所欲为了,不限于立即播放、上传;blob可以用`XMLHttpRequest+FormData``WebSocket`直接发送到服务器,或者用`FileReader`读取成`ArrayBuffer`或者`Base64`给js处理。

`success(blob,duration)``blob`录音数据audio/mp3|wav...格式,`duration`:录音时长,单位毫秒
`success(blob,duration)``blob`录音二进制文件数据audio/mp3|wav...格式,`duration`:录音时长,单位毫秒

`fail(errMsg)`:录音出错回调

Expand Down Expand Up @@ -634,12 +643,18 @@ function transformOgg(pcmData){
### 【静态方法】Recorder.Support()
判断浏览器是否支持录音,随时可以调用。注意:仅仅是检测浏览器支持情况,不会判断和调起用户授权(rec.open()会判断用户授权),不会判断是否支持特定格式录音。

### 【静态方法】Recorder.GetContext()
获取全局的AudioContext对象,如果浏览器不支持将返回null;本方法调用一次后,可通过`Recorder.Ctx`来获得此对象,可用于音频文件解码:`Recorder.Ctx.decodeAudioData(fileArrayBuffer)`。本方法是从老版本的`Recorder.Support()`中剥离出来的,调用Support会自动调用一次本方法。

### 【静态方法】Recorder.IsOpen()
由于Recorder持有的普通麦克风录音资源是全局唯一的,可通过此方法检测是否有Recorder已调用过open打开了麦克风录音功能。

### 【静态方法】Recorder.Destroy()
销毁已持有的所有全局资源(AudioContext、Worker),当要彻底移除Recorder时需要显式的调用此方法。大部分情况下不调用Destroy也不会造成问题。

### 【静态方法】Recorder.CLog
全局的日志输出函数,可赋值一个空函数来屏蔽Recorder的日志输出`Recorder.CLog=function(){}`

### 【静态属性】Recorder.TrafficImgUrl
流量统计用1像素图片地址,在Recorder首次被实例化时将往这个地址发送一个请求,请求是通过Image对象来发送,安全可靠;默认开启统计,url为本库的51la统计用图片地址,为空响应流量消耗非常小,因此对使用几乎没有影响。

Expand Down Expand Up @@ -721,6 +736,12 @@ function transformOgg(pcmData){
`pcmLength`: pcm长度


### 【静态方法】Recorder.PowerDBFS(maxSample)
计算音量,单位dBFS(满刻度相对电平),返回值:-100~0 (最大值0dB,最小值-100代替-∞)。

`maxSample`: 为16位pcm采样的绝对值中最大的一个(计算峰值音量),或者为pcm中所有采样的绝对值的平局值





Expand Down
8 changes: 4 additions & 4 deletions assets/npm-home/hash-history.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
[
{
"sha1": "ef550552c34f40aebd27d186d8608540be998a02",
"time": "2023/2/1 23:06:25"
},
{
"sha1": "1ce1cee1bd9a6532c1105a27493ca1097c174ec1",
"time": "2022-8-7 21:57:00"
Expand All @@ -14,9 +18,5 @@
{
"sha1": "88b22aab0ee72cd0a07d594cb0e035067f28cb69",
"time": "2022-5-7 23:49:36"
},
{
"sha1": "c4d411cffdaf1b15f74acf49a298c15328d845b4",
"time": "2022-3-5 11:51:56"
}
]
84 changes: 84 additions & 0 deletions assets/runtime-codes/fragment.decode.wav.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/******************
《【Demo库】WAV文件解码》
作者:高坚果
时间:2023-01-11 16:37
文档:
DemoFragment.DecodeWav(u8arr)
u8arr: wav的二进制数据Uint8Array
返回:{
pcm:[Int16,...] 解码出来的pcm数据Int16Array
sampleRate:16000 wav的采样率,也是pcm的采样率
duration:123 时长
bitRate:16 wav的位数,注意:pcm固定16位
numChannels:1 wav的声道数,注意:pcm固定单声道
}
解码失败会抛异常
******************/
(
window.DemoFragment||(window.DemoFragment={})
).DecodeWav=function(u8arr){
var eq=function(p,s){
for(var i=0;i<s.length;i++){
if(u8arr[p+i]!=s.charCodeAt(i)){
return false;
};
};
return true;
};
if(eq(0,"RIFF")&&eq(8,"WAVEfmt ")){
var numCh=u8arr[22];
if(u8arr[20]==1 && (numCh==1||numCh==2)){//raw pcm
var sampleRate=u8arr[24]+(u8arr[25]<<8)+(u8arr[26]<<16)+(u8arr[27]<<24);
var bitRate=u8arr[34]+(u8arr[35]<<8);

//搜索data块的位置
var dataPos=0; // 44 或有更多块
for(var i=12,iL=u8arr.length-4;i<iL;){
if(u8arr[i]==100&&u8arr[i+1]==97&&u8arr[i+2]==116&&u8arr[i+3]==97){//eq(i,"data")
dataPos=i+8;break;
}
i+=4;
i+=4+u8arr[i]+(u8arr[i+1]<<8)+(u8arr[i+2]<<16)+(u8arr[i+3]<<24);
}
if(!dataPos){
throw new Error("未找到wav的data块");
}

//统一转成16位
if(bitRate==16){
var pcm=new Int16Array(u8arr.buffer.slice(dataPos));
}else if(bitRate==8){//8位转成16位
var pcm=new Int16Array(u8arr.length-dataPos);
for(var i=dataPos,j=0;j<pcm.length;i++,j++){
pcm[j]=(u8arr[i]-128)<<8;
};
}else{
throw new Error("只支持8位或16位的wav格式");
}
//转成单声道
if(numCh==2){
var pcm1=new Int16Array(pcm.length/2);
for(var i=0;i<pcm1.length;i++){
pcm1[i]=pcm[i*2];
}
pcm=pcm1;
}
var duration=Math.round(pcm.length/sampleRate*1000);

console.log("DecodeWav",sampleRate,bitRate,numCh
,pcm.length
,duration+"ms @"+dataPos);
return {
pcm:pcm
,duration:duration
,sampleRate:sampleRate
,bitRate:bitRate
,numChannels:numCh
};
};
throw new Error("只支持单声道或双声道wav格式");
};
throw new Error("非wav格式音频");
};
2 changes: 1 addition & 1 deletion assets/runtime-codes/lib.transform.mp32other.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Recorder.Mp32Other(newSet,mp3Blob,True,False)

//=====mp3转其他格式核心函数==========
Recorder.Mp32Other=function(newSet,mp3Blob,True,False){
if(!Recorder.Support()){//强制激活Recorder.Ctx 不支持大概率也不支持解码
if(!Recorder.GetContext()){//强制激活Recorder.Ctx 不支持大概率也不支持解码
False&&False("浏览器不支持mp3解码");
return;
};
Expand Down
185 changes: 185 additions & 0 deletions assets/runtime-codes/test.filter.iir.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/******************
《【测试】IIR低通、高通滤波》
作者:高坚果
时间:2023-01-11 16:32
移植java代码测试,测试结果: DigitalAudioFilter 比 MinimIIRFilter 过滤的干净,需要的频率能量损失也更小
******************/

/******Java代码1******/
//https://gitee.com/52jian/digital-audio-filter/blob/master/src/main/java/com/zj/filter/AudioFilter.java
//https://blog.csdn.net/Janix520/article/details/118411734
Recorder.DigitalAudioFilter=function(useLowPass, sampleRate, freq){
var Q=1;
var ov = 2 * Math.PI * freq / sampleRate;
var sn = Math.sin(ov);
var cs = Math.cos(ov);
var alpha = sn / (2 * Q);

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;
var b2 = (1 - cs) / 2 / a0;
}else{
var b0 = (1 + cs) / 2 / a0;
var b1 = -(1 + cs) / a0;
var b2 = (1 + cs) / 2 / a0;
}

var x1=0,x2=0,y=0,y1=0,y2=0;
return function(x){
y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
x2 = x1;
x1 = x;
y2 = y1;
y1 = y;
return y;
};
};


/******Java代码2******/
//https://github.com/ddf/Minim/tree/master/src/main/java/ddf/minim/effects
Recorder.MinimIIRFilter=function(useLowPass, sampleRate, freq){
var freqFrac = freq/sampleRate;
if(useLowPass=="FS"){ //LowPassFS.java
var x = Math.exp(-14.445 * freqFrac);
var a = [ Math.pow(1 - x, 4) ];
var b = [ 4 * x, -6 * x * x, 4 * x * x * x, -x * x * x * x ];
}else if(useLowPass){ //LowPassSP.java
var x = Math.exp(-2*Math.PI*freqFrac);
var a=[ 1 - x ];
var b=[ x ];
}else{ //HighPassSP.java
var x = Math.exp(-2 * Math.PI * freqFrac);
var a = [ (1+x)/2, -(1+x)/2 ];
var b = [ x ];
}

var out=[],ins=[];
for(var i=0,L=Math.max(a.length,b.length);i<L;i++){
out[i]=0; ins[i]=0;
}
return function(x){ //IIRFilter.java uGenerate
ins.splice(0,0,x);
ins.length--;

var y = 0;
for(var ci = 0; ci < a.length; ci++) {
y += a[ci] * ins[ci];
}
for(var ci = 0; ci < b.length; ci++) {
y += b[ci] * out[ci];
}
out.splice(0,0,y);
out.length--;
return y;
};
};






//=====测试代码==================
//加载录音框架
Runtime.Import([
{url:RootFolder+"/src/recorder-core.js",check:function(){return !window.Recorder}}
,{url:RootFolder+"/src/engine/wav.js",check:function(){return !Recorder.prototype.wav}}
,{url:RootFolder+"/assets/runtime-codes/fragment.decode.wav.js",check:function(){return !window.DemoFragment||!DemoFragment.DecodeWav}}//引入DemoFragment.DecodeWav
]);

//显示控制按钮
Runtime.Ctrls([
{html:'<div class="testChoiceFile"></div>'}
,{html:`
<div>
<div>低通:<input class="in_lowPassHz" style="width:100px">Hz,不填不滤波<span class="maxHz"></span></div>
<div>高通:<input class="in_highPassHz" style="width:100px">Hz,不填不滤波<span class="maxHz"></span></div>
<div>采样率:<input class="in_sampleRate" style="width:100px">,不填不转换采样率<span class="maxSampleRate"></span></div>
</div>`}
,{name:"开始转换1",click:"test(1);Date.now"}
,{name:"开始转换2",click:"test(2);Date.now"}
,{name:"开始转换2_FS",click:"test(2,true);Date.now"}

,{choiceFile:{multiple:false,title:"解码",
process:function(fileName,arrayBuffer,filesCount,fileIdx,endCall){
if(/\.wav$/i.test(fileName)){
try{
var data=DemoFragment.DecodeWav(new Uint8Array(arrayBuffer));
}catch(e){
Runtime.Log(fileName+"解码失败:"+e.message,1);
return endCall();
}
setPcmData({pcm:data.pcm,sampleRate:data.sampleRate});
return endCall();
}
Runtime.DecodeAudio(fileName,arrayBuffer,function(data){
setPcmData({pcm:data.data,sampleRate:data.sampleRate});

endCall();
},function(msg){
Runtime.Log(msg,1);
endCall();
});
}
}}
]);

$(".testChoiceFile").append($(".RuntimeChoiceFileBox"));
var pcmData;
var setPcmData=function(data){
pcmData=data;
$(".maxHz").html("最高"+(data.sampleRate/2)+"Hz");
$(".maxSampleRate").html("最大"+data.sampleRate);

var rec=Recorder({
type:"wav",bitRate:16,sampleRate:data.sampleRate
}).mock(data.pcm,data.sampleRate);
rec.stop(function(blob,duration){
Runtime.LogAudio(blob,duration,rec,"文件解码成功");
Runtime.Log("pcm数据已准备好,可以开始转换了,pcm.sampleRate="+data.sampleRate,2);
});
};

var test=function(fn,useFS){
if(!pcmData){
Runtime.Log("请先拖一个文件进来解码",1);
return;
}
var srcSampleRate=pcmData.sampleRate;
var lowPassHz=+$(".in_lowPassHz").val()||0;
var highPassHz=+$(".in_highPassHz").val()||0;
var newSampleRate=+$(".in_sampleRate").val()||0;

var lowPass=null,highPass=null,fnName="";
if(fn==1){
fnName="DigitalAudioFilter";
}else{
fnName="MinimIIRFilter";
}
if(lowPassHz)
lowPass=Recorder[fnName](useFS?"FS":true,srcSampleRate,lowPassHz);
if(highPassHz)
highPass=Recorder[fnName](false,srcSampleRate,highPassHz);

var pcm=new Int16Array(pcmData.pcm.length);
for(var i=0;i<pcm.length;i++){
var v=pcmData.pcm[i];
if(lowPass)v=lowPass(v);
if(highPass)v=highPass(v);
pcm[i]=v;
}

Runtime.Log("开始转换"+fn+" "+fnName+(useFS?".FS":"")+":"+JSON.stringify({lowPass:lowPassHz,highPass:highPassHz,sampleRate:newSampleRate,srcSampleRate:srcSampleRate}),"#aaa");
var rec=Recorder({
type:"wav",bitRate:16,sampleRate:newSampleRate||srcSampleRate
}).mock(pcm,srcSampleRate);
rec.stop(function(blob,duration){
Runtime.LogAudio(blob,duration,rec,"已转换"+fn);
});
};
Loading

0 comments on commit e61c596

Please sign in to comment.