first commit
21
WebRoot/node_modules/Recorder-master/LICENSE
generated
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 xiangyuecn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
423
WebRoot/node_modules/Recorder-master/QuickStart.html
generated
vendored
Normal file
@ -0,0 +1,423 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||
<link rel="shortcut icon" type="image/png" href="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/assets/icon.png">
|
||||
|
||||
<title>Recorder QuickStart: 快速入门</title>
|
||||
<script>
|
||||
var Tips='你可以直接将 <a target="_blank" href="https://github.com/xiangyuecn/Recorder/blob/master/QuickStart.html">/QuickStart.html</a> 文件copy到你的(https)网站中,无需其他文件,就能正常开始测试了;相比 Recorder H5 (/index.html) 这个大而全(杂乱)的demo,本文件更适合入门学习'+unescape("%uD83D%uDE04");
|
||||
console.log(Tips);
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--
|
||||
【1】引入框架文件,注意自己使用时应当自己把源码clone下来,然后通过src="/src/recorder-core.js"引入,这里为了方便copy文件测试起见,使用了JsDelivr CDN。
|
||||
|
||||
另外:[1.1]、[1.2]可以合并为使用"/recorder.mp3.min.js",这个文件为压缩版大幅减小文件体积,已经包含了这3个源码文件
|
||||
-->
|
||||
|
||||
<!-- 【1.1】引入核心文件 -->
|
||||
<script src="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/src/recorder-core.js"></script>
|
||||
|
||||
<!-- 【1.2】引入相应格式支持文件;如果需要多个格式支持,把这些格式的编码引擎js文件放到后面统统加载进来即可 -->
|
||||
<script src="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/src/engine/mp3.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/src/engine/mp3-engine.js"></script>
|
||||
|
||||
<!-- 【1.3】引入可选的扩展支持项,如果不需要这些扩展功能可以不引入 -->
|
||||
<script src="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/src/extensions/frequency.histogram.view.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/src/extensions/lib.fft.js"></script>
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 【2】构建界面 -->
|
||||
<div class="main">
|
||||
<div class="mainBox">
|
||||
<span style="font-size:32px;color:#f60;">Recorder QuickStart: 快速入门</span>
|
||||
<a href="https://github.com/xiangyuecn/Recorder">GitHub >></a>
|
||||
|
||||
<div style="padding-top:10px;color:#666">
|
||||
更多Demo:
|
||||
<a class="lb" href="https://xiangyuecn.github.io/Recorder/">Recorder H5</a>
|
||||
<a class="lb" href="https://jiebian.life/web/h5/github/recordapp.aspx">RecordApp</a>
|
||||
<a class="lb" href="https://jiebian.life/web/h5/github/recordapp.aspx?path=/app-support-sample/QuickStart.html">RecordApp QuickStart</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mainBox">
|
||||
<!-- 按钮控制区域 -->
|
||||
<div class="pd btns">
|
||||
<div>
|
||||
<button onclick="recOpen()" style="margin-right:10px">打开录音,请求权限</button>
|
||||
<button onclick="recClose()" style="margin-right:0">关闭录音,释放资源</button>
|
||||
</div>
|
||||
|
||||
<button onclick="recStart()">录制</button>
|
||||
<button onclick="recStop()" style="margin-right:80px">停止</button>
|
||||
|
||||
<span style="display: inline-block;">
|
||||
<button onclick="recPause()">暂停</button>
|
||||
<button onclick="recResume()">继续</button>
|
||||
</span>
|
||||
<span style="display: inline-block;">
|
||||
<button onclick="recPlay()">播放</button>
|
||||
<button onclick="recUpload()">上传</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 波形绘制区域 -->
|
||||
<div class="pd recpower">
|
||||
<div style="height:40px;width:300px;background:#999;position:relative;">
|
||||
<div class="recpowerx" style="height:40px;background:#0B1;position:absolute;"></div>
|
||||
<div class="recpowert" style="padding-left:50px; line-height:40px; position: relative;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pd waveBox">
|
||||
<div style="border:1px solid #ccc;display:inline-block"><div style="height:100px;width:300px;" class="recwave"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志输出区域 -->
|
||||
<div class="mainBox">
|
||||
<div class="reclog"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 【3】实现录音逻辑 -->
|
||||
<script>
|
||||
var rec,wave,recBlob;
|
||||
/**调用open打开录音请求好录音权限**/
|
||||
var recOpen=function(){//一般在显示出录音按钮或相关的录音界面时进行此方法调用,后面用户点击开始录音时就能畅通无阻了
|
||||
rec=null;
|
||||
wave=null;
|
||||
recBlob=null;
|
||||
var newRec=Recorder({
|
||||
type:"mp3",sampleRate:16000,bitRate:16 //mp3格式,指定采样率hz、比特率kbps,其他参数使用默认配置;注意:是数字的参数必须提供数字,不要用字符串;需要使用的type类型,需提前把格式支持文件加载进来,比如使用wav格式需要提前加载wav.js编码引擎
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate,newBufferIdx,asyncEnd){
|
||||
//录音实时回调,大约1秒调用12次本回调
|
||||
document.querySelector(".recpowerx").style.width=powerLevel+"%";
|
||||
document.querySelector(".recpowert").innerText=bufferDuration+" / "+powerLevel;
|
||||
|
||||
//可视化图形绘制
|
||||
wave.input(buffers[buffers.length-1],powerLevel,bufferSampleRate);
|
||||
}
|
||||
});
|
||||
|
||||
createDelayDialog(); //我们可以选择性的弹一个对话框:为了防止移动端浏览器存在第三种情况:用户忽略,并且(或者国产系统UC系)浏览器没有任何回调,此处demo省略了弹窗的代码
|
||||
newRec.open(function(){//打开麦克风授权获得相关资源
|
||||
dialogCancel(); //如果开启了弹框,此处需要取消
|
||||
|
||||
rec=newRec;
|
||||
|
||||
//此处创建这些音频可视化图形绘制浏览器支持妥妥的
|
||||
wave=Recorder.FrequencyHistogramView({elem:".recwave"});
|
||||
|
||||
reclog("已打开录音,可以点击录制开始录音了",2);
|
||||
},function(msg,isUserNotAllow){//用户拒绝未授权或不支持
|
||||
dialogCancel(); //如果开启了弹框,此处需要取消
|
||||
reclog((isUserNotAllow?"UserNotAllow,":"")+"打开录音失败:"+msg,1);
|
||||
});
|
||||
|
||||
window.waitDialogClick=function(){
|
||||
dialogCancel();
|
||||
reclog("打开失败:权限请求被忽略,<span style='color:#f00'>用户主动点击的弹窗</span>",1);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
/**关闭录音,释放资源**/
|
||||
function recClose(){
|
||||
if(rec){
|
||||
rec.close();
|
||||
reclog("已关闭");
|
||||
}else{
|
||||
reclog("未打开录音",1);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
/**开始录音**/
|
||||
function recStart(){//打开了录音后才能进行start、stop调用
|
||||
if(rec&&Recorder.IsOpen()){
|
||||
recBlob=null;
|
||||
rec.start();
|
||||
reclog("已开始录音...");
|
||||
}else{
|
||||
reclog("未打开录音",1);
|
||||
};
|
||||
};
|
||||
|
||||
/**暂停录音**/
|
||||
function recPause(){
|
||||
if(rec&&Recorder.IsOpen()){
|
||||
rec.pause();
|
||||
}else{
|
||||
reclog("未打开录音",1);
|
||||
};
|
||||
};
|
||||
/**恢复录音**/
|
||||
function recResume(){
|
||||
if(rec&&Recorder.IsOpen()){
|
||||
rec.resume();
|
||||
}else{
|
||||
reclog("未打开录音",1);
|
||||
};
|
||||
};
|
||||
|
||||
/**结束录音,得到音频文件**/
|
||||
function recStop(){
|
||||
if(!(rec&&Recorder.IsOpen())){
|
||||
reclog("未打开录音",1);
|
||||
return;
|
||||
};
|
||||
rec.stop(function(blob,duration){
|
||||
console.log(blob,(window.URL||webkitURL).createObjectURL(blob),"时长:"+duration+"ms");
|
||||
|
||||
recBlob=blob;
|
||||
reclog("已录制mp3:"+duration+"ms "+blob.size+"字节,可以点击播放、上传了",2);
|
||||
},function(msg){
|
||||
reclog("录音失败:"+msg,1);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**播放**/
|
||||
function recPlay(){
|
||||
if(!recBlob){
|
||||
reclog("请先录音,然后停止后再播放",1);
|
||||
return;
|
||||
};
|
||||
var cls=("a"+Math.random()).replace(".","");
|
||||
reclog('播放中: <span class="'+cls+'"></span>');
|
||||
var audio=document.createElement("audio");
|
||||
audio.controls=true;
|
||||
document.querySelector("."+cls).appendChild(audio);
|
||||
//简单利用URL生成播放地址,注意不用了时需要revokeObjectURL,否则霸占内存
|
||||
audio.src=(window.URL||webkitURL).createObjectURL(recBlob);
|
||||
audio.play();
|
||||
|
||||
setTimeout(function(){
|
||||
(window.URL||webkitURL).revokeObjectURL(audio.src);
|
||||
},5000);
|
||||
};
|
||||
|
||||
/**上传**/
|
||||
function recUpload(){
|
||||
var blob=recBlob;
|
||||
if(!blob){
|
||||
reclog("请先录音,然后停止后再上传",1);
|
||||
return;
|
||||
};
|
||||
|
||||
//本例子假设使用原始XMLHttpRequest请求方式,实际使用中自行调整为自己的请求方式
|
||||
//录音结束时拿到了blob文件对象,可以用FileReader读取出内容,或者用FormData上传
|
||||
var api="https://xx.xx/test_request";
|
||||
var onreadystatechange=function(title){
|
||||
return function(){
|
||||
if(xhr.readyState==4){
|
||||
if(xhr.status==200){
|
||||
reclog(title+"上传成功",2);
|
||||
}else{
|
||||
reclog(title+"没有完成上传,演示上传地址无需关注上传结果,只要浏览器控制台内Network面板内看到的请求数据结构是预期的就ok了。", "#d8c1a0");
|
||||
|
||||
console.error(title+"上传失败",xhr.status,xhr.responseText);
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
reclog("开始上传到"+api+",请求稍后...");
|
||||
|
||||
/***方式一:将blob文件转成base64纯文本编码,使用普通application/x-www-form-urlencoded表单上传***/
|
||||
var reader=new FileReader();
|
||||
reader.onloadend=function(){
|
||||
var postData="";
|
||||
postData+="mime="+encodeURIComponent(blob.type);//告诉后端,这个录音是什么格式的,可能前后端都固定的mp3可以不用写
|
||||
postData+="&upfile_b64="+encodeURIComponent((/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result)||[])[1]) //录音文件内容,后端进行base64解码成二进制
|
||||
//...其他表单参数
|
||||
|
||||
var xhr=new XMLHttpRequest();
|
||||
xhr.open("POST", api);
|
||||
xhr.setRequestHeader("Content-Type","application/x-www-form-urlencoded");
|
||||
xhr.onreadystatechange=onreadystatechange("上传方式一【Base64】");
|
||||
xhr.send(postData);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
|
||||
/***方式二:使用FormData用multipart/form-data表单上传文件***/
|
||||
var form=new FormData();
|
||||
form.append("upfile",blob,"recorder.mp3"); //和普通form表单并无二致,后端接收到upfile参数的文件,文件名为recorder.mp3
|
||||
//...其他表单参数
|
||||
|
||||
var xhr=new XMLHttpRequest();
|
||||
xhr.open("POST", api);
|
||||
xhr.onreadystatechange=onreadystatechange("上传方式二【FormData】");
|
||||
xhr.send(form);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//recOpen我们可以选择性的弹一个对话框:为了防止移动端浏览器存在第三种情况:用户忽略,并且(或者国产系统UC系)浏览器没有任何回调
|
||||
var showDialog=function(){
|
||||
if(!/mobile/i.test(navigator.userAgent)){
|
||||
return;//只在移动端开启没有权限请求的检测
|
||||
};
|
||||
dialogCancel();
|
||||
|
||||
//显示弹框,应该使用自己的弹框方式
|
||||
var div=document.createElement("div");
|
||||
document.body.appendChild(div);
|
||||
div.innerHTML=(''
|
||||
+'<div class="waitDialog" style="z-index:99999;width:100%;height:100%;top:0;left:0;position:fixed;background:rgba(0,0,0,0.3);">'
|
||||
+'<div style="display:flex;height:100%;align-items:center;">'
|
||||
+'<div style="flex:1;"></div>'
|
||||
+'<div style="width:240px;background:#fff;padding:15px 20px;border-radius: 10px;">'
|
||||
+'<div style="padding-bottom:10px;">录音功能需要麦克风权限,请允许;如果未看到任何请求,请点击忽略~</div>'
|
||||
+'<div style="text-align:center;"><a onclick="waitDialogClick()" style="color:#0B1">忽略</a></div>'
|
||||
+'</div>'
|
||||
+'<div style="flex:1;"></div>'
|
||||
+'</div>'
|
||||
+'</div>');
|
||||
};
|
||||
var createDelayDialog=function(){
|
||||
dialogInt=setTimeout(function(){//定时8秒后打开弹窗,用于监测浏览器没有发起权限请求的情况,在open前放置定时器利于收到了回调能及时取消(不管open是同步还是异步回调的)
|
||||
showDialog();
|
||||
},8000);
|
||||
};
|
||||
var dialogInt;
|
||||
var dialogCancel=function(){
|
||||
clearTimeout(dialogInt);
|
||||
|
||||
//关闭弹框,应该使用自己的弹框方式
|
||||
var elems=document.querySelectorAll(".waitDialog");
|
||||
for(var i=0;i<elems.length;i++){
|
||||
elems[i].parentNode.removeChild(elems[i]);
|
||||
};
|
||||
};
|
||||
//recOpen弹框End
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!--以下这坨可以忽略-->
|
||||
<script>
|
||||
function reclog(s,color){
|
||||
var now=new Date();
|
||||
var t=("0"+now.getHours()).substr(-2)
|
||||
+":"+("0"+now.getMinutes()).substr(-2)
|
||||
+":"+("0"+now.getSeconds()).substr(-2);
|
||||
var div=document.createElement("div");
|
||||
var elem=document.querySelector(".reclog");
|
||||
elem.insertBefore(div,elem.firstChild);
|
||||
div.innerHTML='<div style="color:'+(!color?"":color==1?"red":color==2?"#0b1":color)+'">['+t+']'+s+'</div>';
|
||||
};
|
||||
|
||||
reclog("Recorder H5使用简单,功能丰富,支持PC、Android,但IOS上仅Safari支持录音"+unescape("%uD83D%uDCAA"),"#f60;font-weight:bold;font-size:24px");
|
||||
reclog("RecordApp除Recorder支持的外,支持Hybrid App,IOS上支持微信网页和小程序web-view"+unescape("%uD83C%uDF89"),"#0b1;font-weight:bold;font-size:24px");
|
||||
reclog(Tips);
|
||||
</script>
|
||||
|
||||
<!-- 加载打赏挂件 -->
|
||||
<script src="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/assets/zdemo.widget.donate.js"></script>
|
||||
<script>
|
||||
var donateView=document.createElement("div");
|
||||
document.querySelector(".reclog").appendChild(donateView);
|
||||
DonateWidget({
|
||||
log:function(msg){reclog(msg)}
|
||||
,mobElem:donateView
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body{
|
||||
word-wrap: break-word;
|
||||
background:#f5f5f5 center top no-repeat;
|
||||
background-size: auto 680px;
|
||||
}
|
||||
pre{
|
||||
white-space:pre-wrap;
|
||||
}
|
||||
a{
|
||||
text-decoration: none;
|
||||
color:#06c;
|
||||
}
|
||||
a:hover{
|
||||
color:#f00;
|
||||
}
|
||||
|
||||
.main{
|
||||
max-width:700px;
|
||||
margin:0 auto;
|
||||
padding-bottom:80px
|
||||
}
|
||||
|
||||
.mainBox{
|
||||
margin-top:12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
--border: 1px solid #f60;
|
||||
box-shadow: 2px 2px 3px #aaa;
|
||||
}
|
||||
|
||||
|
||||
.btns button{
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: #f60;
|
||||
color:#fff;
|
||||
padding: 0 15px;
|
||||
margin:3px 20px 3px 0;
|
||||
line-height: 36px;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.btns button:active{
|
||||
background: #f00;
|
||||
}
|
||||
|
||||
.pd{
|
||||
padding:0 0 6px 0;
|
||||
}
|
||||
.lb{
|
||||
display:inline-block;
|
||||
vertical-align: middle;
|
||||
background:#00940e;
|
||||
color:#fff;
|
||||
font-size:14px;
|
||||
padding:2px 8px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
</style>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
937
WebRoot/node_modules/Recorder-master/README.md
generated
vendored
Normal file
@ -0,0 +1,937 @@
|
||||
# :open_book:Recorder用于html5录音
|
||||
|
||||
[](?Ref=Desc&Start)[在线测试](https://xiangyuecn.github.io/Recorder/),支持大部分已实现`getUserMedia`的移动端、PC端浏览器;主要包括:Chrome、Firefox、Safari、Android WebView、腾讯Android X5内核(QQ、微信);不支持:~~UC系内核(典型的支付宝),大部分国产手机厂商自研套壳娱乐浏览器,IOS上除Safari外的其他任何形式的浏览器(含PWA、WebClip、任何App内网页)~~。快捷方式: [【QuickStart】](https://xiangyuecn.github.io/Recorder/QuickStart.html),[【RecordApp测试】](https://jiebian.life/web/h5/github/recordapp.aspx),[【vue+webpack测试】](https://xiangyuecn.github.io/Recorder/assets/demo-vue),[【Android、IOS App Demo】](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample),[【工具】Recorder代码运行和静态分发](https://xiangyuecn.github.io/Recorder/assets/%E5%B7%A5%E5%85%B7-%E4%BB%A3%E7%A0%81%E8%BF%90%E8%A1%8C%E5%92%8C%E9%9D%99%E6%80%81%E5%88%86%E5%8F%91Runtime.html),[【工具】裸(RAW、WAV)PCM转WAV播放测试和转码](https://xiangyuecn.github.io/Recorder/assets/%E5%B7%A5%E5%85%B7-%E8%A3%B8PCM%E8%BD%ACWAV%E6%92%AD%E6%94%BE%E6%B5%8B%E8%AF%95.html) ,[【无用户操作测试】](https://xiangyuecn.github.io/Recorder/assets/ztest_no_user_operation.html),[【Can I Use】查看浏览器支持情况](https://caniuse.com/#search=getUserMedia)。
|
||||
|
||||
录音默认输出mp3格式,另外可选wav格式;有限支持ogg(beta)、webm(beta)、amr(beta)格式;支持任意格式扩展(前提有相应编码器)。
|
||||
|
||||
mp3默认16kbps的比特率,2kb每秒的录音大小,音质还可以(如果使用8kbps可达到1kb每秒,不过音质太渣)。主要用于语音录制,双声道语音没有意义,特意仅对单声道进行支持。mp3和wav格式支持边录边转码,录音结束时转码速度极快,支持实时转码成小片段文件和实时传输,demo中已实现一个语音通话聊天,下面有介绍;其他格式录音结束时可能需要花费比较长的时间进行转码。
|
||||
|
||||
mp3使用lamejs编码(CBR),压缩后的recorder.mp3.min.js文件150kb左右(开启gzip后54kb)。如果对录音文件大小没有特别要求,可以仅仅使用录音核心+wav编码器(raw pcm format录音文件超大),压缩后的recorder.wav.min.js不足5kb。录音得到的mp3(CBR)、wav(PCM),均可简单拼接小的二进制录音片段文件来生成长的音频文件,具体参考下面这两种编码器的详细介绍。
|
||||
|
||||
如需在Hybrid App内使用(支持IOS、Android),或提供IOS微信的支持,请参阅[app-support-sample](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample)目录。
|
||||
|
||||
*IOS、国产厂商自研套壳娱乐浏览器上的使用限制等问题和兼容请参阅下面的知识库部分;打开录音后对音频播放的影响、录音中途来电话等问题也参阅下面的知识库。*
|
||||
|
||||
<p align="center"><a href="https://github.com/xiangyuecn/Recorder"><img width="100" src="https://gitee.com/xiangyuecn/Recorder/raw/master/assets/icon.png" alt="Recorder logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
Basic:
|
||||
<a title="Stars" href="https://github.com/xiangyuecn/Recorder"><img src="https://img.shields.io/github/stars/xiangyuecn/Recorder?color=0b1&logo=github" alt="Stars"></a>
|
||||
<a title="Forks" href="https://github.com/xiangyuecn/Recorder"><img src="https://img.shields.io/github/forks/xiangyuecn/Recorder?color=0b1&logo=github" alt="Forks"></a>
|
||||
<a title="npm Version" href="https://www.npmjs.com/package/recorder-core"><img src="https://img.shields.io/npm/v/recorder-core?color=0b1&logo=npm" alt="npm Version"></a>
|
||||
<a title="License" href="https://github.com/xiangyuecn/Recorder/blob/master/LICENSE"><img src="https://img.shields.io/github/license/xiangyuecn/Recorder?color=0b1&logo=github" alt="License"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
Traffic:
|
||||
<a title="npm Downloads" href="https://www.npmjs.com/package/recorder-core"><img src="https://img.shields.io/npm/dt/recorder-core?color=f60&logo=npm" alt="npm Downloads"></a>
|
||||
<a title="cnpm" href="https://npm.taobao.org/package/recorder-core"><img src="https://img.shields.io/badge/cnpm-available-1690CD" alt="cnpm"></a><a title="cnpm" href="https://npm.taobao.org/package/recorder-core"><img src="https://npm.taobao.org/badge/d/recorder-core.svg" alt="cnpm"></a>
|
||||
<a title="JsDelivr CDN" href="https://www.jsdelivr.com/package/gh/xiangyuecn/Recorder"><img src="https://data.jsdelivr.com/v1/package/gh/xiangyuecn/Recorder/badge" alt="JsDelivr CDN"></a>
|
||||
<a title="51LA" href="https://www.51.la/?20469973"><img src="https://img.shields.io/badge/51LA-available-0b1" alt="cnpm"></a>
|
||||
</p>
|
||||
|
||||
[](?RefEnd)
|
||||
|
||||
|
||||
# :open_book:快速使用
|
||||
|
||||
你可以通过阅读和运行[QuickStart.html](https://xiangyuecn.github.io/Recorder/QuickStart.html)文件来快速入门学习,直接将`QuickStart.html`copy到你的(https、localhost)网站中,无需其他文件,就能正常开始测试了;注意:需要在https、localhost等[安全环境](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#Privacy_and_security)下才能进行录音。
|
||||
|
||||
|
||||
## 【1】加载框架
|
||||
|
||||
**方式一**:使用script标签引入
|
||||
|
||||
在需要录音功能的页面引入压缩好的recorder.xxx.min.js文件即可,[JsDelivr CDN](https://www.jsdelivr.com/features)
|
||||
``` html
|
||||
<script src="recorder.mp3.min.js"></script> <!--已包含recorder-core和mp3格式支持, CDN: https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/recorder.mp3.min.js-->
|
||||
```
|
||||
或者直接使用源码(src内的为源码、dist内的为压缩后的),可以引用src目录中的recorder-core.js+相应类型的实现文件,比如要mp3录音:
|
||||
``` html
|
||||
<script src="src/recorder-core.js"></script> <!--必须引入的录音核心,CDN: https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/dist/recorder-core.js-->
|
||||
|
||||
<script src="src/engine/mp3.js"></script> <!--相应格式支持文件;如果需要多个格式支持,把这些格式的编码引擎js文件放到后面统统加载进来即可-->
|
||||
<script src="src/engine/mp3-engine.js"></script> <!--如果此格式有额外的编码引擎的话,也要加上-->
|
||||
|
||||
<script src="src/extensions/waveview.js"></script> <!--可选的扩展支持项-->
|
||||
```
|
||||
|
||||
**方式二**:通过import/require引入
|
||||
|
||||
通过 npm/cnpm 进行安装 `npm install recorder-core` ,如果直接clone的源码下面文件路径调整一下即可 [](?Ref=ImportCode&Start)
|
||||
``` javascript
|
||||
//必须引入的核心,换成require也是一样的。注意:recorder-core会自动往window下挂载名称为Recorder对象,全局可调用window.Recorder,也许可自行调整相关源码清除全局污染
|
||||
import Recorder from 'recorder-core'
|
||||
|
||||
//需要使用到的音频格式编码引擎的js文件统统加载进来
|
||||
import 'recorder-core/src/engine/mp3'
|
||||
import 'recorder-core/src/engine/mp3-engine'
|
||||
|
||||
//以上三个也可以合并使用压缩好的recorder.xxx.min.js
|
||||
//比如 import Recorder from 'recorder-core/recorder.mp3.min' //已包含recorder-core和mp3格式支持
|
||||
|
||||
//可选的扩展支持项
|
||||
import 'recorder-core/src/extensions/waveview'
|
||||
```
|
||||
[](?RefEnd)
|
||||
|
||||
## 【2】调用录音
|
||||
[](?Ref=Codes&Start)这里假设只录3秒,录完后立即播放,[在线编辑运行此代码>>](https://xiangyuecn.github.io/Recorder/assets/%E5%B7%A5%E5%85%B7-%E4%BB%A3%E7%A0%81%E8%BF%90%E8%A1%8C%E5%92%8C%E9%9D%99%E6%80%81%E5%88%86%E5%8F%91Runtime.html?idf=self_base_demo)
|
||||
``` javascript
|
||||
//简单控制台直接测试方法:在任意(无CSP限制)页面内加载Recorder,加载成功后再执行一次本代码立即会有效果,import("https://xiangyuecn.github.io/Recorder/recorder.mp3.min.js").then(function(s){console.log("import ok")}).catch(function(e){console.error("import fail",e)})
|
||||
|
||||
var rec;
|
||||
/**调用open打开录音请求好录音权限**/
|
||||
var recOpen=function(success){//一般在显示出录音按钮或相关的录音界面时进行此方法调用,后面用户点击开始录音时就能畅通无阻了
|
||||
rec=Recorder({
|
||||
type:"mp3",sampleRate:16000,bitRate:16 //mp3格式,指定采样率hz、比特率kbps,其他参数使用默认配置;注意:是数字的参数必须提供数字,不要用字符串;需要使用的type类型,需提前把格式支持文件加载进来,比如使用wav格式需要提前加载wav.js编码引擎
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate,newBufferIdx,asyncEnd){
|
||||
//录音实时回调,大约1秒调用12次本回调
|
||||
//可利用extensions/waveview.js扩展实时绘制波形
|
||||
//可利用extensions/sonic.js扩展实时变速变调,此扩展计算量巨大,onProcess需要返回true开启异步模式
|
||||
}
|
||||
});
|
||||
|
||||
//var dialog=createDelayDialog(); 我们可以选择性的弹一个对话框:为了防止移动端浏览器存在第三种情况:用户忽略,并且(或者国产系统UC系)浏览器没有任何回调,此处demo省略了弹窗的代码
|
||||
rec.open(function(){//打开麦克风授权获得相关资源
|
||||
//dialog&&dialog.Cancel(); 如果开启了弹框,此处需要取消
|
||||
//rec.start() 此处可以立即开始录音,但不建议这样编写,因为open是一个延迟漫长的操作,通过两次用户操作来分别调用open和start是推荐的最佳流程
|
||||
|
||||
success&&success();
|
||||
},function(msg,isUserNotAllow){//用户拒绝未授权或不支持
|
||||
//dialog&&dialog.Cancel(); 如果开启了弹框,此处需要取消
|
||||
console.log((isUserNotAllow?"UserNotAllow,":"")+"无法录音:"+msg);
|
||||
});
|
||||
};
|
||||
|
||||
/**开始录音**/
|
||||
function recStart(){//打开了录音后才能进行start、stop调用
|
||||
rec.start();
|
||||
};
|
||||
|
||||
/**结束录音**/
|
||||
function recStop(){
|
||||
rec.stop(function(blob,duration){
|
||||
console.log(blob,(window.URL||webkitURL).createObjectURL(blob),"时长:"+duration+"ms");
|
||||
rec.close();//释放录音资源,当然可以不释放,后面可以连续调用start;但不释放时系统或浏览器会一直提示在录音,最佳操作是录完就close掉
|
||||
rec=null;
|
||||
|
||||
//已经拿到blob文件对象想干嘛就干嘛:立即播放、上传
|
||||
|
||||
/*** 【立即播放例子】 ***/
|
||||
var audio=document.createElement("audio");
|
||||
audio.controls=true;
|
||||
document.body.appendChild(audio);
|
||||
//简单利用URL生成播放地址,注意不用了时需要revokeObjectURL,否则霸占内存
|
||||
audio.src=(window.URL||webkitURL).createObjectURL(blob);
|
||||
audio.play();
|
||||
},function(msg){
|
||||
console.log("录音失败:"+msg);
|
||||
rec.close();//可以通过stop方法的第3个参数来自动调用close
|
||||
rec=null;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
//我们可以选择性的弹一个对话框:为了防止移动端浏览器存在第三种情况:用户忽略,并且(或者国产系统UC系)浏览器没有任何回调
|
||||
/*伪代码:
|
||||
function createDelayDialog(){
|
||||
if(Is Mobile){//只针对移动端
|
||||
return new Alert Dialog Component
|
||||
.Message("录音功能需要麦克风权限,请允许;如果未看到任何请求,请点击忽略~")
|
||||
.Button("忽略")
|
||||
.OnClick(function(){//明确是用户点击的按钮,此时代表浏览器没有发起任何权限请求
|
||||
//此处执行fail逻辑
|
||||
console.log("无法录音:权限请求被忽略");
|
||||
})
|
||||
.OnCancel(NOOP)//自动取消的对话框不需要任何处理
|
||||
.Delay(8000); //延迟8秒显示,这么久还没有操作基本可以判定浏览器有毛病
|
||||
};
|
||||
};
|
||||
*/
|
||||
|
||||
|
||||
//这里假设立即运行,只录3秒,录完后立即播放,本段代码copy到控制台内可直接运行
|
||||
recOpen(function(){
|
||||
recStart();
|
||||
setTimeout(recStop,3000);
|
||||
});
|
||||
```
|
||||
|
||||
## 【附】录音上传示例
|
||||
``` javascript
|
||||
var TestApi="/test_request";//用来在控制台network中能看到请求数据,测试的请求结果无关紧要
|
||||
var rec=Recorder();rec.open(function(){rec.start();setTimeout(function(){rec.stop(function(blob,duration){
|
||||
//-----↓↓↓以下才是主要代码↓↓↓-------
|
||||
|
||||
//本例子假设使用jQuery封装的请求方式,实际使用中自行调整为自己的请求方式
|
||||
//录音结束时拿到了blob文件对象,可以用FileReader读取出内容,或者用FormData上传
|
||||
var api=TestApi;
|
||||
|
||||
/***方式一:将blob文件转成base64纯文本编码,使用普通application/x-www-form-urlencoded表单上传***/
|
||||
var reader=new FileReader();
|
||||
reader.onloadend=function(){
|
||||
$.ajax({
|
||||
url:api //上传接口地址
|
||||
,type:"POST"
|
||||
,data:{
|
||||
mime:blob.type //告诉后端,这个录音是什么格式的,可能前后端都固定的mp3可以不用写
|
||||
,upfile_b64:(/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result)||[])[1] //录音文件内容,后端进行base64解码成二进制
|
||||
//...其他表单参数
|
||||
}
|
||||
,success:function(v){
|
||||
console.log("上传成功",v);
|
||||
}
|
||||
,error:function(s){
|
||||
console.error("上传失败",s);
|
||||
}
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
|
||||
/***方式二:使用FormData用multipart/form-data表单上传文件***/
|
||||
var form=new FormData();
|
||||
form.append("upfile",blob,"recorder.mp3"); //和普通form表单并无二致,后端接收到upfile参数的文件,文件名为recorder.mp3
|
||||
//...其他表单参数
|
||||
$.ajax({
|
||||
url:api //上传接口地址
|
||||
,type:"POST"
|
||||
,contentType:false //让xhr自动处理Content-Type header,multipart/form-data需要生成随机的boundary
|
||||
,processData:false //不要处理data,让xhr自动处理
|
||||
,data:form
|
||||
,success:function(v){
|
||||
console.log("上传成功",v);
|
||||
}
|
||||
,error:function(s){
|
||||
console.error("上传失败",s);
|
||||
}
|
||||
});
|
||||
|
||||
//-----↑↑↑以上才是主要代码↑↑↑-------
|
||||
},function(msg){console.log("录音失败:"+msg);});},3000);},function(msg){console.log("无法录音:"+msg);});
|
||||
```
|
||||
[](?RefEnd)
|
||||
|
||||
## 【附】问题排查
|
||||
- 打开[Demo页面](https://xiangyuecn.github.io/Recorder/)试试看,是不是也有同样的问题。
|
||||
- 检查是不是在https之类的安全环境下调用的。
|
||||
- 检查是不是IOS系统,确认[caniuse](https://caniuse.com/#search=getUserMedia)IOS对`getUserMedia`的支持情况。
|
||||
- 检查上面第1步是否把框架加载到位,在[Demo页面](https://xiangyuecn.github.io/Recorder/)有应该加载哪些js的提示。
|
||||
- 提交Issue,热心网友帮你解答。
|
||||
|
||||
|
||||
|
||||
## 【QQ群】交流与支持
|
||||
|
||||
欢迎加QQ群:781036591,纯小写口令:`recorder`
|
||||
|
||||
<img src="https://gitee.com/xiangyuecn/Recorder/raw/master/assets/qq_group_781036591.png" width="220px">
|
||||
|
||||
|
||||
|
||||
## 案例演示
|
||||
|
||||
### 【在线Demo完整版】
|
||||
[<img src="https://gitee.com/xiangyuecn/Recorder/raw/master/assets/demo.png" width="100px">](https://xiangyuecn.github.io/Recorder/) https://xiangyuecn.github.io/Recorder/
|
||||
|
||||
> `2019-3-27` 在QQ和微信打开时,发现这个网址被屏蔽了,尝试申诉了一下。`2019-4-7`晚上又发现被屏蔽了,小米浏览器也一样报危险网站,尝试打开一下别人的`github.io`发现全是这样,看来是`github.io`的问题,被波及了,不过第二天又自己好了。
|
||||
|
||||
|
||||
### 【Demo片段列表】
|
||||
1. [【Demo库】【格式转换】-mp3格式转成其他格式](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=lib.transform.mp32other)
|
||||
2. [【Demo库】【格式转换】-wav格式转成其他格式](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=lib.transform.wav2other)
|
||||
3. [【Demo库】【格式转换】-amr格式转成其他格式](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=lib.transform.amr2other)
|
||||
4. [【教程】实时转码并上传-通用版](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=teach.realtime.encode_transfer)
|
||||
5. [【教程】实时转码并上传-mp3专版](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=teach.realtime.encode_transfer_mp3)
|
||||
6. [【Demo库】【文件合并】-mp3多个片段文件合并](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=lib.merge.mp3_merge)
|
||||
7. [【Demo库】【文件合并】-wav多个片段文件合并](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=lib.merge.wav_merge)
|
||||
8. [【教程】实时多路音频混音](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=teach.realtime.mix_multiple)
|
||||
9. [【教程】变速变调音频转换](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=teach.sonic.transform)
|
||||
10. [【教程】DTMF(电话拨号按键信号)解码、编码](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=teach.dtmf.decode_and_encode)
|
||||
11. [【Demo库】PCM采样率提升](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=lib.samplerate.raise)
|
||||
12. [【测试】音频可视化相关扩展测试](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=test.extensions.visualization)
|
||||
|
||||
|
||||
|
||||
#### 【祝福贺卡助手】
|
||||
使用到这个库用于祝福语音的录制,已开通网页版和微信小程序版。专门针对IOS的微信中进行了兼容处理,IOS上微信环境中调用的微信的api(小程序、公众号api)。小程序地址:[<img src="https://gitee.com/xiangyuecn/Recorder/raw/master/assets/jiebian.life-xcx.png" width="100px">](https://jiebian.life/t/a);网页地址:[<img src="https://gitee.com/xiangyuecn/Recorder/raw/master/assets/jiebian.life-web.png" width="100px">](https://jiebian.life/t/a)
|
||||
|
||||
#### 【注】
|
||||
如果你的项目用到了这个库也想展示到这里,可以发个isuse,注明使用介绍和访问方式,我们收录在这里。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# :open_book:知识库
|
||||
|
||||
本库期待的使用场景是语音录制,因此音质只要不比高品质的感觉差太多就行;1分钟的语音进行编码是很快的,但如果录制超长的录音,比如10分钟以上,不同类型的编码可能会花费比较长的时间,因为只有边录边转码(Worker)支持的类型才能进行极速转码。另外未找到双声道语音录制存在的意义(翻倍录音数据大小,并且拉低音质),因此特意仅对单声道进行支持。
|
||||
|
||||
|
||||
浏览器Audio Media[兼容性](https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats#Browser_compatibility)mp3最好,wav还行,其他要么不支持播放,要么不支持编码;因此本库最佳推荐使用mp3、wav格式,代码也是优先照顾这两种格式。
|
||||
|
||||
**特别注**:`IOS(11.X、12.X、13.X)`上只有`Safari`支持`getUserMedia`,IOS上其他浏览器均不支持,唯一有点卵用的Safari `getUserMedia` 底层实现bug奇多(严重关切他们团队水准,临时工少发工资了吧),参考下面的已知问题。
|
||||
|
||||
**特别注**:大部分国产手机厂商的浏览器(系统浏览器,都用的UC内核?)虽然支持`getUserMedia`方法,但并不能使用,表现为直接返回拒绝或者干脆没有任何回调;UC系列目测全部阵亡(含支付宝)。
|
||||
|
||||
**留意中途来电话**:在移动端录音时,如果录音中途来电话,或者通话过程中打开录音,是不一定能进行录音的;经过简单测试发现,IOS上Safari将暂停返回音频数据,直到通话结束才开始继续有音频数据返回;小米上Chrome不管是来电还是通话中开始录音都能对麦克风输入的声音进行录音(听筒中的并不能录到,扬声器外放的会被明显降噪);只是简单测试,更多机器和浏览器并未做测试,不过整体上来看来电话或通话中进行录音的可行性并不理想,也不赞成在这种过程中进行录音;但只要通话结束后录音还是会正常进行,影响基本不大。
|
||||
|
||||
**录音时对播放音频的影响**:仅在移动端,录音过程中尽量不要去播放音频,正在播放的也应该暂停播放,否则不同手机系统、浏览器环境可能表现会出乎意料;已知IOS Safari上录音打开后,如果播放音频,声音会[变得非常小](https://www.cnblogs.com/cocoajin/p/7591068.html);Android上也有可能声音被切换到听筒播放,而不是扬声器大喇叭上播放导致声音也会变小;更多可能的情况需要更多设备、浏览器的测试数据才能发掘;PC上似乎无此影响。
|
||||
|
||||
**特别注**:如果在`iframe`里面调用的录音功能,并且和上层的网页是不同的域(跨域了),如果未设置相应策略,权限永远是被拒绝的,[参考此处](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#Privacy_and_security)。另外如果要在`非跨域的iframe`里面使用,最佳实践应该是让window.top去加载Recorder(异步加载js),iframe里面使用top.Recorder,免得各种莫名其妙(比如微信里面的各种渣渣功能,搞多了就习惯了)。
|
||||
|
||||
> 如果需要最大限度的兼容IOS(仅增加微信支持),可以使用`RecordApp`,它已包含`Recorder`,源码在`src/app-support`、`app-support-sample`中,但此兼容库需要服务器端提供微信JsSDK的签名、下载素材接口,涉及微信公众(订阅)号的开发。
|
||||
|
||||
支持|Recorder|[RecordApp](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample)
|
||||
-:|:-:|:-:
|
||||
PC浏览器|√|√
|
||||
Android Chrome Firefox|√|√
|
||||
Android微信(含小程序)|√|√
|
||||
Android Hybrid App|√|√
|
||||
Android其他浏览器|未知|未知
|
||||
IOS Safari|√|√
|
||||
IOS微信(含小程序)||√
|
||||
IOS Hybrid App||√
|
||||
IOS其他浏览器||
|
||||
开发难度|简单|复杂
|
||||
第三方依赖|无|依赖微信公众号
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 已知问题
|
||||
*2018-09-19* [caniuse](https://caniuse.com/#search=getUserMedia) 注明`IOS` `11.X - 12.X(13.X)` 上 只有`Safari`支持调用`getUserMedia`,其他App下WKWebView(UIWebView?)([相关资料](https://forums.developer.apple.com/thread/88052))均不支持。经用户测试验证IOS 12上chrome、UC都无法录音,部分IOS 12 Safari可以获取到并且能正常录音,但部分不行,原因未知,参考[ios 12 支不支持录音了](https://www.v2ex.com/t/490695)。在IOS上不支持录音的环境下应该采用其他解决方案,参考`案例演示`、`关于微信JsSDK`部分。
|
||||
|
||||
*2019-02-28* [issues#14](https://github.com/xiangyuecn/Recorder/issues/14) 如果`getUserMedia`返回的[`MediaStreamTrack.readyState == "ended"`,`"ended" which indicates that the input is not giving any more data and will never provide new data.`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack) ,导致无法录音。如果产生这种情况,目前在`rec.open`方法调用时会正确检测到,并执行`fail`回调。造成`issues#14` `ended`原因是App源码中`AndroidManifest.xml`中没有声明`android.permission.MODIFY_AUDIO_SETTINGS`权限,导致腾讯X5不能正常录音。
|
||||
|
||||
*2019-03-09* 在Android上QQ、微信里,请求授权使用麦克风的提示,经过长时间观察发现,他们的表现很随机、很奇特。可能每次在调用`getUserMedia`时候都会弹选择,也可能选择一次就不会再弹提示,也可能重启App后又会弹。如果用户拒绝了,可能第二天又会弹,或者永远都不弹了,要么重置(装)App。使用腾讯X5内核的App测试也是一样奇特表现,拒绝权限后可能必须要重置(装)。这个问题貌似跟X5内核自动升级的版本有关。QQ浏览器更加惨不忍睹,2019-08-16测试发现卸载重装、拒绝权限后永远无法弹出授权,通过浏览器设置-清理-清理地理位置授权才能恢复,重启、重装、清理系统垃圾、删除根目录文件夹(腾讯那个大文件不敢删,毒瘤)垃圾均无效,奇葩。
|
||||
|
||||
*2019-06-14* 经[#29](https://github.com/xiangyuecn/Recorder/issues/29)反馈,稍微远程真机测试了部分厂商的比较新的Android手机系统浏览器的录音支持情况;华为:直接返回拒绝,小米:没有回调,OPPO:好像是没有回调,vivo:好像是没有回调;另外专门测试了一下UC最新版(支付宝):直接返回拒绝。另[参考](https://www.jianshu.com/p/6cd5a7fa562c)。也许他们都商量好了或者本身都是用的UC?至于没有任何回调的,此种浏览器没有良心。
|
||||
|
||||
*2019-07-22* 对[#34](https://github.com/xiangyuecn/Recorder/issues/34)反馈研究后发现,问题一:~~macOS、IOS的Safari对连续调用录音(中途未调用close)是有问题的,但只要调用close后再重复录音就没有问题~~(已通过特殊手段解决)。问题二:IOS上如果录音之前先播放了任何Audio,录音过程可能会变得很诡异,但如果先录音,就不存在此问题(19-09-18 Evan:QQ1346751357反馈发现本问题并非必现,[功能页面](https://hft.bigdatahefei.com/LocateSearchService/sfc/index),但本库的Demo内却必现,原因不明)。chrome、firefox正常的很。目测这两个问题是非我等屌丝能够解决的,于是报告给苹果家程序员看看,因此发了个[帖子](https://forums.developer.apple.com/message/373108),顺手在`Feedback Assistant`提交了`bug report`,但好几天过去了没有任何回应(顺带给微软一个好评)。问题一目前已通过全局共享一个MediaStream连接来解决,原因在于Safari上MediaStream断开后就无法再次进行连接使用(表现为静音),改成了全局只连接一次就避免了此问题;全局处理也有利于屏蔽底层细节,start时无需再调用底层接口,提升兼容、可靠性。
|
||||
|
||||
*2019-10-26* 针对[#51](https://github.com/xiangyuecn/Recorder/issues/51)的问题研究后发现,如果录音时设备偶尔出现很卡的情况下(CPU被其他程序大量占用),浏览器采集到的音频是断断续续的,导致10秒的录音可能就只返回了5秒的数据量,这个时候最终编码得到的音频时长明显变短,播放时的效果就像快放一样。此问题能够稳定复现(使用别的程序大量占用CPU来模拟),目前已在`envIn`内部函数中进行了补偿处理,在浏览器两次传入PCM数据之间填充一段静默数据已弥补丢失的时长;最终编码得到的音频时长将和实际录音时长基本一致,消除了快放效果,但由于丢失的音频已被静默数据代替,听起来就是数据本身的断断续续的效果。在设备不卡时录音没有此问题。
|
||||
|
||||
*2019-11-03* lamejs原版编码器编码出来的mp3文件首尾存在填充数据并且会占据一定时长(这种数据播放时静默,记录的信息数据或者填充),同一录音mp3格式的时长会比wav格式的时长要长0-100ms左右,大部分情况下不会有影响,但如果涉及到实时转码并传输的话,这些数据将会造成多段mp3片段的总时长比实际录音要长,最终播放时会均匀的感觉到停顿,并且mp3片段越小越明显。本库已对lamejs编码出来的mp3文件进行了处理,去掉了头部的非音频数据,但由于编码出来的mp3每一帧数据都有固定时长,文件结尾最后一帧可能录音的时长不能刚好填满,就会产生填充数据;因此本库编码出来的mp3文件会比wav格式长0-30ms左右,多出来的时长在mp3的结尾处;mp3解码出来的pcm数据直接去掉结尾多出来的部分,就和wav中的pcm数据基本一致了;另外可以通过调节待编码的pcm数据长度以达到刚好填满最后一帧来规避此问题,参考`Recorder.SampleData`方法提供的连续转码针对此问题的处理(但小的mp3片段拼接起来停顿导致的杂音还是非常明显,实时处理时使用`takeoffEncodeChunk`选项可完全避免此问题)。[参考wiki](https://github.com/xiangyuecn/Recorder/wiki/lamejs编码出来的mp3时长修正)。
|
||||
|
||||
*2020-04-26* Safari Bug:据QQ群内`1048506792`、`190451148`开发者反馈研究发现,IOS ?-13.X Safari内打开录音后,如果切换到了其他标签、或其他App并且播放了任何声音,此时将会中断已打开的录音(系统级的?),切换回正在录音的页面,这个页面的录音功能将会彻底失效,并且刷新也无法恢复录音;表现为关闭录音后再次打开录音,能够正常获得权限,但浏览器返回的采集到的音频为静默的PCM,此时地址栏也并未显示出麦克风图标,刷新这个标签也也是一样不能正常获得录音,只有关掉此标签新打开页面才可正常录音。如果打开录音后关闭了录音,然后切换到其他标签或App播放声音,然后返回录音页面,不会出现此问题。此为Safari的底层Bug(也许是少给临时工工钱了吧,无能为力)。使用长按录音类似的用户交互可大幅度避免踩到这坨翔。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# :open_book:方法文档
|
||||
|
||||

|
||||
|
||||
### 【构造】rec=Recorder(set)
|
||||
|
||||
构造函数,拿到`Recorder`的实例,然后可以进行请求获取麦克风权限和录音。
|
||||
|
||||
`set`参数为配置对象,默认配置值如下:
|
||||
``` javascript
|
||||
set={
|
||||
type:"mp3" //输出类型:mp3,wav等,使用一个类型前需要先引入对应的编码引擎
|
||||
,bitRate:16 //比特率,必须是数字 wav(位):16、8,MP3(单位kbps):8kbps时文件大小1k/s,16kbps 2k/s,录音文件很小
|
||||
|
||||
,sampleRate:16000 //采样率,必须是数字,wav格式(8位)文件大小=sampleRate*时间;mp3此项对低比特率文件大小有影响,高比特率几乎无影响。
|
||||
//wav任意值,mp3取值范围:48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000
|
||||
|
||||
,onProcess:NOOP //接收到录音数据时的回调函数:fn(buffers,powerLevel,bufferDuration,bufferSampleRate,newBufferIdx,asyncEnd)
|
||||
//返回值:onProcess如果返回true代表开启异步模式,在某些大量运算的场合异步是必须的,必须在异步处理完成时调用asyncEnd(不能真异步时需用setTimeout包裹);返回其他值或者不返回为同步模式(需避免在回调内执行耗时逻辑);如果开启异步模式,在onProcess执行后新增的buffer会全部替换成空数组,因此本回调开头应立即将newBufferIdx到本次回调结尾位置的buffer全部保存到另外一个数组内,处理完成后写回buffers中本次回调的结尾位置。
|
||||
//buffers=[[Int16,...],...]:缓冲的PCM数据,为从开始录音到现在的所有pcm片段,每次回调可能增加0-n个不定量的pcm片段。
|
||||
//powerLevel:当前缓冲的音量级别0-100。
|
||||
//bufferDuration:已缓冲时长。
|
||||
//bufferSampleRate:缓冲使用的采样率(当type支持边录边转码(Worker)时,此采样率和设置的采样率相同,否则不一定相同)。
|
||||
//newBufferIdx:本次回调新增的buffer起始索引。
|
||||
//asyncEnd:fn() 如果onProcess是异步的(返回值为true时),处理完成时需要调用此回调,如果不是异步的请忽略此参数,此方法回调时必须是真异步(不能真异步时需用setTimeout包裹)。
|
||||
//如果需要绘制波形之类功能,需要实现此方法即可,使用以计算好的powerLevel可以实现音量大小的直观展示,使用buffers可以达到更高级效果
|
||||
//注意,buffers数据的采样率和set.sampleRate不一定相同,可能为浏览器提供的原始采样率rec.srcSampleRate,也可能为已转换好的采样率set.sampleRate;如需浏览器原始采样率的数据,请使用rec.buffers原始数据,而不是本回调的参数;如需明确和set.sampleRate完全相同采样率的数据,请在onProcess中自行连续调用采样率转换函数Recorder.SampleData(),配合mock方法可实现实时转码和压缩语音传输;修改或替换buffers内的数据将会改变最终生成的音频内容(注意不能改变第一维数组长度),比如简单有限的实现实时静音、降噪、混音等处理,详细参考下面的rec.buffers
|
||||
|
||||
//*******高级设置******
|
||||
//,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逻辑
|
||||
}
|
||||
```
|
||||
|
||||
**注意:set内是数字的明确传数字**,不要传字符串之类的导致不可预测的异常,其他有配置的地方也是一样(感谢`214282049@qq.com`19-01-10发的反馈邮件)。
|
||||
|
||||
*注:如果录音结束后生成的音频文件的比特率和采样率和set中的不同,将会把set中的bitRate、sampleRate更新成音频文件的。*
|
||||
|
||||
### 【方法】rec.open(success,fail)
|
||||
请求打开录音资源,如果浏览器不支持录音、用户拒绝麦克风权限、或者非安全环境(非https、file等)将会调用`fail`;打开后需要调用`close`来关闭,因为浏览器或设备的系统可能会显示正在录音。
|
||||
|
||||
注意:此方法回调是可能是同步的(异常、或者已持有资源时)也可能是异步的(浏览器弹出权限请求时);一般使用时打开,用完立即关闭;可重复调用,可用来测试是否能录音。
|
||||
|
||||
另外:因为此方法会调起用户授权请求,如果仅仅想知道浏览器是否支持录音(比如:如果浏览器不支持就走另外一套录音方案),应使用`Recorder.Support()`方法。
|
||||
|
||||
**注意:打开录音后,如果未调用close关闭,可能会影响audio音频的播放,表现为移动端audio播放有明显的杂音(麦克风的电流音?),因此如果你录音后有别的操作,尽量录完音就立即调用close关闭录音。**
|
||||
|
||||
> **特别注**: 鉴于UC系浏览器(大部分国产手机厂商系统浏览器)大概率表面支持录音但永远不会有任何回调、或者此浏览器支持第三种情况(用户忽略 并且 此浏览器认为此种情况不需要回调 并且程序员完美实现了);如果当前环境是移动端,可以在调用此方法`8秒`后如果未收到任何回调,弹出一个自定义提示框(只需要一个按钮),提示内容范本:`录音功能需要麦克风权限,请允许;如果未看到任何请求,请点击忽略~`,按钮文本:`忽略`;当用户点击了按钮,直接手动执行`fail`逻辑,因为此时浏览器压根就没有弹移动端特有的模态话权限请求对话框;但如果收到了回调(可能是同步的,因此弹框必须在`rec.open`调用前准备好随时取消),需要把我们弹出的提示框自动关掉,不需要用户做任何处理。pc端的由于不是模态化的请求对话框,可能会被用户误点,所以尽量要判断一下是否是移动端。
|
||||
|
||||
`success`=fn();
|
||||
|
||||
`fail`=fn(errMsg,isUserNotAllow); 如果是用户主动拒绝的录音权限,除了有错误消息外,isUserNotAllow=true,方便程序中做不同的提示,提升用户主动授权概率
|
||||
|
||||
|
||||
### 【方法】rec.close(success)
|
||||
关闭释放录音资源,释放完成后会调用`success()`回调。如果正在录音或者stop调用未完成前调用了close将会强制终止当前录音。
|
||||
|
||||
注意:如果创建了多个Recorder对象并且调用了open(应避免同时有多个对象进行了open),只有最后一个新建的才有权限进行实际的资源释放(和多个对象close调用顺序无关),浏览器或设备的系统才会不再显示正在录音的提示。
|
||||
|
||||
### 【方法】rec.start()
|
||||
开始录音,需先调用`open`;未close之前可以反复进行调用开始新的录音。
|
||||
|
||||
只要open成功后,调用此方法是安全的,如果未open强行调用导致的内部错误将不会有任何提示,stop时自然能得到错误;另外open操作可能需要花费比较长时间,如果中途调用了stop,open完成时(同步)的任何start调用将会被自动阻止,也是不会有提示的。
|
||||
|
||||
### 【方法】rec.stop(success,fail,autoClose)
|
||||
结束录音并返回录音数据`blob对象`,拿到blob对象就可以为所欲为了,不限于立即播放、上传
|
||||
|
||||
`success(blob,duration)`:`blob`:录音数据audio/mp3|wav...格式,`duration`:录音时长,单位毫秒
|
||||
|
||||
`fail(errMsg)`:录音出错回调
|
||||
|
||||
`autoClose`:`false` 可选,是否自动调用`close`,默认为`false`不调用
|
||||
|
||||
提示:stop时会进行音频编码,根据类型的不同音频编码花费的时间也不相同。对于支持边录边转码(Worker)的类型,将极速完成编码并回调;对于不支持的10几秒录音花费2秒左右算是正常,但内部采用了分段编码+setTimeout来处理,界面卡顿不明显。
|
||||
|
||||
|
||||
### 【方法】rec.pause()
|
||||
暂停录音。
|
||||
|
||||
### 【方法】rec.resume()
|
||||
恢复继续录音。
|
||||
|
||||
|
||||
### 【属性】rec.buffers
|
||||
此数据为从开始录音到现在为止的所有已缓冲的PCM片段列表,`buffers` `=` `[[Int16,...],...]` 为二维数组;在没有边录边转码的支持时(mock调用、非mp3等),录音stop时会使用此完整数据进行转码成指定的格式。
|
||||
|
||||
buffers中的PCM数据为浏览器采集的原始音频数据,采样率为浏览器提供的原始采样率`rec.srcSampleRate`;在`rec.set.onProcess`回调中`buffers`参数就是此数据或者此数据重新采样后的新数据;修改或替换`onProcess`回调中`buffers`参数可以改变最终生成的音频内容,但修改`rec.buffers`不一定会有效,因此你可以在`onProcess`中修改或替换`buffers`参数里面的内容,注意只能修改或替换上次回调以来新增的buffer(不允许修改已处理过的,不允许增删第一维数组,允许将第二维数组任意修改替换成空数组也可以);以此可以简单有限的实现实时静音、降噪、混音等处理。
|
||||
|
||||
如果你需要长时间实时录音(如长时间语音通话),并且不需要得到最终完整编码的音频文件:
|
||||
1. 未提供set.takeoffEncodeChunk时,Recorder初始化时应当使用一个未知的类型进行初始化(如: type:"unknown",仅仅用于初始化而已,实时转码可以手动转成有效格式,因为有效格式可能内部还有其他类型的缓冲,`unknown`类型`onProcess buffers`和`rec.buffers`是同一个数组);提供set.takeoffEncodeChunk接管了编码器实时输出时,无需特殊处理,因为编码器内部将不会使用缓冲;
|
||||
2. 实时在`onProcess`中修改`buffers`参数数组,可以只保留最后两个元素,其他元素设为null(代码:`onProcess: buffers[buffers.length-3]=null`),不保留也行,全部设为null,以释放占用的内存;`rec.buffers`将会自动清理,无需手动清理;注意:提供set.takeoffEncodeChunk时,应当延迟一下清理,不然buffers被清理掉时,这个buffers还未推入编码器进行编码;
|
||||
3. 录音结束时可以不用调用`stop`,直接调用`close`丢弃所有数据即可。只要buffers[0]==null时调用`stop`永远会直接走fail回调。
|
||||
|
||||
### 【属性】rec.srcSampleRate
|
||||
浏览器提供的原始采样率,只有start或mock调用后才会有值,此采样率就是rec.buffers数据的采样率。
|
||||
|
||||
|
||||
### 【方法】rec.mock(pcmData,pcmSampleRate)
|
||||
模拟一段录音数据,后面可以调用stop进行编码。需提供pcm数据 `pcmData` `=` `[Int16,...]` 为一维数组,和pcm数据的采样率 `pcmSampleRate`。
|
||||
|
||||
提示:在录音实时回调中配合`Recorder.SampleData()`方法使用效果更佳,可实时生成小片段语音文件。
|
||||
|
||||
**注意:pcmData为一维数组,如果提供二维数组将会产生不可预料的错误**;如果需要使用类似`onProcess`回调的`buffers`或者`rec.buffers`这种pcm列表(二维数组)时,可自行展开成一维,或者使用`Recorder.SampleData()`方法转换成一维。
|
||||
|
||||
本方法可用于将一个音频解码出来的pcm数据方便的转换成另外一个格式:
|
||||
``` javascript
|
||||
var amrBlob=...;//amr音频blob对象
|
||||
var amrSampleRate=8000;//amr音频采样率
|
||||
|
||||
//解码amr得到pcm数据
|
||||
var reader=new FileReader();
|
||||
reader.onload=function(){
|
||||
Recorder.AMR.decode(new Uint8Array(reader.result),function(pcm){
|
||||
transformOgg(pcm);
|
||||
});
|
||||
};
|
||||
reader.readAsArrayBuffer(amrBlob);
|
||||
|
||||
//将pcm转成ogg
|
||||
function transformOgg(pcmData){
|
||||
Recorder({type:"ogg",bitRate:64,sampleRate:32000})
|
||||
.mock(pcmData,amrSampleRate)
|
||||
.stop(function(blob,duration){
|
||||
//我们就得到了新采样率和比特率的ogg文件
|
||||
console.log(blob,duration);
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 【静态方法】Recorder.Support()
|
||||
判断浏览器是否支持录音,随时可以调用。注意:仅仅是检测浏览器支持情况,不会判断和调起用户授权(rec.open()会判断用户授权),不会判断是否支持特定格式录音。
|
||||
|
||||
### 【静态方法】Recorder.IsOpen()
|
||||
由于Recorder持有的录音资源是全局唯一的,可通过此方法检测是否有Recorder已调用过open打开了录音功能。
|
||||
|
||||
### 【静态方法】Recorder.Destroy()
|
||||
销毁已持有的所有全局资源(AudioContext、Worker),当要彻底移除Recorder时需要显式的调用此方法。大部分情况下不调用Destroy也不会造成问题。
|
||||
|
||||
### 【静态属性】Recorder.TrafficImgUrl
|
||||
流量统计用1像素图片地址,在Recorder首次被实例化时将往这个地址发送一个请求,请求是通过Image对象来发送,安全可靠;默认开启统计,url为本库的51la统计用图片地址,为空响应流量消耗非常小,因此对使用几乎没有影响。
|
||||
|
||||
设置为空字符串后将不参与统计,大部分情况下无需关闭统计,如果你网页的url私密性要求很高,请在调用Recorder之前将此url设为空字符串;本功能于2019-11-09添加,[点此](https://www.51.la/?20469973)前往51la查看统计概况。
|
||||
|
||||
### 【静态属性】Recorder.BufferSize
|
||||
录音时的AudioContext缓冲大小,默认值为4096。会影响H5录音时的onProcess调用速率,相对于AudioContext.sampleRate=48000时,4096接近12帧/s,调节此参数可生成比较流畅的回调动画。
|
||||
|
||||
取值256, 512, 1024, 2048, 4096, 8192, or 16384
|
||||
|
||||
注意:取值不能过低,2048开始不同浏览器可能回调速率跟不上造成音质问题。一般无需调整,调整后需要先close掉已打开的录音,再open时才会生效。
|
||||
|
||||
*这个属性在旧版Recorder中是放在已废弃的set.bufferSize中,后面因为兼容处理Safari上MediaStream断开后就无法再次进行连接使用的问题(表现为静音),把MediaStream连接也改成了全局只连接一次,因此set.bufferSize就移出来变成了Recorder的属性*
|
||||
|
||||
### 【静态方法】Recorder.SampleData(pcmDatas,pcmSampleRate,newSampleRate,prevChunkInfo,option)
|
||||
对pcm数据的采样率进行转换,配合mock方法使用效果更佳,比如实时转换成小片段语音文件。
|
||||
|
||||
注意:本方法只会将高采样率的pcm转成低采样率的pcm,当newSampleRate>pcmSampleRate想转成更高采样率的pcm时,本方法将不会进行转换处理(由低的采样率转成高的采样率没有存在的意义);在特殊场合下如果确实需要提升采样率,比如8k必须转成16k,可参考[【Demo库】PCM采样率提升](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=lib.samplerate.raise)自行编写代码转换一下即可。
|
||||
|
||||
`pcmDatas`: [[Int16,...]] pcm片段列表,二维数组,比如可以是:rec.buffers、onProcess中的buffers
|
||||
|
||||
`pcmSampleRate`:48000 pcm数据的采样率,比如用:rec.srcSampleRate、onProcess中的bufferSampleRate
|
||||
|
||||
`newSampleRate`:16000 需要转换成的采样率,newSampleRate>=pcmSampleRate时不会进行任何处理,小于时会进行重新采样
|
||||
|
||||
`prevChunkInfo`:{} 可选,上次调用时的返回值,用于连续转换,本次调用将从上次结束位置开始进行处理。或可自行定义一个ChunkInfo从pcmDatas指定的位置开始进行转换
|
||||
|
||||
`option`:
|
||||
``` javascript
|
||||
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
|
||||
``` javascript
|
||||
{
|
||||
//可定义,从指定位置开始转换到结尾
|
||||
index:0 pcmDatas已处理到的索引
|
||||
offset:0.0 已处理到的index对应的pcm中的偏移的下一个位置
|
||||
|
||||
//仅作为返回值
|
||||
frameNext:null||[Int16,...] 下一帧的部分数据,frameSize设置了的时候才可能会有
|
||||
sampleRate:16000 结果的采样率,<=newSampleRate
|
||||
data:[Int16,...] 转换后的PCM结果,为一维数组;如果是连续转换,并且pcmDatas中并没有新数据时,data的长度可能为0
|
||||
}
|
||||
```
|
||||
|
||||
### 【静态方法】Recorder.PowerLevel(pcmAbsSum,pcmLength)
|
||||
计算音量百分比的一个方法,返回值:0-100,主要当做百分比用;注意:这个不是分贝,因此没用volume当做名称。
|
||||
|
||||
`pcmAbsSum`: pcm Int16所有采样的绝对值的和
|
||||
|
||||
`pcmLength`: pcm长度
|
||||
|
||||
|
||||
# :open_book:压缩合并一个自己需要的js文件
|
||||
可参考/src/package-build.js中如何合并的一个文件,比如mp3是由`recorder-core.js`,`engine/mp3.js`,`engine/mp3-engine.js`组成的。
|
||||
|
||||
除了`recorder-core.js`其他引擎文件都是可选的,可以把全部编码格式合到一起也,也可以只合并几种,然后就可以支持相应格式的录音了。
|
||||
|
||||
可以修改/src/package-build.js后,在src目录内执行压缩:
|
||||
``` javascript
|
||||
cnpm install
|
||||
npm start
|
||||
```
|
||||
|
||||
# :open_book:关于现有编码器
|
||||
如果你有其他格式的编码器并且想贡献出来,可以提交新增格式文件的PR(文件放到/src/engine中),我们升级它。
|
||||
|
||||
## wav (raw pcm format)
|
||||
wav格式编码器时参考网上资料写的,会发现代码和别人家的差不多。源码2kb大小。[wav转其他格式参考和测试](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=lib.transform.wav2other)
|
||||
|
||||
### wav转pcm
|
||||
生成的wav文件内音频数据的编码为未压缩的pcm数据(raw pcm),只是在pcm数据前面加了一个44字节的wav头;因此直接去掉前面44字节就能得到原始的pcm数据,如:`blob.slice(44,blob.size,"audio/pcm")`;
|
||||
|
||||
### 简单将多段小的wav片段合成长的wav文件
|
||||
由于RAW格式的wav内直接就是pcm数据,因此将小的wav片段文件去掉wav头后得到的原始pcm数据合并到一起,再加上新的wav头即可合并出长的wav文件;要求待合成的所有wav片段的采样率和位数需一致。[wav合并参考和测试+可移植源码](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=lib.merge.wav_merge)
|
||||
|
||||
## mp3 (CBR)
|
||||
采用的是[lamejs](https://github.com/zhuker/lamejs)(LGPL License)这个库的代码,`https://github.com/zhuker/lamejs/blob/bfb7f6c6d7877e0fe1ad9e72697a871676119a0e/lame.all.js`这个版本的文件代码;已对lamejs源码进行了部分改动,用于精简代码和修复发现的问题。LGPL协议涉及到的文件:`mp3-engine.js`;这些文件也采用LGPL授权,不适用MIT协议。源码518kb大小,压缩后150kb左右,开启gzip后50来k。[mp3转其他格式参考和测试](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=lib.transform.mp32other)
|
||||
|
||||
### 简单将多段小的mp3片段合成长的mp3文件
|
||||
由于lamejs CBR编码出来的mp3二进制数据从头到尾全部是大小相同的数据帧(采样率44100等无法被8整除的部分帧可能存在额外多1字节填充),没有其他任何多余信息,通过文件长度可计算出mp3的时长`fileSize*8/bitRate`([参考](https://blog.csdn.net/u010650845/article/details/53520426)),数据帧之间可以直接拼接。因此将小的mp3片段文件的二进制数据全部合并到一起即可得到长的mp3文件;要求待合成的所有mp3片段的采样率和比特率需一致。[mp3合并参考和测试+可移植源码](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=lib.merge.mp3_merge)
|
||||
|
||||
*注:CBR编码由于每帧数据的时长是固定的,mp3文件结尾最后这一帧的录音可能不能刚好填满,就会产生填充数据,多出来的这部分数据会导致mp3时长变长一点点,在实时转码传输时应当留意,解码成pcm后可直接去掉结尾的多余;另外可以通过调节待编码的pcm数据长度以达到刚好填满最后一帧来规避此问题,参考`Recorder.SampleData`方法提供的连续转码针对此问题的处理。首帧或前两帧可能是lame记录的信息帧,本库已去除(但小的mp3片段拼接起来停顿导致的杂音还是非常明显,实时处理时使用`takeoffEncodeChunk`选项可完全避免此问题),参考上面的已知问题。*
|
||||
|
||||
|
||||
## beta-ogg (Vorbis)
|
||||
采用的是[ogg-vorbis-encoder-js](https://github.com/higuma/ogg-vorbis-encoder-js)(MIT License),`https://github.com/higuma/ogg-vorbis-encoder-js/blob/7a872423f416e330e925f5266d2eb66cff63c1b6/lib/OggVorbisEncoder.js`这个版本的文件代码。此编码器源码2.2M,超级大,压缩后1.6M,开启gzip后327K左右。对录音的压缩率比lamejs高出一倍, 但Vorbis in Ogg好像Safari不支持([真的假的](https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats#Browser_compatibility))。
|
||||
|
||||
## beta-webm
|
||||
这个编码器时通过查阅MDN编写的一个玩意,没多大使用价值:录几秒就至少要几秒来编码。。。原因是:未找到对已有pcm数据进行快速编码的方法。数据导入到MediaRecorder,音频有几秒就要等几秒,类似边播放边收听形。(想接原始录音Stream?我不可能给的!)输出音频虽然可以通过比特率来控制文件大小,但音频文件中的比特率并非设定比特率,采样率由于是我们自己采样的,到这个编码器随他怎么搞。只有比较新的浏览器支持(需实现浏览器MediaRecorder),压缩率和mp3差不多。源码2kb大小。
|
||||
|
||||
## beta-amr (NB 窄带)
|
||||
采用的是[benz-amr-recorder](https://github.com/BenzLeung/benz-amr-recorder)(MIT License)优化后的[amr.js](https://github.com/jpemartins/amr.js)(Unknown License),`https://github.com/BenzLeung/benz-amr-recorder/blob/462c6b91a67f7d9f42d0579fb5906fad9edb2c9d/src/amrnb.js`这个版本的文件代码,已对此代码进行过调整更方便使用。支持编码和解码操作。由于最高只有12.8kbps的码率(AMR 12.2,8000hz),音质和同比配置的mp3、ogg差一个档次。由于支持解码操作,理论上所有支持Audio的浏览器都可以播放(需要自己写代码实现)。源码1M多,蛮大,压缩后445K,开启gzip后136K。优点:录音文件小。
|
||||
|
||||
### Recorder.amr2wav(amrBlob,True,False)
|
||||
已实现的一个把amr转成wav格式来播放的方法,`True=fn(wavBlob,duration)`。要使用此方法需要带上上面的`wav`格式编码器。仿照此方法可轻松转成别的格式,参考`mock`方法介绍那节。
|
||||
|
||||
|
||||
|
||||
# :open_book:其他音频格式支持办法
|
||||
``` javascript
|
||||
//比如增加aac格式支持 (可参考/src/engine/wav.js的简单实现;如果要实现边录边转码应该参考mp3的实现,需实现的接口比较多)
|
||||
|
||||
//新增一个aac.js,编写以下格式代码即可实现这个类型
|
||||
Recorder.prototype.aac=function(pcmData,successCall,failCall){
|
||||
//通过aac编码器把pcm[Int16,...]数据转成aac格式数据,通过this.set拿到传入的配置数据
|
||||
... pcmData->aacData
|
||||
|
||||
//返回数据
|
||||
successCall(new Blob([aacData.buffer],{type:"audio/aac"}));
|
||||
}
|
||||
|
||||
//调用
|
||||
Recorder({type:"aac"})
|
||||
```
|
||||
|
||||
|
||||
# :open_book:扩展
|
||||
在`src/extensions`目录内为扩展支持库,这些扩展库默认都没有合并到生成代码中,需单独引用(`dist`或`src`中的)才能使用。
|
||||
|
||||
【附】部分扩展使用效果图([在线运行观看](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=test.extensions.visualization)):
|
||||
|
||||

|
||||
|
||||
|
||||
## `WaveView`扩展
|
||||
[waveview.js](https://github.com/xiangyuecn/Recorder/blob/master/src/extensions/waveview.js),4kb大小源码,录音时动态显示波形,具体样子参考演示地址页面。此扩展参考[MCVoiceWave](https://github.com/HaloMartin/MCVoiceWave)库编写的,具体代码在`https://github.com/HaloMartin/MCVoiceWave/blob/f6dc28975fbe0f7fc6cc4dbc2e61b0aa5574e9bc/MCVoiceWave/MCVoiceWaveView.m`中。
|
||||
|
||||
此扩展是在录音时`onProcess`回调中使用;`Recorder.BufferSize`会影响绘制帧率,越小越流畅(但越消耗cpu),默认配置的大概12帧/s。基础使用方法:[](?Ref=WaveView.Codes&Start)
|
||||
``` javascript
|
||||
var wave;
|
||||
var rec=Recorder({
|
||||
onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate){
|
||||
wave.input(buffers[buffers.length-1],powerLevel,bufferSampleRate);//输入音频数据,更新显示波形
|
||||
}
|
||||
});
|
||||
rec.open(function(){
|
||||
wave=Recorder.WaveView({elem:".elem"}); //创建wave对象,写这里面浏览器妥妥的
|
||||
|
||||
rec.start();
|
||||
});
|
||||
```
|
||||
|
||||
[](?RefEnd)
|
||||
|
||||
### 【构造】wave=Recorder.WaveView(set)
|
||||
构造函数,`set`参数为配置对象,默认配置值如下:
|
||||
``` javascript
|
||||
set={
|
||||
elem:"css selector" //自动显示到dom,并以此dom大小为显示大小
|
||||
//或者配置显示大小,手动把waveviewObj.elem显示到别的地方
|
||||
,width:0 //显示宽度
|
||||
,height:0 //显示高度
|
||||
|
||||
//以上配置二选一
|
||||
|
||||
,scale:2 //缩放系数,应为正整数,使用2(3? no!)倍宽高进行绘制,避免移动端绘制模糊
|
||||
,speed:8 //移动速度系数,越大越快
|
||||
|
||||
,lineWidth:3 //线条基础粗细
|
||||
|
||||
//渐变色配置:[位置,css颜色,...] 位置: 取值0.0-1.0之间
|
||||
,linear1:[0,"rgba(150,96,238,1)",0.2,"rgba(170,79,249,1)",1,"rgba(53,199,253,1)"] //线条渐变色1,从左到右
|
||||
,linear2:[0,"rgba(209,130,255,0.6)",1,"rgba(53,199,255,0.6)"] //线条渐变色2,从左到右
|
||||
,linearBg:[0,"rgba(255,255,255,0.2)",1,"rgba(54,197,252,0.2)"] //背景渐变色,从上到下
|
||||
}
|
||||
```
|
||||
|
||||
### 【方法】wave.input(pcmData,powerLevel,sampleRate)
|
||||
输入音频数据,更新波形显示,这个方法调用的越快,波形越流畅。pcmData `[Int16,...]` 一维数组,为当前的录音数据片段,其他参数和`onProcess`回调相同。
|
||||
|
||||
|
||||
## `WaveSurferView`扩展
|
||||
[wavesurfer.view.js](https://github.com/xiangyuecn/Recorder/blob/master/src/extensions/wavesurfer.view.js),7kb大小源码,音频可视化波形显示,具体样子参考演示地址页面。
|
||||
|
||||
此扩展的使用方式和`WaveView`扩展完全相同,请参考上面的`WaveView`来使用;本扩展的波形绘制直接简单的使用PCM的采样数值大小来进行线条的绘制,同一段音频绘制出的波形和Audition内显示的波形外观上几乎没有差异。
|
||||
|
||||
### 【构造】surfer=Recorder.WaveSurferView(set)
|
||||
构造函数,`set`参数为配置对象,默认配置值如下:
|
||||
``` javascript
|
||||
set={
|
||||
elem:"css selector" //自动显示到dom,并以此dom大小为显示大小
|
||||
//或者配置显示大小,手动把surferObj.elem显示到别的地方
|
||||
,width:0 //显示宽度
|
||||
,height:0 //显示高度
|
||||
|
||||
//以上配置二选一
|
||||
|
||||
,scale:2 //缩放系数,应为正整数,使用2(3? no!)倍宽高进行绘制,避免移动端绘制模糊
|
||||
|
||||
,fps:50 //绘制帧率,不可过高,50-60fps运动性质动画明显会流畅舒适,实际显示帧率达不到这个值也并无太大影响
|
||||
|
||||
,duration:2500 //当前视图窗口内最大绘制的波形的持续时间,此处决定了移动速率
|
||||
,direction:1 //波形前进方向,取值:1由左往右,-1由右往左
|
||||
,position:0 //绘制位置,取值-1到1,-1为最底下,0为中间,1为最顶上,小数为百分比
|
||||
|
||||
,centerHeight:1 //中线基础粗细,如果为0不绘制中线,position=±1时应当设为0
|
||||
|
||||
//波形颜色配置:[位置,css颜色,...] 位置: 取值0.0-1.0之间
|
||||
,linear:[0,"rgba(0,187,17,1)",0.7,"rgba(255,215,0,1)",1,"rgba(255,102,0,1)"]
|
||||
,centerColor:"" //中线css颜色,留空取波形第一个渐变颜色
|
||||
}
|
||||
```
|
||||
|
||||
### 【方法】surfer.input(pcmData,powerLevel,sampleRate)
|
||||
输入音频数据,更新波形显示。pcmData `[Int16,...]` 一维数组,为当前的录音数据片段,其他参数和`onProcess`回调相同。
|
||||
|
||||
|
||||
|
||||
## `FrequencyHistogramView`扩展
|
||||
[frequency.histogram.view.js](https://github.com/xiangyuecn/Recorder/blob/master/src/extensions/frequency.histogram.view.js) + [lib.fft.js](https://github.com/xiangyuecn/Recorder/blob/master/src/extensions/lib.fft.js),12kb大小源码,音频可视化频率直方图显示,具体样子参考演示地址页面。此扩展核心算法参考Java开源库[jmp123](https://sourceforge.net/projects/jmp123/files/)的代码编写的,`jmp123`版本`0.3`;直方图特意优化主要显示0-5khz语音部分,其他高频显示区域较小,不适合用来展示音乐频谱。
|
||||
|
||||
此扩展的使用方式和`WaveView`扩展完全相同,请参考上面的`WaveView`来使用;请注意:必须同时引入`lib.fft.js`才能正常工作。
|
||||
|
||||
|
||||
### 【构造】histogram=Recorder.FrequencyHistogramView(set)
|
||||
构造函数,`set`参数为配置对象,默认配置值如下:
|
||||
``` javascript
|
||||
set={
|
||||
elem:"css selector" //自动显示到dom,并以此dom大小为显示大小
|
||||
//或者配置显示大小,手动把frequencyObj.elem显示到别的地方
|
||||
,width:0 //显示宽度
|
||||
,height:0 //显示高度
|
||||
|
||||
//以上配置二选一
|
||||
|
||||
,scale:2 //缩放系数,应为正整数,使用2(3? no!)倍宽高进行绘制,避免移动端绘制模糊
|
||||
|
||||
,fps:20 //绘制帧率,不可过高
|
||||
|
||||
,lineCount:30 //直方图柱子数量,数量的多少对性能影响不大,密集运算集中在FFT算法中
|
||||
,widthRatio:0.6 //柱子线条宽度占比,为所有柱子占用整个视图宽度的比例,剩下的空白区域均匀插入柱子中间;默认值也基本相当于一根柱子占0.6,一根空白占0.4;设为1不留空白,当视图不足容下所有柱子时也不留空白
|
||||
,spaceWidth:0 //柱子间空白固定基础宽度,柱子宽度自适应,当不为0时widthRatio无效,当视图不足容下所有柱子时将不会留空白,允许为负数,让柱子发生重叠
|
||||
,minHeight:0 //柱子保留基础高度,position不为±1时应该保留点高度
|
||||
,position:-1 //绘制位置,取值-1到1,-1为最底下,0为中间,1为最顶上,小数为百分比
|
||||
,mirrorEnable:false //是否启用镜像,如果启用,视图宽度会分成左右两块,右边这块进行绘制,左边这块进行镜像(以中间这根柱子的中心进行镜像)
|
||||
|
||||
,stripeEnable:true //是否启用柱子顶上的峰值小横条,position不是-1时应当关闭,否则会很丑
|
||||
,stripeHeight:3 //峰值小横条基础高度
|
||||
,stripeMargin:6 //峰值小横条和柱子保持的基础距离
|
||||
|
||||
,fallDuration:1000 //柱子从最顶上下降到最底部最长时间ms
|
||||
,stripeFallDuration:3500 //峰值小横条从最顶上下降到底部最长时间ms
|
||||
|
||||
//柱子颜色配置:[位置,css颜色,...] 位置: 取值0.0-1.0之间
|
||||
,linear:[0,"rgba(0,187,17,1)",0.5,"rgba(255,215,0,1)",1,"rgba(255,102,0,1)"]
|
||||
//峰值小横条渐变颜色配置,取值格式和linear一致,留空为柱子的渐变颜色
|
||||
,stripeLinear:null
|
||||
|
||||
,shadowBlur:0 //柱子阴影基础大小,设为0不显示阴影,如果柱子数量太多时请勿开启,非常影响性能
|
||||
,shadowColor:"#bbb" //柱子阴影颜色
|
||||
,stripeShadowBlur:-1 //峰值小横条阴影基础大小,设为0不显示阴影,-1为柱子的大小,如果柱子数量太多时请勿开启,非常影响性能
|
||||
,stripeShadowColor:"" //峰值小横条阴影颜色,留空为柱子的阴影颜色
|
||||
|
||||
//当发生绘制时会回调此方法,参数为当前绘制的频率数据和采样率,可实现多个直方图同时绘制,只消耗一个input输入和计算时间
|
||||
,onDraw:function(frequencyData,sampleRate){}
|
||||
}
|
||||
```
|
||||
|
||||
### 【方法】histogram.input(pcmData,powerLevel,sampleRate)
|
||||
输入音频数据,更新直方图显示。pcmData `[Int16,...]` 一维数组,为当前的录音数据片段,其他参数和`onProcess`回调相同。
|
||||
|
||||
|
||||
## `Sonic`扩展
|
||||
[sonic.js](https://github.com/xiangyuecn/Recorder/blob/master/src/extensions/sonic.js),37kb大小源码(压缩版gzip后4.5kb),音频变速变调转换,[参考此demo片段在线测试使用](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=teach.sonic.transform)。此扩展从[Sonic.java](https://github.com/waywardgeek/sonic/blob/71c51195de71627d7443d05378c680ba756545e8/Sonic.java)移植,并做了适当精简。
|
||||
|
||||
可到[assets/sonic-java](https://github.com/xiangyuecn/Recorder/tree/master/assets/sonic-java)目录运行java代码测试原版效果。
|
||||
|
||||
### 本扩展支持
|
||||
1. `Pitch`:变调不变速(会说话的汤姆猫),男女变声,只调整音调,不改变播放速度
|
||||
2. `Speed`:变速不变调(快放慢放),只调整播放速度,不改变音调
|
||||
3. `Rate`:变速变调,会改变播放速度和音调
|
||||
4. `Volume`:支持调整音量
|
||||
5. 支持实时处理,可在onProcess中实时处理PCM(需开启异步),配合SampleData方法使用更佳
|
||||
|
||||
### Sonic文档
|
||||
Sonic有两个构造方法,一个是同步方法,Sonic.Async是异步方法,同步方法简单直接但处理量大时会消耗大量时间,主要用于一次性的处理;异步方法由WebWorker在后台进行运算处理,但异步方法不一定能成功开启(低版本浏览器),主要用于实时处理。异步方法调用后必须调用flush方法,否则会产生内存泄露。
|
||||
|
||||
注意:由于同步方法转换操作需要占用比较多的CPU(但比转码小点),因此实时处理时在低端设备上可能会导致性能问题;在一次性处理大量pcm时,可采取切片+setTimeout进行处理,参考上面的demo片段。
|
||||
|
||||
注意:变速变调会大幅增减PCM数据长度,如果需要在onProcess中实时处理PCM,需要在rec.set中设置内部参数`rec.set.disableEnvInFix=true`来禁用设备卡顿时音频输入丢失补偿功能,否则可能导致错误的识别为设备卡顿。
|
||||
|
||||
注意:每次input输入的数据量应该尽量的大些,太少容易产生杂音,每次传入200ms以上的数据量就几乎没有影响了。
|
||||
|
||||
``` javascript
|
||||
//【构造初始化】
|
||||
var sonic=Recorder.Sonic(set) //同步调用,用于一次性处理
|
||||
var sonic=Recorder.Sonic.Async(set) //异步调用,用于实时处理,调用后必须调用flush方法,否则会产生内存泄露。
|
||||
/*set:{
|
||||
sampleRate:待处理pcm的采样率,就是input输入的buffer的采样率
|
||||
}*/
|
||||
|
||||
//【功能配置调用函数】同步异步通用,以下num取值正常为0.1-2.0,超过这个范围也是可以的,但不推荐
|
||||
sonic.setPitch(num) //num:0.1-n,变调不变速(会说话的汤姆猫),男女变声,只调整音调,不改变播放速度,默认为1.0不调整
|
||||
sonic.setSpeed(num) //num:0.1-n,变速不变调(快放慢放),只调整播放速度,不改变音调,默认为1.0不调整
|
||||
sonic.setRate(num) //num:0.1-n,变速变调,越小越缓重,越大越尖锐,会改变播放速度和音调,默认为1.0不调整
|
||||
sonic.setVolume(num) //num:0.1-n,调整音量,默认为1.0不调整
|
||||
sonic.setChordPitch(bool) //bool:默认false,作用未知,不推荐使用
|
||||
sonic.setQuality(num) //num:0或1,默认0时会减小输入采样率来提供处理速度,变调时才会用到,不推荐使用
|
||||
|
||||
//【同步调用方法】
|
||||
sonic.input(buffer) //buffer:[Int16,...] 一维数组,输入pcm数据,返回转换后的部分pcm数据,完整输出需要调用flush;返回值[Int16,...]长度可能为0,代表没有数据被转换;此方法是耗时的方法,一次性处理大量pcm需要切片+setTimeout优化
|
||||
sonic.flush() //将残余的未转换的pcm数据完成转换并返回;返回值[Int16,...]长度可能为0,代表没有数据被转换
|
||||
|
||||
//【异步调用方法】
|
||||
sonic.input(buffer,callback) //callback:fn(pcm),和同步方法相同,只是返回值通过callback返回
|
||||
sonic.flush(callback) //callback:fn(pcm),和同步方法相同,只是返回值通过callback返回
|
||||
```
|
||||
|
||||
|
||||
## `DTMF`扩展
|
||||
[dtmf.decode.js](https://github.com/xiangyuecn/Recorder/blob/master/src/extensions/dtmf.decode.js) + [lib.fft.js](https://github.com/xiangyuecn/Recorder/blob/master/src/extensions/lib.fft.js)、[dtmf.encode.js](https://github.com/xiangyuecn/Recorder/blob/master/src/extensions/dtmf.encode.js),两个js一个解码、一个编码,体积小均不超过10kb,纯js实现易于移植。[参考此demo片段在线测试使用](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=teach.dtmf.decode_and_encode)。
|
||||
|
||||
1. DTMF(电话拨号按键信号)解码器,解码得到按键值:可实现实时从音频数据流中解码得到电话拨号按键信息,用于电话录音软解,软电话实时提取DTMF按键信号等;识别DTMF按键准确度高,误识别率低,支持识别120ms以上按键间隔+30ms以上的按键音,纯js实现易于移植;请注意:使用dtmf.decode.js必须同时引入`lib.fft.js`(由java移植过来的)才能正常工作。
|
||||
2. DTMF(电话拨号按键信号)编码生成器,生成按键对应的音频PCM信号:可实现生成按键对应的音频PCM信号,用于DTMF按键信号生成,软电话实时发送DTMF按键信号等;生成信号代码、原理简单粗暴,纯js实现易于移植,0依赖。
|
||||
|
||||
### 【方法】Recorder.DTMF_Decode(pcmData,sampleRate,prevChunk)
|
||||
解码DTMF只有这个一个函数,此函数支持连续调用,将上次的返回值当做参数即可实现实时音频流数据的连续解码处理。
|
||||
|
||||
``` javascript
|
||||
参数:
|
||||
pcmData:[Int16,...] pcm一维数组,原则上一次处理的数据量不要超过10秒,太长的数据应当分段延时处理
|
||||
sampleRate: 123 pcm的采样率
|
||||
prevChunk: null || {} 上次的返回值,用于连续识别
|
||||
|
||||
返回:
|
||||
chunk:{
|
||||
keys:[keyItem,...] 识别到的按键,如果未识别到数组长度为0
|
||||
keyItem:{
|
||||
key:"" //按键值 0-9 #*
|
||||
time:123 //所在的时间位置,ms
|
||||
}
|
||||
|
||||
//以下用于下次接续识别
|
||||
lastIs:"" "":mute {}:match 结尾处是什么
|
||||
lastCheckCount:0 结尾如果是key,此时的检查次数
|
||||
totalLen:0 总采样数,相对4khz
|
||||
pcm:[Int16,...] 4khz pcm数据
|
||||
}
|
||||
```
|
||||
|
||||
### 【方法】Recorder.DTMF_Encode(key,sampleRate,duration,mute)
|
||||
本方法用来生成单个按键信号pcm数据,属于底层方法,要混合多个按键信号到别的pcm中请用封装好的DTMF_EncodeMix方法。
|
||||
|
||||
``` javascript
|
||||
参数:
|
||||
key: 单个按键0-9#*
|
||||
sampleRate:123 要生成的pcm采样率
|
||||
duration:100 按键音持续时间
|
||||
mute:50 按键音前后静音时长
|
||||
返回:
|
||||
pcm:[Int16,...],生成单个按键信号
|
||||
```
|
||||
|
||||
### 【方法】Recorder.DTMF_EncodeMix(set)
|
||||
本方法返回EncodeMix对象,将输入的按键信号混合到持续输入的pcm流中,当.mix(inputPcms)提供的太短的pcm会无法完整放下一个完整的按键信号,所以需要不停调用.mix(inputPcms)进行混合。
|
||||
``` javascript
|
||||
set={
|
||||
duration:100 //按键信号持续时间 ms,最小值为30ms
|
||||
,mute:25 //按键音前后静音时长 ms,取值为0也是可以的
|
||||
,interval:200 //两次按键信号间隔时长 ms,间隔内包含了duration+mute*2,最小值为120ms
|
||||
}
|
||||
|
||||
EncodeMix对象:
|
||||
.add(keys)
|
||||
添加一个按键或多个按键 "0" "123#*",后面慢慢通过mix方法混合到pcm中,无返回值
|
||||
|
||||
.mix(pcms,sampleRate,index)
|
||||
将已添加的按键信号混合到pcm中,pcms:[[Int16,...],...]二维数组,sampleRate:pcm的采样率,index:pcms第一维开始索引,将从这个pcm开始混合。
|
||||
返回状态对象:{
|
||||
newEncodes:[{key:"*",data:[Int16,...]},...] //本次混合新生成的按键信号列表 ,如果没有产生新信号将为空数组
|
||||
,hasNext:false //是否还有未混合完的信号
|
||||
}
|
||||
注意:调用本方法会修改pcms中的内容,因此混合结果就在pcms内。
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
# :open_book:兼容性
|
||||
对于支持录音的浏览器能够正常录音并返回录音数据;对于不支持的浏览器,引入js和执行相关方法都不会产生异常,并且进入相关的fail回调。一般在open的时候就能检测到是否支持或者被用户拒绝,可在用户开始录音之前提示浏览器不支持录音或授权。
|
||||
|
||||
|
||||
|
||||
|
||||
# :open_book:Android Hybrid App中录音示例
|
||||
在Android Hybrid App中使用本库来录音,需要在App源码中实现以下两步分:
|
||||
|
||||
1. 在`AndroidManifest.xml`声明需要用到的两个权限
|
||||
``` xml
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
```
|
||||
|
||||
2. `WebChromeClient`中实现`onPermissionRequest`网页授权请求
|
||||
``` java
|
||||
@Override
|
||||
public void onPermissionRequest(PermissionRequest request) {
|
||||
...此处应包裹一层系统权限请求
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
request.grant(request.getResources());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 注:如果应用的`腾讯X5内核`,除了上面两个权限外,还必须提供`android.permission.CAMERA`权限。另外无法重写此`onPermissionRequest`方法,他会自己弹框询问,如果被拒绝了就看X5脸色了(随着X5不停更新什么时候恢复弹框天知地知就是你不知),参考已知问题部分。
|
||||
|
||||
如果不出意外,App内显示的网页就能正常录音了。
|
||||
|
||||
### 备忘小插曲
|
||||
排查 [#46](https://github.com/xiangyuecn/Recorder/issues/46) `Android WebView`内长按录音不能收到`touchend`问题时,发现touch事件会被打断,反复折腾,最终发现是每次检测权限都会调用`Activity.requestPermissions`,而`requestPermissions`会造成WebView打断touch事件,进而产生H5、AppNative原生录都会产生此问题;最后老实把精简掉的`checkSelfPermission`加上检测一下是否已授权,就没有此问题了,囧。
|
||||
|
||||
### 附带测试项目
|
||||
[app-support-sample/demo_android](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample/demo_android)目录中提供了Android测试源码(如果不想自己打包可以用打包好的apk来测试,文件名为`app-debug.apk.zip`,自行去掉.zip后缀)。
|
||||
|
||||
|
||||
|
||||
|
||||
# :open_book:IOS Hybrid App中录音示例
|
||||
纯粹的H5录音在IOS WebView中是不支持的,需要有Native层的支持,具体参考RecordApp中的[app-support-sample/demo_ios](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample/demo_ios),含IOS App源码。
|
||||
|
||||
|
||||
|
||||
# :open_book:语音通话聊天demo:实时编码、传输与播放验证
|
||||
在[线测试Demo](https://xiangyuecn.github.io/Recorder/)中包含了一个语音通话聊天的测试功能,没有服务器支持所以仅支持局域网内一对一语音。用两个设备(浏览器打开两个标签也可以)打开demo,勾选H5版语音通话聊天,按提示交换两个设备的信息即可成功进行P2P连接,然后进行语音。实际使用时数据传输可以用WebSocket,会简单好多。
|
||||
|
||||
编写本语音测试的目的在于验证H5录音实时转码、传输的可行性,并验证实时转码mp3格式小片段文件接收后的可播放性。经测试发现:除了移动端可能存在设备性能低下的问题以外,录音后实时转码mp3并传输给对方是可行的,对方接收后播放也能连贯的播放(效果还是要看播放代码写的怎么样,目前没有比较完美的播放代码)。另外(16kbps,16khz)MP3开语音15分钟大概3M的流量,wav 15分钟要37M多流量。
|
||||
|
||||
另外除wav外MP3等格式编码出来的音频的播放时间比PCM原始数据要长一些或短一些,如果涉及到解码或拼接时,这个地方需要注意(如果类型支持,实时处理时使用`takeoffEncodeChunk`选项可完全避免此问题)。
|
||||
|
||||

|
||||
|
||||
|
||||
# :open_book:工具:代码运行和静态分发Runtime
|
||||
[在线访问](https://xiangyuecn.github.io/Recorder/assets/%E5%B7%A5%E5%85%B7-%E4%BB%A3%E7%A0%81%E8%BF%90%E8%A1%8C%E5%92%8C%E9%9D%99%E6%80%81%E5%88%86%E5%8F%91Runtime.html),本工具提供在线运行和测试代码的能力,本库的大部分小demo将由此工具来进行开发和承载。本工具提供代码片段的分发功能,代码存储在url中,因此简单可靠;额外提供了一套源码作者的身份认证机制。
|
||||
|
||||
我们不传输、不存储数据,我们只是代码的可靠搬运工。看图:
|
||||
|
||||

|
||||
|
||||
|
||||
# :open_book:工具:裸(RAW、WAV)PCM转WAV播放测试和转码
|
||||
[在线访问](https://xiangyuecn.github.io/Recorder/assets/%E5%B7%A5%E5%85%B7-%E8%A3%B8PCM%E8%BD%ACWAV%E6%92%AD%E6%94%BE%E6%B5%8B%E8%AF%95.html),本工具用来对原始的PCM音频数据进行封装、播放、转码,操作极其简单,免去了动用二进制编辑工具操作的麻烦。比如加工一下Android AudioRecord(44100)采集的音频。源码在`assets/工具-裸PCM转WAV播放测试.html`;
|
||||
|
||||

|
||||
|
||||
|
||||
# :open_book:关于微信JsSDK和RecordApp
|
||||
微信内浏览器他家的[JsSDK](https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421141115)也支持录音,涉及笨重难调的公众号开发(光sdk初始化就能阻碍很多新奇想法的产生,signature限制太多),只能满足最基本的使用(大部分情况足够了)。获取音频数据必须绕一个大圈:录好音了->上传到微信服务器->自家服务器请求微信服务器多进行媒体下载->保存录音(微信小程序以前也是二逼路子,现在稍微好点能实时拿到录音mp3数据)。
|
||||
|
||||
[2018]由于微信IOS上不支持原生JS录音,Android上又支持,为了兼容而去兼容的事情我是拒绝的(而且是仅仅为了兼容IOS上面的微信),其实也算不上去兼容,因为微信JsSDK中的接口完全算是另外一种东西,接入的话对整个录音流程都会产生完全不一样的变化,还不如没有进入录音流程之前就进行分支判断处理。
|
||||
|
||||
[2019]大动干戈,仅为兼容IOS而生,不得不向大厂低头,我还是为兼容而去兼容了IOS微信,对不支持录音的IOS微信`浏览器`、`小程序web-view`进行了兼容,使用微信JsSDK来录音,并以前未开源的兼容代码基础上重写了`RecordApp`,源码在`app-support-sample`、`src/app-support`内。
|
||||
|
||||
最后:如果要兼容IOS,可以自行接入JsSDK或使用`RecordApp`(没有公众号开个订阅号又不要钱),基本上可以忽略兼容性问题,就是麻烦点。
|
||||
|
||||
|
||||
# :star:捐赠
|
||||
如果这个库有帮助到您,请 Star 一下。
|
||||
|
||||
您也可以使用支付宝或微信打赏作者:
|
||||
|
||||
 
|
||||
486
WebRoot/node_modules/Recorder-master/app-support-sample/QuickStart.html
generated
vendored
Normal file
@ -0,0 +1,486 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||
<link rel="shortcut icon" type="image/png" href="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/assets/icon.png">
|
||||
|
||||
<title>RecordApp QuickStart: 快速入门</title>
|
||||
|
||||
<script>
|
||||
var Tips='你可以直接将 <a target="_blank" href="https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/QuickStart.html">/app-support-sample/QuickStart.html</a> 文件copy 到你的(https)网站中,然后将变量PageSet_RecordAppWxApi改成你自己的后端API地址,无需其他文件,就能正常开始测试了;相比 RecordApp (/app-support-sample/index.html) 这个大而全(杂乱)的demo,本文件更适合入门学习'+unescape("%uD83D%uDE04");
|
||||
console.log(Tips);
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script>
|
||||
/********将此处后端API地址改成你的地址即可开始测试**********/
|
||||
|
||||
/**【需修改】请使用自己的微信JsSDK签名接口、素材下载接口,不能用这个空的默认值,微信【强制】要【绑安全域名】,别的站用不了。
|
||||
后端签名接口参考文档:微信JsSDK wx.config需使用到后端接口进行签名,文档: https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html 阅读:通过config接口注入权限验证配置、附录1-JS-SDK使用权限签名算法
|
||||
后端素材下载接口参考文档: https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1444738727
|
||||
**/
|
||||
var PageSet_RecordAppWxApi="";
|
||||
/*这个api接口:
|
||||
会实现两个功能,ajax POST请求参数如下(都是两个参数,完整细节看下面ajax调用):
|
||||
功能一、action="sign" //JsSDK签名
|
||||
url="https://x.com/page" //当前页面url地址,需要对这个地址进行签名
|
||||
功能二、action="wxdown" //素材下载
|
||||
mediaID="abcd" //需下载的素材ID
|
||||
响应内容(JSON Object):
|
||||
{
|
||||
c:0 //code,0:正常,其他:错误
|
||||
,m:"" //errMsg code!=0时的错误描述
|
||||
,v:{} //返回结果value,为JSON Object
|
||||
//sign时:v={appid:"", timestamp:"", noncestr:"", signature:""} 就是返回wx.config需要的签名相关参数
|
||||
//wxdown时:v={mime:"audio/amr", data:"base64文本"} 就是返回素材下载的音频文件base64编码数据
|
||||
}*/
|
||||
|
||||
|
||||
/**【需修改】请使用自己的js文件目录,不要用github的不稳定。RecordApp会自动从这个目录内进行加载相关的实现文件、Recorder核心、编码引擎,会自动默认加载哪些文件,请查阅app.js内所有Platform的paths配置**/
|
||||
var PageSet_RecordAppBaseFolder = "https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/dist/";//使用dist目录内的文件小2/3
|
||||
</script>
|
||||
|
||||
<!--【1.1】加载独立配置文件,免得修改app.js-->
|
||||
<!-- 可选开启ios weixin支持的相关配置 -->
|
||||
<script src="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/app-support-sample/ios-weixin-config.js"></script>
|
||||
<!-- 可选开启native支持的相关配置 -->
|
||||
<script src="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/app-support-sample/native-config.js"></script>
|
||||
|
||||
|
||||
<!--
|
||||
【1.2】引入框架文件,app.js会自动加载实现文件、Recorder核心、编码引擎,需确保PageSet_RecordAppBaseFolder目录正确
|
||||
注意自己使用时应当自己把源码clone下来,然后通过src="/src/app-support/app.js"引入,这里为了方便copy文件测试起见,使用了JsDelivr CDN。
|
||||
-->
|
||||
<script src="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/dist/app-support/app.js"></script>
|
||||
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
//注册可选扩展库
|
||||
var paths=RecordApp.Platforms.Default.Config.paths;
|
||||
paths.push({
|
||||
url:PageSet_RecordAppBaseFolder+"extensions/waveview.js"
|
||||
,lazyBeforeStart:1 //开启延迟加载,在Start调用前任何时间进行加载都行
|
||||
,check:function(){return !Recorder.WaveView}
|
||||
});
|
||||
paths.push({
|
||||
url:PageSet_RecordAppBaseFolder+"extensions/lib.fft.js"
|
||||
,lazyBeforeStart:1
|
||||
,check:function(){return !Recorder.LibFFT}
|
||||
});
|
||||
paths.push({
|
||||
url:PageSet_RecordAppBaseFolder+"extensions/frequency.histogram.view.js"
|
||||
,lazyBeforeStart:1
|
||||
,check:function(){return !Recorder.FrequencyHistogramView}
|
||||
});
|
||||
|
||||
RecordApp.AlwaysUseWeixinJS=!!(+localStorage["RecordApp_AlwaysUseWeixinJS"]||0);
|
||||
RecordApp.AlwaysAppUseJS=!!(+localStorage["RecordApp_AlwaysAppUseJS"]||0);
|
||||
|
||||
//立即加载环境,自动把Recorder加载进来
|
||||
RecordApp.Install(function(){
|
||||
console.log("RecordApp.Install成功");
|
||||
},function(){
|
||||
var msg="RecordApp.Install出错:"+err;
|
||||
console.log(msg);
|
||||
alert(msg);
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<!-- 【2】构建界面 -->
|
||||
<div class="main">
|
||||
<div class="mainBox">
|
||||
<span style="font-size:32px;color:#f60;">RecordApp QuickStart: 快速入门</span>
|
||||
<a href="https://github.com/xiangyuecn/Recorder">GitHub >></a>
|
||||
|
||||
<div style="padding-top:10px;color:#666">
|
||||
更多Demo:
|
||||
<a class="lb" href="https://xiangyuecn.github.io/Recorder/">Recorder H5</a>
|
||||
<a class="lb" href="https://jiebian.life/web/h5/github/recordapp.aspx">RecordApp</a>
|
||||
<a class="lb" href="https://xiangyuecn.github.io/Recorder/QuickStart.html">Recorder QuickStart</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mainBox">
|
||||
<!-- 按钮控制区域 -->
|
||||
<div class="pd btns">
|
||||
<button onclick="recReq()">请求权限</button>
|
||||
<button onclick="recStart()">录制</button>
|
||||
<button onclick="recStop()" style="margin-right:80px">停止</button>
|
||||
|
||||
<span style="display: inline-block;">
|
||||
<button onclick="recPlay()">播放</button>
|
||||
<button onclick="recUpload()">上传</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 波形绘制区域 -->
|
||||
<div class="pd recpower">
|
||||
<div style="height:40px;width:300px;background:#999;position:relative;">
|
||||
<div class="recpowerx" style="height:40px;background:#0B1;position:absolute;"></div>
|
||||
<div class="recpowert" style="padding-left:50px; line-height:40px; position: relative;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pd waveBox">
|
||||
<div style="border:1px solid #ccc;display:inline-block"><div style="height:100px;width:300px;" class="recwave"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- 功能配置区域 -->
|
||||
<div>
|
||||
<div class="pd">
|
||||
<span class="lb">JsSDK :</span> <label><input type="checkbox" class="alwaysUseWeixinJS">Android微信内也用JsSDK</label>
|
||||
</div>
|
||||
<div>
|
||||
<span class="lb">AppUseJS :</span> <label><input type="checkbox" class="alwaysAppUseJS">App里面总是使用Recorder H5录音</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志输出区域 -->
|
||||
<div class="mainBox">
|
||||
<div class="reclog"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 【3】实现录音逻辑 -->
|
||||
<script>
|
||||
var rec,wave,recBlob;
|
||||
/**调用RequestPermission打开录音请求好录音权限**/
|
||||
var recReq=function(){//一般在显示出录音按钮或相关的录音界面时进行此方法调用,后面用户点击开始录音时就能畅通无阻了
|
||||
rec=false;
|
||||
reclog("正在打开录音,请稍后...");
|
||||
|
||||
createDelayDialog(); //我们可以选择性的弹一个对话框:为了防止移动端浏览器存在第三种情况:用户忽略,并且(或者国产系统UC系)浏览器没有任何回调,此处demo省略了弹窗的代码
|
||||
RecordApp.RequestPermission(function(){
|
||||
rec=true;
|
||||
dialogCancel(); //如果开启了弹框,此处需要取消
|
||||
|
||||
reclog("已打开录音,可以点击录制开始录音了",2);
|
||||
},function(err,isUserNotAllow){
|
||||
dialogCancel(); //如果开启了弹框,此处需要取消
|
||||
reclog((isUserNotAllow?"UserNotAllow,":"")+"打开录音失败:"+err,1);
|
||||
});
|
||||
|
||||
window.waitDialogClick=function(){
|
||||
dialogCancel();
|
||||
reclog("打开失败:权限请求被忽略,<span style='color:#f00'>用户主动点击的弹窗</span>",1);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
/**开始录音**/
|
||||
function recStart(){
|
||||
if(!rec|| !RecordApp.Current){
|
||||
reclog("未请求权限", 1);
|
||||
return;
|
||||
};
|
||||
|
||||
if(RecordApp.Current==RecordApp.Platforms.Weixin){
|
||||
reclog("正在使用微信JsSDK,录音过程中不会有任何回调,不要惊慌");
|
||||
}else if(RecordApp.Current==RecordApp.Platforms.Native){
|
||||
reclog("正在使用Native录音,底层由App原生层提供支持");
|
||||
}else{
|
||||
reclog("正在使用H5录音,底层由Recorder直接提供支持");
|
||||
};
|
||||
|
||||
var set={
|
||||
type:"mp3"
|
||||
,bitRate:16
|
||||
,sampleRate:16000
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate,newBufferIdx,asyncEnd){
|
||||
//录音实时回调,大约1秒调用12次本回调
|
||||
document.querySelector(".recpowerx").style.width=powerLevel+"%";
|
||||
document.querySelector(".recpowert").innerText=bufferDuration+" / "+powerLevel;
|
||||
|
||||
//可视化图形绘制
|
||||
wave.input(buffers[buffers.length-1],powerLevel,bufferSampleRate);
|
||||
}
|
||||
};
|
||||
|
||||
wave=null;
|
||||
recBlob=null;
|
||||
RecordApp.Start(set,function(){
|
||||
reclog(RecordApp.Current.Key+"录制中:"+set.type+" "+set.bitRate+"kbps",2);
|
||||
|
||||
//此处创建这些音频可视化图形绘制浏览器支持妥妥的
|
||||
wave=Recorder.FrequencyHistogramView({elem:".recwave"});
|
||||
},function(err){
|
||||
reclog(RecordApp.Current.Key+"开始录音失败:"+err,1);
|
||||
});
|
||||
};
|
||||
|
||||
/**结束录音,得到音频文件**/
|
||||
function recStop(){
|
||||
if(!rec|| !RecordApp.Current){
|
||||
reclog("未请求权限",1);
|
||||
return;
|
||||
};
|
||||
|
||||
RecordApp.Stop(function(blob,duration){
|
||||
console.log(blob,(window.URL||webkitURL).createObjectURL(blob),"时长:"+duration+"ms");
|
||||
|
||||
recBlob=blob;
|
||||
reclog("已录制mp3:"+duration+"ms "+blob.size+"字节,可以点击播放、上传了",2);
|
||||
},function(msg){
|
||||
reclog("录音失败:"+msg,1);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**播放**/
|
||||
function recPlay(){
|
||||
if(!recBlob){
|
||||
reclog("请先录音,然后停止后再播放",1);
|
||||
return;
|
||||
};
|
||||
var cls=("a"+Math.random()).replace(".","");
|
||||
reclog('播放中: <span class="'+cls+'"></span>');
|
||||
var audio=document.createElement("audio");
|
||||
audio.controls=true;
|
||||
document.querySelector("."+cls).appendChild(audio);
|
||||
//简单利用URL生成播放地址,注意不用了时需要revokeObjectURL,否则霸占内存
|
||||
audio.src=(window.URL||webkitURL).createObjectURL(recBlob);
|
||||
audio.play();
|
||||
|
||||
setTimeout(function(){
|
||||
(window.URL||webkitURL).revokeObjectURL(audio.src);
|
||||
},5000);
|
||||
};
|
||||
|
||||
/**上传**/
|
||||
function recUpload(){
|
||||
var blob=recBlob;
|
||||
if(!blob){
|
||||
reclog("请先录音,然后停止后再上传",1);
|
||||
return;
|
||||
};
|
||||
|
||||
//本例子假设使用原始XMLHttpRequest请求方式,实际使用中自行调整为自己的请求方式
|
||||
//录音结束时拿到了blob文件对象,可以用FileReader读取出内容,或者用FormData上传
|
||||
var api="https://xx.xx/test_request";
|
||||
var onreadystatechange=function(title){
|
||||
return function(){
|
||||
if(xhr.readyState==4){
|
||||
if(xhr.status==200){
|
||||
reclog(title+"上传成功",2);
|
||||
}else{
|
||||
reclog(title+"没有完成上传,演示上传地址无需关注上传结果,只要浏览器控制台内Network面板内看到的请求数据结构是预期的就ok了。", "#d8c1a0");
|
||||
|
||||
console.error(title+"上传失败",xhr.status,xhr.responseText);
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
reclog("开始上传到"+api+",请求稍后...");
|
||||
|
||||
/***方式一:将blob文件转成base64纯文本编码,使用普通application/x-www-form-urlencoded表单上传***/
|
||||
var reader=new FileReader();
|
||||
reader.onloadend=function(){
|
||||
var postData="";
|
||||
postData+="mime="+encodeURIComponent(blob.type);//告诉后端,这个录音是什么格式的,可能前后端都固定的mp3可以不用写
|
||||
postData+="&upfile_b64="+encodeURIComponent((/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result)||[])[1]) //录音文件内容,后端进行base64解码成二进制
|
||||
//...其他表单参数
|
||||
|
||||
var xhr=new XMLHttpRequest();
|
||||
xhr.open("POST", api);
|
||||
xhr.setRequestHeader("Content-Type","application/x-www-form-urlencoded");
|
||||
xhr.onreadystatechange=onreadystatechange("上传方式一【Base64】");
|
||||
xhr.send(postData);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
|
||||
/***方式二:使用FormData用multipart/form-data表单上传文件***/
|
||||
var form=new FormData();
|
||||
form.append("upfile",blob,"recorder.mp3"); //和普通form表单并无二致,后端接收到upfile参数的文件,文件名为recorder.mp3
|
||||
//...其他表单参数
|
||||
|
||||
var xhr=new XMLHttpRequest();
|
||||
xhr.open("POST", api);
|
||||
xhr.onreadystatechange=onreadystatechange("上传方式二【FormData】");
|
||||
xhr.send(form);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//recReq我们可以选择性的弹一个对话框:为了防止移动端浏览器存在第三种情况:用户忽略,并且(或者国产系统UC系)浏览器没有任何回调
|
||||
var showDialog=function(){
|
||||
if(!/mobile/i.test(navigator.userAgent)){
|
||||
return;//只在移动端开启没有权限请求的检测
|
||||
};
|
||||
dialogCancel();
|
||||
|
||||
//显示弹框,应该使用自己的弹框方式
|
||||
var div=document.createElement("div");
|
||||
document.body.appendChild(div);
|
||||
div.innerHTML=(''
|
||||
+'<div class="waitDialog" style="z-index:99999;width:100%;height:100%;top:0;left:0;position:fixed;background:rgba(0,0,0,0.3);">'
|
||||
+'<div style="display:flex;height:100%;align-items:center;">'
|
||||
+'<div style="flex:1;"></div>'
|
||||
+'<div style="width:240px;background:#fff;padding:15px 20px;border-radius: 10px;">'
|
||||
+'<div style="padding-bottom:10px;">录音功能需要麦克风权限,请允许;如果未看到任何请求,请点击忽略~</div>'
|
||||
+'<div style="text-align:center;"><a onclick="waitDialogClick()" style="color:#0B1">忽略</a></div>'
|
||||
+'</div>'
|
||||
+'<div style="flex:1;"></div>'
|
||||
+'</div>'
|
||||
+'</div>');
|
||||
};
|
||||
var createDelayDialog=function(){
|
||||
dialogInt=setTimeout(function(){//定时8秒后打开弹窗,用于监测浏览器没有发起权限请求的情况,在open前放置定时器利于收到了回调能及时取消(不管open是同步还是异步回调的)
|
||||
showDialog();
|
||||
},8000);
|
||||
};
|
||||
var dialogInt;
|
||||
var dialogCancel=function(){
|
||||
clearTimeout(dialogInt);
|
||||
|
||||
//关闭弹框,应该使用自己的弹框方式
|
||||
var elems=document.querySelectorAll(".waitDialog");
|
||||
for(var i=0;i<elems.length;i++){
|
||||
elems[i].parentNode.removeChild(elems[i]);
|
||||
};
|
||||
};
|
||||
//recReq弹框End
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!--以下这坨可以忽略-->
|
||||
<script>
|
||||
(function(){
|
||||
var alwaysUseWeixinJS=document.querySelector(".alwaysUseWeixinJS");
|
||||
var alwaysAppUseJS=document.querySelector(".alwaysAppUseJS");
|
||||
|
||||
alwaysUseWeixinJS.checked=RecordApp.AlwaysUseWeixinJS;
|
||||
alwaysAppUseJS.checked=RecordApp.AlwaysAppUseJS;
|
||||
|
||||
var clickFn=function(key){
|
||||
return function(e){
|
||||
localStorage[key]=e.target.checked?1:0;
|
||||
location.reload();
|
||||
};
|
||||
};
|
||||
alwaysUseWeixinJS.addEventListener("click",clickFn("RecordApp_AlwaysUseWeixinJS"));
|
||||
alwaysAppUseJS.addEventListener("click",clickFn("RecordApp_AlwaysAppUseJS"));
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
function reclog(s,color){
|
||||
var now=new Date();
|
||||
var t=("0"+now.getHours()).substr(-2)
|
||||
+":"+("0"+now.getMinutes()).substr(-2)
|
||||
+":"+("0"+now.getSeconds()).substr(-2);
|
||||
var div=document.createElement("div");
|
||||
var elem=document.querySelector(".reclog");
|
||||
elem.insertBefore(div,elem.firstChild);
|
||||
div.innerHTML='<div style="color:'+(!color?"":color==1?"red":color==2?"#0b1":color)+'">['+t+']'+s+'</div>';
|
||||
};
|
||||
|
||||
reclog("Recorder H5使用简单,功能丰富,支持PC、Android,但IOS上仅Safari支持录音"+unescape("%uD83D%uDCAA"),"#f60;font-weight:bold;font-size:24px");
|
||||
reclog("RecordApp除Recorder支持的外,支持Hybrid App,IOS上支持微信网页和小程序web-view"+unescape("%uD83C%uDF89"),"#0b1;font-weight:bold;font-size:24px");
|
||||
reclog(Tips);
|
||||
</script>
|
||||
|
||||
<!-- 加载打赏挂件 -->
|
||||
<script src="https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/assets/zdemo.widget.donate.js"></script>
|
||||
<script>
|
||||
var donateView=document.createElement("div");
|
||||
document.querySelector(".reclog").appendChild(donateView);
|
||||
DonateWidget({
|
||||
log:function(msg){reclog(msg)}
|
||||
,mobElem:donateView
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body{
|
||||
word-wrap: break-word;
|
||||
background:#f5f5f5 center top no-repeat;
|
||||
background-size: auto 680px;
|
||||
}
|
||||
pre{
|
||||
white-space:pre-wrap;
|
||||
}
|
||||
a{
|
||||
text-decoration: none;
|
||||
color:#06c;
|
||||
}
|
||||
a:hover{
|
||||
color:#f00;
|
||||
}
|
||||
|
||||
.main{
|
||||
max-width:700px;
|
||||
margin:0 auto;
|
||||
padding-bottom:80px
|
||||
}
|
||||
|
||||
.mainBox{
|
||||
margin-top:12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
--border: 1px solid #f60;
|
||||
box-shadow: 2px 2px 3px #aaa;
|
||||
}
|
||||
|
||||
|
||||
.btns button{
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: #f60;
|
||||
color:#fff;
|
||||
padding: 0 15px;
|
||||
margin:3px 20px 3px 0;
|
||||
line-height: 36px;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.btns button:active{
|
||||
background: #f00;
|
||||
}
|
||||
|
||||
.pd{
|
||||
padding:0 0 6px 0;
|
||||
}
|
||||
.lb{
|
||||
display:inline-block;
|
||||
vertical-align: middle;
|
||||
background:#00940e;
|
||||
color:#fff;
|
||||
font-size:14px;
|
||||
padding:2px 8px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
</style>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
423
WebRoot/node_modules/Recorder-master/app-support-sample/README.md
generated
vendored
Normal file
@ -0,0 +1,423 @@
|
||||
[Recorder](https://github.com/xiangyuecn/Recorder/) | RecordApp
|
||||
|
||||
Basic:
|
||||
<a title="Stars" href="https://github.com/xiangyuecn/Recorder"><img src="https://img.shields.io/github/stars/xiangyuecn/Recorder?color=0b1&logo=github" alt="Stars"></a>
|
||||
<a title="Forks" href="https://github.com/xiangyuecn/Recorder"><img src="https://img.shields.io/github/forks/xiangyuecn/Recorder?color=0b1&logo=github" alt="Forks"></a>
|
||||
<a title="npm Version" href="https://www.npmjs.com/package/recorder-core"><img src="https://img.shields.io/npm/v/recorder-core?color=0b1&logo=npm" alt="npm Version"></a>
|
||||
<a title="License" href="https://github.com/xiangyuecn/Recorder/blob/master/LICENSE"><img src="https://img.shields.io/github/license/xiangyuecn/Recorder?color=0b1&logo=github" alt="License"></a>
|
||||
|
||||
Traffic:
|
||||
<a title="npm Downloads" href="https://www.npmjs.com/package/recorder-core"><img src="https://img.shields.io/npm/dt/recorder-core?color=f60&logo=npm" alt="npm Downloads"></a>
|
||||
<a title="cnpm" href="https://npm.taobao.org/package/recorder-core"><img src="https://img.shields.io/badge/cnpm-available-1690CD" alt="cnpm"></a><a title="cnpm" href="https://npm.taobao.org/package/recorder-core"><img src="https://npm.taobao.org/badge/d/recorder-core.svg" alt="cnpm"></a>
|
||||
<a title="JsDelivr CDN" href="https://www.jsdelivr.com/package/gh/xiangyuecn/Recorder"><img src="https://data.jsdelivr.com/v1/package/gh/xiangyuecn/Recorder/badge" alt="JsDelivr CDN"></a>
|
||||
<a title="51LA" href="https://www.51.la/?20469973"><img src="https://img.shields.io/badge/51LA-available-0b1" alt="cnpm"></a>
|
||||
|
||||
|
||||
# :open_book:RecordApp 最大限度的统一兼容PC、Android和IOS
|
||||
|
||||
[在线测试](https://jiebian.life/web/h5/github/recordapp.aspx),`RecordApp`源码在[/src/app-support](https://github.com/xiangyuecn/Recorder/tree/master/src/app-support)目录,当前`/app-support-sample`目录为参考配置的演示目录。`RecordApp`由`Recorder`提供基础支持,所以`Recorder`的源码也是属于`RecordApp`的一部分。
|
||||
|
||||
|
||||
# :open_book:快速使用
|
||||
|
||||
你可以通过阅读和运行[QuickStart.html](https://jiebian.life/web/h5/github/recordapp.aspx?path=/app-support-sample/QuickStart.html)文件来快速入门学习,你可以直接将 [/app-support-sample/QuickStart.html](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/QuickStart.html) 文件copy 到你的(https)网站中,然后将变量PageSet_RecordAppWxApi改成你自己的后端API地址,无需其他文件,就能正常开始测试了,App内同样适用。
|
||||
|
||||
|
||||
## 【后端】可选 - 实现后端微信接口
|
||||
RecordApp默认开启IOS端微信内的支持(可配置禁用支持),在IOS微信环境内,自动走微信JsSDK来录音,其他环境走Native、H5录音。
|
||||
|
||||
开启微信支持需要后端实现:
|
||||
- 签名接口:使用微信JsSDK需要后端提供JsSDK签名。
|
||||
- 素材下载接口:JsSDK录音完成后需要后端服务器调用微信素材接口下载录音二进制数据。
|
||||
|
||||
详细的接口文档和实现,请阅读下面的`Weixin(IOS-Weixin).Config`章节。
|
||||
|
||||
|
||||
## 【App】可选 - 实现App原生接口
|
||||
RecordApp默认未开启App内原生录音支持,可开启后在App环境中将走Native录音,其他环境走Weixin、H5录音。
|
||||
|
||||
详细的开启和实现,请阅读下面的`Native.Config`章节。
|
||||
|
||||
|
||||
## 【1】加载框架
|
||||
|
||||
**方式一**:使用script标签引入
|
||||
|
||||
``` html
|
||||
<!-- 可选的独立配置文件,提供这些文件时可免去修改app.js源码。
|
||||
【注意】:使用时应该使用自己编写的文件,而不是直接使用这个参考用的文件 -->
|
||||
<!-- 可选开启native支持的相关配置 -->
|
||||
<script src="app-support-sample/native-config.js"></script>
|
||||
<!-- 可选开启ios weixin支持的相关配置 -->
|
||||
<script src="app-support-sample/ios-weixin-config.js"></script>
|
||||
|
||||
<!-- 在需要录音功能的页面引入`app-support/app.js`文件(src内的为源码、dist内的为压缩后的)即可。
|
||||
app.js会自动加载实现文件、Recorder核心、编码引擎,应确保app.js内BaseFolder目录的正确性(参阅RecordAppBaseFolder)。
|
||||
(如何避免自动加载:使用时可以把所有支持文件全部手动引入,或者压缩时可以把所有支持文件压缩到一起,会检测到组件已加载,就不会再进行自动加载;会自动默认加载哪些文件,请查阅app.js内所有Platform的paths配置)
|
||||
(**注意:需要在https等安全环境下才能进行录音**) -->
|
||||
<script src="src/app-support/app.js"></script>
|
||||
|
||||
|
||||
<!-- 可选的扩展支持项的引入
|
||||
方法一:我们可以先直接引入Recorder核心,然后再引入扩展支持,这样会自动检测到组件已加载
|
||||
<script src="src/recorder-core.js"></script>
|
||||
<script src="src/extensions/waveview.js"></script>
|
||||
|
||||
方法二:通过注入到Default实现的paths中让RecordApp去自动加载
|
||||
<script>
|
||||
RecordApp.Platforms.Default.Config.paths.push({
|
||||
url:"src/extensions/waveview.js"
|
||||
,lazyBeforeStart:1 //开启延迟加载,在Start调用前任何时间进行加载都行
|
||||
,check:function(){return !Recorder.WaveView} //检测是否需要加载
|
||||
});
|
||||
</script>
|
||||
|
||||
方法三:直接修改app.js源码中RecordApp.Platforms.Default.Config.paths,添加需要加载的js
|
||||
-->
|
||||
```
|
||||
|
||||
**方式二**:通过import/require引入
|
||||
|
||||
通过npm进行安装 `npm install recorder-core` ,如果直接clone的源码下面文件路径调整一下即可 [](?Ref=ImportCode&Start)
|
||||
``` javascript
|
||||
/********先加载RecordApp需要用到的支持文件*********/
|
||||
//必须引入的app核心文件,换成require也是一样的。注意:app.js会自动往window下挂载名称为RecordApp对象,全局可调用window.RecordApp,也许可自行调整相关源码清除全局污染
|
||||
import RecordApp from 'recorder-core/src/app-support/app'
|
||||
//可选开启Native支持,需要引入此文件
|
||||
import 'recorder-core/src/app-support/app-native-support'
|
||||
//可选开启IOS上微信录音支持,需要引入此文件
|
||||
import 'recorder-core/src/app-support/app-ios-weixin-support'
|
||||
|
||||
//这里放置可选的独立配置文件,提供这些文件时可免去修改app.js源码。这些配置文件需要自己编写,参考https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample 目录内的这两个测试用的配置文件代码。
|
||||
//import '你的配置文件目录/native-config.js' //可选开启native支持的相关配置
|
||||
//import '你的配置文件目录/ios-weixin-config.js' //可选开启ios weixin支持的相关配置
|
||||
|
||||
/*********然后加载Recorder需要的文件***********/
|
||||
//必须引入的核心。所有文件都需要自行引入,否则app.js会尝试用script来请求需要的这些文件,进而导致错误,引入后会检测到组件已自动加载,就不会去请求了
|
||||
import 'recorder-core'
|
||||
|
||||
//需要使用到的音频格式编码引擎的js文件统统加载进来
|
||||
import 'recorder-core/src/engine/mp3'
|
||||
import 'recorder-core/src/engine/mp3-engine'
|
||||
|
||||
//由于大部分情况下ios-weixin的支持需要用到amr解码器,应当把amr引擎也加载进来
|
||||
import 'recorder-core/src/engine/beta-amr'
|
||||
import 'recorder-core/src/engine/beta-amr-engine'
|
||||
|
||||
//可选的扩展支持项
|
||||
import 'recorder-core/src/extensions/waveview'
|
||||
```
|
||||
[](?RefEnd)
|
||||
|
||||
## 【2】调用录音
|
||||
[](?Ref=Codes&Start)然后使用,假设立即运行,只录3秒,会自动根据环境使用Native录音、微信JsSDK录音、H5录音
|
||||
``` javascript
|
||||
//var dialog=createDelayDialog(); 开启可选的弹框伪代码,需先于权限请求前执行,因为回调不确定是同步还是异步的
|
||||
//请求录音权限
|
||||
RecordApp.RequestPermission(function(){
|
||||
//dialog&&dialog.Cancel(); 如果开启了弹框,此处需要取消
|
||||
|
||||
RecordApp.Start({//如果需要的组件还在延迟加载,Start调用会等待这些组件加载完成后才会调起底层平台的Start方法,可通绑定RecordApp.Current.OnLazyReady事件来确定是否已完成组件的加载,或者设置RecordApp.UseLazyLoad=false来关闭延迟加载(会阻塞Install导致RequestPermission变慢)
|
||||
type:"mp3",sampleRate:16000,bitRate:16 //mp3格式,指定采样率hz、比特率kbps,其他参数使用默认配置;注意:是数字的参数必须提供数字,不要用字符串;需要使用的type类型,需提前把支持文件到Platforms.Default内注册
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate,newBufferIdx,asyncEnd){
|
||||
//如果当前环境支持实时回调(RecordApp.Current.CanProcess()),收到录音数据时就会实时调用本回调方法
|
||||
//可利用extensions/waveview.js扩展实时绘制波形
|
||||
//可利用extensions/sonic.js扩展实时变速变调,此扩展计算量巨大,onProcess需要返回true开启异步模式
|
||||
}
|
||||
},function(){
|
||||
setTimeout(function(){
|
||||
RecordApp.Stop(function(blob,duration){//到达指定条件停止录音和清理资源
|
||||
console.log(blob,(window.URL||webkitURL).createObjectURL(blob),"时长:"+duration+"ms");
|
||||
|
||||
//已经拿到blob文件对象想干嘛就干嘛:立即播放、上传
|
||||
},function(msg){
|
||||
console.log("录音失败:"+msg);
|
||||
});
|
||||
},3000);
|
||||
},function(msg){
|
||||
console.log("开始录音失败:"+msg);
|
||||
});
|
||||
},function(msg,isUserNotAllow){//用户拒绝未授权或不支持
|
||||
//dialog&&dialog.Cancel(); 如果开启了弹框,此处需要取消
|
||||
|
||||
console.log((isUserNotAllow?"UserNotAllow,":"")+"无法录音:"+msg);
|
||||
});
|
||||
|
||||
|
||||
//我们可以选择性的弹一个对话框:为了防止当移动端浏览器使用Recorder H5录音时存在第三种情况:用户忽略,并且(或者国产系统UC系)浏览器没有任何回调
|
||||
/*伪代码:
|
||||
function createDelayDialog(){
|
||||
if(Is Mobile){//只针对移动端
|
||||
return new Alert Dialog Component
|
||||
.Message("录音功能需要麦克风权限,请允许;如果未看到任何请求,请点击忽略~")
|
||||
.Button("忽略")
|
||||
.OnClick(function(){//明确是用户点击的按钮,此时代表浏览器没有发起任何权限请求
|
||||
//此处执行fail逻辑
|
||||
console.log("无法录音:权限请求被忽略");
|
||||
})
|
||||
.OnCancel(NOOP)//自动取消的对话框不需要任何处理
|
||||
.Delay(8000); //延迟8秒显示,这么久还没有操作基本可以判定浏览器有毛病
|
||||
};
|
||||
};
|
||||
*/
|
||||
```
|
||||
[](?RefEnd)
|
||||
|
||||
## 【附】录音立即播放、上传示例
|
||||
参考[Recorder](https://github.com/xiangyuecn/Recorder)中的示例。
|
||||
|
||||
|
||||
## 【QQ群】交流与支持
|
||||
|
||||
欢迎加QQ群:781036591,纯小写口令:`recorder`
|
||||
|
||||
<img src="https://gitee.com/xiangyuecn/Recorder/raw/master/assets/qq_group_781036591.png" width="220px">
|
||||
|
||||
|
||||
## 【截图】运行效果图
|
||||
|
||||
<img src="https://gitee.com/xiangyuecn/Recorder/raw/master/assets/use_native_ios.gif" width="360px"> <img src="https://gitee.com/xiangyuecn/Recorder/raw/master/assets/use_native_android.gif" width="360px">
|
||||
|
||||
|
||||
|
||||
## 案例演示
|
||||
|
||||
### 【IOS】Hybrid App测试
|
||||
|
||||
[demo_ios](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample/demo_ios)目录内包含IOS App测试源码,和核心文件 [RecordAppJsBridge.swift](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/demo_ios/recorder/RecordAppJsBridge.swift) ,详细的原生实现、权限配置等请阅读这个目录内的README;clone后用`xcode`打开后编译运行(没有Mac OS? [装个黑苹果](https://www.jianshu.com/p/cbde4ec9f742) )。本demo为swift代码,兼容IOS 9.0+,已测试IOS 12.3。
|
||||
|
||||
**xcode测试项目clone后请修改`PRODUCT_BUNDLE_IDENTIFIER`,不然这个测试id被抢来抢去要闲置7天才能被使用,嫌弃苹果公司工程师水准**
|
||||
|
||||
|
||||
### 【Android】Hybrid App测试
|
||||
|
||||
[demo_android](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample/demo_android)目录内包含Android App测试源码,和核心文件 [RecordAppJsBridge.java](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/demo_android/app/src/main/java/com/github/xianyuecn/recorder/RecordAppJsBridge.java) ,详细的原生实现、权限配置等请阅读这个目录内的README;目录内 [app-debug.apk.zip](https://xiangyuecn.github.io/Recorder/app-support-sample/demo_android/app-debug.apk.zip) 为打包好的debug包(40kb,删掉.zip后缀),或者clone后自行用`Android Studio`编译打包。本demo为java代码,兼容API Level 15+,已测试Android 9.0。
|
||||
|
||||
### 【IOS微信】H5测试
|
||||
[<img src="https://gitee.com/xiangyuecn/Recorder/raw/master/assets/demo-recordapp.png" width="100px">](https://jiebian.life/web/h5/github/recordapp.aspx) https://jiebian.life/web/h5/github/recordapp.aspx
|
||||
|
||||
此demo页面为代理页面([源](https://xiangyuecn.github.io/Recorder/app-support-sample/)),受[微信JsSDK](https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421141115)的域名限制,直接在`github.io`上访问将导致`JsSDK`无法调用。
|
||||
|
||||
### 【IOS微信】小程序WebView测试
|
||||
[<img src="https://gitee.com/xiangyuecn/Recorder/raw/master/assets/jiebian.life-xcx.png" width="100px">](https://jiebian.life/t/a)
|
||||
|
||||
1. 在小程序页面内,找任意一个文本输入框,输入`::apitest`,然后点一下别的地方让输入框失去焦点,此时会提示`命令已处理`。
|
||||
2. 重启小程序,会发现丑陋的控制台已经显示出来了,在控制台命令区域输入`location.href="/web/h5/github/recordapp.aspx"`并运行。
|
||||
3. 不出意外就进入了上面这个在线测试页面,开始愉快的测试吧。
|
||||
|
||||
> Android微信H5、WebView支持录音,无需特殊兼容,因此上面特意针对IOS微信。
|
||||
|
||||
|
||||
|
||||
|
||||
# :open_book:仅为兼容IOS而生
|
||||
|
||||
由于IOS上除了`Safari`可以H5录音外,[其他浏览器、WebView](https://forums.developer.apple.com/thread/88052)均不能进行H5录音,Android和PC上情况好很多;可以说是仅为兼容IOS上的微信而生。
|
||||
|
||||
据[艾瑞移动设备指数](https://index.iresearch.com.cn/device)2019年7月29日数据:苹果占比`23.29%`位居第一,华为以`19.74%`排名第二。不得不向大厂低头,于是就有了此最大限度的兼容方案;由于有些开发者比较关心此问题,于是就开源了。
|
||||
|
||||
当`IOS`哪天开始全面支持`getUserMedia`录音功能时,本兼容方案就可以删除了,H5原生录音一把梭。
|
||||
|
||||
|
||||
> `RecordApp`单纯点来讲就是为了兼容IOS的,使用的复杂性比`Recorder`高了很多,到底用哪个,自己选
|
||||
|
||||
支持|[Recorder](https://github.com/xiangyuecn/Recorder/)|RecordApp
|
||||
-:|:-:|:-:
|
||||
PC浏览器|√|√
|
||||
Android Chrome Firefox|√|√
|
||||
Android微信(含小程序)|√|√
|
||||
Android Hybrid App|√|√
|
||||
Android其他浏览器|未知|未知
|
||||
IOS Safari|√|√
|
||||
IOS微信(含小程序)||√
|
||||
IOS Hybrid App||√
|
||||
IOS其他浏览器||
|
||||
开发难度|简单|复杂
|
||||
第三方依赖|无|依赖微信公众号
|
||||
|
||||
|
||||
## 使用重要前提
|
||||
|
||||
本功能并非拿来就能用的,需要对源码进行调整配置,可参考[app-support-sample](../app-support-sample)目录内的配置文件。
|
||||
|
||||
使用本功能虽然可以最大限度的兼容`Android`和`IOS`,但使用[app-ios-weixin-support.js](../src/app-support/app-ios-weixin-support.js)需要后端提供支持,如果使用[app-native-support.js](../src/app-support/app-native-support.js)需要App端提供支持,具体情况查看这两个文件内的注释。
|
||||
|
||||
如果不能得到上面相应的支持,并且坚决要使用相关功能,那将会很困难。
|
||||
|
||||
|
||||
## 支持功能
|
||||
|
||||
- 会自动加载`Recorder`,因此`Recorder`支持的功能,`RecordApp`基本上都能支持,包括语音通话聊天。
|
||||
- 优先使用`Recorder` H5进行录音,如果浏览器不支持将使用`IOS-Weixin`选项。
|
||||
- 默认开启`IOS-Weixin`支持(可配置禁用支持),用于支持IOS中微信`H5`、`小程序WebView`的录音功能,参考[ios-weixin-config.js](ios-weixin-config.js)接入配置。
|
||||
- 可选手动开启`Native`支持,用于支持IOS、Android上的Hybrid App录音,默认未开启支持,参考[native-config.js](native-config.js)开启`Native`支持配置,实现自己App的`JsBridge`接口调用;本方式优先级最高。
|
||||
|
||||
|
||||
## 限制功能
|
||||
|
||||
- `IOS-Weixin`不支持实时回调,因此当在IOS微信上录音时,实时音量反馈、实时波形、实时转码等功能不会有效果;并且微信素材下载接口下载的amr音频音质勉强能听(总比没有好,自行实现时也许可以使用它的高清接口,不过需要服务器端转码)。
|
||||
- `IOS-Weixin`使用的`微信JsSDK`单次调用录音最长为60秒,底层已屏蔽了这个限制,超时后会立即重启接续录音,因此当在IOS微信上录音时,超过60秒还未停止,将重启录音,中间可能会导致短暂的停顿感觉。
|
||||
- `demo_ios`中swift代码使用的`AVAudioRecorder`来录音,由于录音数据是通过这个对象写入文件来获取的,可能是因为存在文件写入缓存的原因,数据并非实时的flush到文件的,因此实时发送给js的数据存在300ms左右的滞后;`AudioQueue`、`AudioUnit`之类的更强大的工具文章又少,代码又多,本质上是因为不会用,所以就成这样了。
|
||||
- `Android WebView`本身是支持录音的(古董版本就算啦),仅需处理网页授权即可,但Android里面使用网页的录音权限问题可能比原生的权限机制要复杂,为了简化js端的复杂性(出问题了好甩锅),不管是Android还是IOS都实现一下可能会简单很多;另外Android和IOS的音频编码并非易事,且不易更新,使用js编码引擎大大简化App的逻辑;因此就有了Android版的Hybrid App Demo。
|
||||
|
||||
|
||||
|
||||
# :open_book:方法文档
|
||||
|
||||

|
||||
|
||||
## 【静态方法】RecordApp.RequestPermission(success,fail)
|
||||
请求录音权限,如果当前环境不支持录音或用户拒绝将调用错误回调;调用`RecordApp.Start`前需先至少调用一次此方法,用于准备好必要的环境;请求权限后如果不使用了,不管有没有调用`Start`,至少要调用一次`Stop`来清理可能持有的资源。
|
||||
|
||||
主要用于在`Start`前让用户授予权限,因为未获得权限时可能会弹出授权弹框让用户好去处理;App和大部分浏览器只需授权一次,后续就不会再弹框了;因为`Start`中已隐式包含了授权请求逻辑,对于少部分每次都会弹授权请求的浏览器,不调用本方法也能获得权限。
|
||||
|
||||
`success`: `fn()` 有权限时回调
|
||||
|
||||
`fail`: `fn(errMsg,isUserNotAllow)` 没有权限或者不能录音时回调,如果是用户主动拒绝的录音权限,除了有错误消息外,`isUserNotAllow=true`,方便程序中做不同的提示,提升用户主动授权概率
|
||||
|
||||
|
||||
## 【静态方法】RecordApp.Start(set,success,fail)
|
||||
开始录音,需先调用`RecordApp.RequestPermission`。
|
||||
|
||||
注:开始录音后如果底层支持实时返回PCM数据,将会回调`set.onProcess`事件方法,并非所有平台都支持实时回调,可以通过`RecordApp.Current.CanProcess()`方法来检测。
|
||||
|
||||
``` javascript
|
||||
set配置默认值:
|
||||
{
|
||||
type:"mp3"//最佳输出格式,如果底层实现能够支持就应当优先返回此格式
|
||||
sampleRate:16000//最佳采样率hz
|
||||
bitRate:16//最佳比特率kbps
|
||||
|
||||
onProcess:NOOP//如果当前环境支持实时回调(RecordApp.Current.CanProcess()),接收到录音数据时的回调函数:fn(buffers,powerLevel,bufferDuration,bufferSampleRate,newBufferIdx,asyncEnd),此回调和Recorder的回调行为完全一致
|
||||
|
||||
//*******高级设置******
|
||||
//,disableEnvInFix:false 内部参数,禁用设备卡顿时音频输入丢失补偿功能,如果不清楚作用请勿随意使用
|
||||
//,takeoffEncodeChunk:NOOP //fn(chunkBytes) chunkBytes=[Uint8,...]:实时编码环境下接管编码器输出,当编码器实时编码出一块有效的二进制音频数据时实时回调此方法;参数为二进制的Uint8Array,就是编码出来的音频数据片段,所有的chunkBytes拼接在一起即为完整音频。本实现的想法最初由QQ2543775048提出。此回调和Recorder的回调行为完全一致
|
||||
//加了这个回调就意味着录音环境必须支持实时回调,因此RecordApp.Current.CanProcess()==false时,Start将直接走fail回调(如IOS-Weixin环境就不支持)
|
||||
}
|
||||
注意:此对象会被修改,因为平台实现时需要把实际使用的值存入此对象
|
||||
|
||||
IOS-Weixin底层会把从微信素材下载过来的原始音频信息存储在set.DownWxMediaData中。
|
||||
```
|
||||
|
||||
`success`: `fn()` 打开录音时回调
|
||||
|
||||
`fail`: `fn(errMsg)` 开启录音出错时回调
|
||||
|
||||
|
||||
## 【静态方法】RecordApp.Stop(success,fail)
|
||||
结束录音和清理资源。
|
||||
|
||||
`success`: `fn(blob,duration)` 结束录音时回调,`blob:Blob` 录音数据`audio/mp3|wav...`格式,`duration`: `123` 音频持续时间。
|
||||
|
||||
`fail`: `fn(errMsg)` 录音出错时回调
|
||||
|
||||
如果不提供success参数=null时,将不会进行音频编码操作,只进行清理完可能持有的资源后走fail回调。
|
||||
|
||||
|
||||
## 【静态方法】RecordApp.Install(success,fail)
|
||||
对底层平台进行识别和加载相应的类库进行初始化,`RecordApp.RequestPermission`只是对此方法进行了一次封装,并且多了一个权限请求而已。如果你只想完成功能的加载,并不想调起权限请求,可手动调用此方法。此方法可以反复调用。
|
||||
|
||||
`success`: `fn()` 初始化成功回调
|
||||
|
||||
`fail`: `fn(errMsg)` 初始化失败回调
|
||||
|
||||
|
||||
|
||||
|
||||
## 【全局方法】window.top.NativeRecordReceivePCM(pcmDataBase64,sampleRate)
|
||||
开启了`Native`支持时,会有这个方法,用于原生App实时返回pcm数据。
|
||||
|
||||
此方法由Native Platform底层实现来调用,在开始录音后,需调用此方法传递数据给js。
|
||||
|
||||
`pcmDataBase64`: `Int16[] Base64` 当前单声道录音缓冲PCM片段Base64编码,正常情况下为上次回调本接口开始到现在的录音数据
|
||||
|
||||
`sampleRate` 缓冲PCM的采样率
|
||||
|
||||
|
||||
## 【静态属性】RecordApp.UseLazyLoad
|
||||
默认为`true`开启部分非核心组件的延迟加载,不会阻塞`Install`,`Install`后通过`RecordApp.Current.OnLazyReady`事件来确定组件是否已全部加载;如果设为`false`,将忽略组件的延迟加载属性,`Install`时会将所有组件一次性加载完成后才会`Install`成功。
|
||||
|
||||
此配置只有在组件是通过RecordApp自动加载时才会有效,如果组件是手动引入的时不会生效;会影响的组件有:`RecordApp.Platforms`的`Config.paths`中标记了`lazyBeforeStart=1`、`lazyBeforeStop=1`的js;`lazyBeforeStart`标记的js会在`Start`调用前完成加载,否则会阻塞`Start`,`lazyBeforeStop`标记的js会在`Stop`调用前完成加载,否则会阻塞`Stop`。
|
||||
|
||||
|
||||
## 【静态属性】RecordApp.Current
|
||||
为`RecordApp.Install`初始化后识别到的底层平台,取值为`RecordApp.Platforms`之一。
|
||||
|
||||
## 【静态方法】RecordApp.Current.OnLazyReady(fn)
|
||||
绑定一个函数,在所有延迟加载的组件加载完成后回调,受`RecordApp.UseLazyLoad`属性的影响,此回调的调用时机是不一样的:开启延迟加载后,OnLazyReady会在Install完成后,所有组件加载完成时调用;关闭延迟加载后,OnLazyReady会在Install完成前调用。
|
||||
|
||||
`fn`: `fn(errMsg)` 提供一个回调函数,参数为错误信息,如果错误信息为空代表没有错误,否则代表组件有加载失败,可再次请求权限会尝试重新加载组件。
|
||||
|
||||
## 【静态方法】RecordApp.Current.CanProcess()
|
||||
识别的底层平台是否支持实时返回PCM数据,如果返回值为true,`set.onProcess`将可以被实时回调。
|
||||
|
||||
## 【静态方法】RecordApp.GetStartUsedRecOrNull()
|
||||
获取底层平台录音过程中会使用用来处理实时数据的Recorder对象实例rec,如果底层录音过程中不实用Recorder进行数据的实时处理,将返回null。除了微信平台外,其他平台均会返回rec,但Start调用前和Stop调用后均会返回null,只有Start后和Stop彻底完成前之间才会返回rec。
|
||||
|
||||
rec中的方法不一定都能使用,主要用来获取内部缓冲用的,比如:实时清理缓冲,当缓冲被清理,Stop时永远会走fail回调。
|
||||
|
||||
## 【静态属性】RecordApp.Platforms
|
||||
支持的平台列表,目前有三个:
|
||||
1. `Native`: 原生App平台支持,底层由实际的`JsBridge`提供,此平台默认未开启
|
||||
2. `IOS-Weixin`: IOS微信`浏览器`、`小程序web-view`支持,底层使用的`微信JsSDK` `+` `Recorder`,此平台默认开启
|
||||
3. `Default`: H5原生支持,底层使用的`Recorder H5`,此平台默认开启且不允许关闭,其他平台需要此平台提供支持
|
||||
|
||||
|
||||
|
||||
# :open_book:底层平台配置和实现
|
||||
底层平台为`RecordApp.Platforms`中定义的值。
|
||||
|
||||
|
||||
## 统一实现参考
|
||||
每个底层平台都实现了三个方法,`Native`在[app-native-support.js](https://github.com/xiangyuecn/Recorder/blob/master/src/app-support/app-native-support.js)中实现了,`IOS-Weixin`在[app-ios-weixin-support.js](https://github.com/xiangyuecn/Recorder/blob/master/src/app-support/app-ios-weixin-support.js)中实现了,`Default`在[app.js](https://github.com/xiangyuecn/Recorder/blob/master/src/app-support/app.js)中实现了。
|
||||
|
||||
### platform.RequestPermission(success,fail)
|
||||
本底层具体的权限请求实现,参数和`RecordApp.RequestPermission`相同。
|
||||
|
||||
### platform.Start(set,success,fail)
|
||||
本底层具体的开始录音实现,参数和`RecordApp.Start`相同。
|
||||
|
||||
### platform.Stop(success,fail)
|
||||
本底层具体的开始录音实现,参数和`RecordApp.Stop`相同。
|
||||
|
||||
|
||||
## 【使用前需修改】配置
|
||||
每个底层平台都有一个`platform.Config`配置,这个配置是根据平台的需要什么我们这里面就要给什么;每个`platform.Config`内都有一个`paths`数组,里面包含了此平台初始化时需要加载的相关的实现文件、Recorder核心、编码引擎,可修改这些数组加载自己需要的格式编码引擎。另外还有一个全局配置`RecordAppBaseFolder`。
|
||||
|
||||
### 【全局变量】window.RecordAppBaseFolder
|
||||
可提供文件基础目录`BaseFolder`,用来自动定位加载类库,此目录可以是`src/`或者`/dist/`,必须`/`结尾;目录内应该包含`recorder-core.js、engine`等。实际取值需自行根据自己的网站目录调整,或者加载`app.js`前,设置此全局变量。
|
||||
|
||||
### 【Event】window.OnRecordAppInstalled()
|
||||
可提供这个全局的回调函数用来配置`RecordApp`,在`app.js`内代码执行完毕时会尝试回调此方法,免得`RecordAppBaseFolder`要在`app.js`之前定义,其他配置又要在之后定义的麻烦。使用可以参考[app-support-sample/ios-weixin-config.js](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/ios-weixin-config.js)配置。
|
||||
|
||||
|
||||
### 【配置】RecordApp.Platforms.Default.Config
|
||||
此为默认的H5原生录音实现配置,配置内定义了对Recorder库和编码引擎的加载,可修改配置内的paths来添加自动加载扩展js。由于其他平台都需要此平台进行支持,因此修改这个配置会影响其他平台。
|
||||
|
||||
|
||||
### 【配置】RecordApp.Platforms.Native.Config
|
||||
修改这个配置会有点复杂,可以参考[app-support-sample/native-config.js](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/native-config.js)中的演示有效的配置。
|
||||
|
||||
使用App原生录音,必需提供配置中的`IsApp`、`JsBridgeRequestPermission`、`JsBridgeStart`、`JsBridgeStop`方法,具体情况请查阅[src/app-support/app.js](https://github.com/xiangyuecn/Recorder/blob/master/src/app-support/app.js)内有详细的说明。
|
||||
|
||||
|
||||
### 【配置】RecordApp.Platforms.Weixin(IOS-Weixin).Config
|
||||
修改这个配置会有点复杂,可以参考[app-support-sample/ios-weixin-config.js](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/ios-weixin-config.js)中的演示有效的配置。
|
||||
|
||||
使用微信录音,必需提供配置中的`WxReady`、`DownWxMedia`方法,可选提供`Enable`方法,具体情况请查阅[src/app-support/app.js](https://github.com/xiangyuecn/Recorder/blob/master/src/app-support/app.js)内有详细的说明。
|
||||
|
||||
- `Enable`: 回调返回是否要启用微信支持,本方法是可选的,默认启用支持。
|
||||
- `WxReady`: 对使用到的[微信JsSDK进行签名](https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421141115),至少要包含`startRecord,stopRecord,onVoiceRecordEnd,uploadVoice`接口。签名操作需要后端支持。
|
||||
- `DownWxMedia`: 对[微信录音素材进行下载](https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1444738727),下载操作需要后端支持。
|
||||
|
||||
以上两个方法都是公众(订阅)号开发范畴,需要注册开通相应的微信服务账号。
|
||||
|
||||
|
||||
|
||||
# :star:捐赠
|
||||
如果这个库有帮助到您,请 Star 一下。
|
||||
|
||||
您也可以使用支付宝或微信打赏作者:
|
||||
|
||||
 
|
||||
50
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/README.md
generated
vendored
Normal file
@ -0,0 +1,50 @@
|
||||
[Recorder](https://github.com/xiangyuecn/Recorder/) | [RecordApp](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample)
|
||||
|
||||
# :open_book:Android Hybrid App
|
||||
|
||||
目录内包含Android App测试源码,和核心文件 [RecordAppJsBridge.java](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/demo_android/app/src/main/java/com/github/xianyuecn/recorder/RecordAppJsBridge.java) ;目录内 [app-debug.apk.zip](https://xiangyuecn.github.io/Recorder/app-support-sample/demo_android/app-debug.apk.zip) 为打包好的debug包(40kb,删掉.zip后缀),或者clone后自行用`Android Studio`编译打包。本demo为java代码,兼容API Level 15+,已测试Android 9.0。
|
||||
|
||||
本Demo是对[/app-support-sample/native-config.js](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/native-config.js)配置示例中定义的JsBridge接口的实现。
|
||||
|
||||
可以直接copy目录内`RecordAppJsBridge.java`使用,此文件为核心文件,其他文件都是没什么价值的;支持新开发WebView界面,或对已有的WebView实例升级支持RecordApp。
|
||||
|
||||
|
||||
## 【截图】
|
||||

|
||||
|
||||
|
||||
## 【限制】
|
||||
|
||||
- 虽然兼容API Level 15+,但实际上17+才是好的选择,因为addJavascriptInterface
|
||||
- 古董WebView(21-)虽然能正常接收PCM和进行MP3、WAV编码,但对Blob对象的播放不一定能良好支持
|
||||
|
||||
|
||||
|
||||
# :open_book:原理
|
||||
|
||||
通过addJavascriptInterface往WebView注入一个全局对象`RecordAppJsBridge`,js中通过`window.RecordAppJsBridge`来访问,只有存在这个对象,就代表是在App中;js通过这个对象的`request`方法和java进行数据的交互。
|
||||
|
||||
|
||||
## 数据交互
|
||||
java收到js发起的`RecordAppJsBridge.request`请求,解析请求数据参数,并调用参数中接口对应的java方法,同步执行完后把数据返回给js,如果方法是异步的,将在异步操作完成后java将调用网页的js方法`AppJsBridgeRequest.Call`将数据异步返回。
|
||||
|
||||
|
||||
## 录音接口
|
||||
接口对应的方法使用的`AudioRecord`来录音,`AudioRecord`使用稳健的44100采样率进行音频采集,我们实时接收PCM数据并进行采样率的转换,然后调用`AppJsBridgeRequest.Record`把数据返回给js端即可完成完整的录音功能。
|
||||
|
||||
Android端的录音还算完美,比IOS的轻松很多。
|
||||
|
||||
|
||||
## 需要权限
|
||||
1. `android.permission.RECORD_AUDIO`
|
||||
2. `android.permission.MODIFY_AUDIO_SETTINGS`
|
||||
|
||||
|
||||
## 如何接入使用
|
||||
请阅读[RecordAppJsBridge.java](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/demo_android/app/src/main/java/com/github/xianyuecn/recorder/RecordAppJsBridge.java)文件开头的注释文档,可直接copy此文件到你的项目中使用;支持新开发WebView界面,或对已有的WebView实例升级支持RecordApp。
|
||||
|
||||
|
||||
## 为什么不用UserAgent来识别App环境
|
||||
|
||||
通过修改WebView的UA来让H5、服务器判断是不是在App里面运行的,此方法非常简单而且实用。但有一个致命缺陷,当UA数据很敏感的场景下,虽然方便了我方H5、服务器来识别这个App,但也同时也暴露给了任何在此WebView中发起的请求,不可避免的会将我们的标识信息随请求而发送给第三方(虽然可通过额外编程把信息抹掉,但代价太大了)。IOS不动UA基本上别人的服务器几乎不太可能识别出我们的App,Android神一样的把包名添加到了X-Requested-With请求头中,还能不能讲理了。
|
||||
|
||||
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/app-debug.apk.zip
generated
vendored
Normal file
18
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/app/build.gradle
generated
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
compileSdkVersion 28
|
||||
defaultConfig {
|
||||
applicationId "com.github.xianyuecn.recorder"
|
||||
minSdkVersion 15
|
||||
targetSdkVersion 28
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
resValue "string","app_build_time",System.currentTimeMillis().toString()
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
}
|
||||
35
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/app/src/main/AndroidManifest.xml
generated
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.github.xianyuecn.recorder">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
|
||||
<application
|
||||
android:icon="@drawable/icon"
|
||||
android:label="Recorder Demo"
|
||||
android:allowBackup="true"
|
||||
android:supportsRtl="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<meta-data android:name="android.max_aspect" android:value="9.9"/>
|
||||
|
||||
<activity android:name=".MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="screenLayout|screenSize|keyboardHidden|orientation"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
362
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/app/src/main/java/com/github/xianyuecn/recorder/MainActivity.java
generated
vendored
Normal file
@ -0,0 +1,362 @@
|
||||
package com.github.xianyuecn.recorder;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Bitmap;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.webkit.ConsoleMessage;
|
||||
import android.webkit.PermissionRequest;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Toast;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/* 录音Hybrid App Demo界面 https://github.com/xiangyuecn/Recorder
|
||||
没什么有用的东西 */
|
||||
public class MainActivity extends Activity {
|
||||
static private final String LogTag="MainActivity";
|
||||
|
||||
private WebView webView;
|
||||
private EditText logs;
|
||||
private RecordAppJsBridge jsBridge;
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
if(Build.VERSION.SDK_INT>=26 && Build.VERSION.SDK_INT<=27){
|
||||
//android 8.x 透明+竖屏居然被脑残限制,印度阿三的脑洞吗? https://blog.csdn.net/starry_eve/article/details/82777160
|
||||
killAndroid8x();
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
|
||||
|
||||
webView = findViewById(R.id.main_webview);
|
||||
logs=findViewById(R.id.main_logs);
|
||||
|
||||
//******调用核心方法*********************
|
||||
//注入JsBridge, 实现api接口,简单demo无视4.2以下版本
|
||||
jsBridge=new RecordAppJsBridge(this, webView, permissionReq, Log);
|
||||
//*******以下内容无关紧要*****************
|
||||
|
||||
final String cmds="支持命令(首行输入,不含引号):\n`:url:网址::`导航到此地址\n`:reload:::`重新加载当前页面\n`:js:js代码::`执行js";
|
||||
logs.append("日志输出已开启\n"+cmds);
|
||||
|
||||
//设置基本信息,如开启js、storage、权限处理
|
||||
initWebSet();
|
||||
|
||||
logs.addTextChangedListener(new TextWatcher() {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
if(count>0 && s.charAt(0)==':'){
|
||||
String txt="\n\n命令已执行,"+cmds+"\n\n";
|
||||
Pattern exp=Pattern.compile("^:(.+?):(.*)::");
|
||||
Matcher m=exp.matcher(logs.getText());
|
||||
if(m.find()){
|
||||
switch(m.group(1)){
|
||||
case "url":
|
||||
webView.loadUrl(m.group(2));
|
||||
break;
|
||||
case "reload":
|
||||
webView.reload();
|
||||
break;
|
||||
case "js":
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
webView.evaluateJavascript(m.group(2), null);
|
||||
}else{
|
||||
webView.loadUrl("javascript:"+m.group(2));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
txt+=",未知命令"+m.group(1);
|
||||
break;
|
||||
}
|
||||
|
||||
txt+="\n"+logs.getText();
|
||||
logs.setText(txt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {}
|
||||
});
|
||||
|
||||
//app编译时间
|
||||
SimpleDateFormat dateformat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
|
||||
Log.i(LogTag, "App编译时间:"+dateformat.format(Long.parseLong(getResources().getString(R.string.app_build_time))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
//权限处理 小功能就不用我的Android-UsesPermission库了,手撸一个简洁版的
|
||||
private RecordAppJsBridge.UsesPermission permissionReq= new RecordAppJsBridge.UsesPermission() {
|
||||
@Override
|
||||
public void Request(String[] keys, final Runnable True, final Runnable False) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
boolean has=true;
|
||||
for(String k : keys){
|
||||
if( MainActivity.this.checkSelfPermission(k) != PackageManager.PERMISSION_GRANTED){
|
||||
has=false;
|
||||
}
|
||||
}
|
||||
if(has){
|
||||
//已授权,已知requestPermissions调用会导致webview长按录音时会打断touch事件,提早检测早退出
|
||||
True.run();
|
||||
return;
|
||||
}
|
||||
|
||||
PermissionCall=new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if(HasPermission) {
|
||||
True.run();
|
||||
}else{
|
||||
//无权限
|
||||
False.run();
|
||||
}
|
||||
}
|
||||
};
|
||||
MainActivity.this.requestPermissions(keys, ReqCode);
|
||||
}else{
|
||||
//无需授权
|
||||
True.run();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private RecordAppJsBridge.ILog Log=new RecordAppJsBridge.ILog() {
|
||||
@Override
|
||||
public void i(String tag, String msg) {
|
||||
android.util.Log.i(tag, msg);
|
||||
|
||||
print("[i]["+tag+"]"+msg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void e(String tag, String msg) {
|
||||
android.util.Log.e(tag, msg);
|
||||
|
||||
print("[e]["+tag+"]"+msg);
|
||||
}
|
||||
|
||||
|
||||
StringBuffer msgs=new StringBuffer();
|
||||
int waitInt=0;
|
||||
private void print(String msg){
|
||||
msgs.append("\n\n[").append(time()).append("]").append(msg);
|
||||
|
||||
//延迟在主线程更新日志文本框
|
||||
if(waitInt==0) {
|
||||
waitInt = RecordAppJsBridge.ThreadX.SetTimeout(500, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
waitInt=0;
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
CharSequence txt=logs.getText();
|
||||
if(txt.length()>250*1024){
|
||||
txt=txt.subSequence(txt.length()-200*1024, txt.length());
|
||||
}
|
||||
txt=txt+msgs.toString();
|
||||
msgs.setLength(0);
|
||||
|
||||
logs.setText(txt);
|
||||
logs.setSelection(txt.length(),txt.length());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
private String time(){
|
||||
SimpleDateFormat formatter=new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
|
||||
return formatter.format(new Date());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
jsBridge.close();
|
||||
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* WebView基本信息设置
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private void initWebSet(){
|
||||
WebSettings set=webView.getSettings();
|
||||
set.setJavaScriptEnabled(true);
|
||||
set.setDefaultTextEncodingName("utf-8");
|
||||
set.setDomStorageEnabled(true);
|
||||
|
||||
File cacheDir=getExternalCacheDir();
|
||||
File fileDir=getExternalFilesDir(null);
|
||||
if(cacheDir==null){
|
||||
cacheDir=getCacheDir();
|
||||
}
|
||||
if(fileDir==null){
|
||||
fileDir=getFilesDir();
|
||||
}
|
||||
set.setAppCacheEnabled(true);
|
||||
set.setAppCachePath(cacheDir.getAbsolutePath());
|
||||
set.setDatabaseEnabled(true);
|
||||
set.setDatabasePath(fileDir.getAbsolutePath());
|
||||
set.setGeolocationEnabled(true);
|
||||
set.setGeolocationDatabasePath(fileDir.getAbsolutePath());
|
||||
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
set.setMediaPlaybackRequiresUserGesture(false);
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
set.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
||||
}
|
||||
|
||||
webView.setWebViewClient(new WebViewClient(){
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, String url) {
|
||||
return !url.startsWith("http");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageStarted(WebView view, String url, Bitmap favicon) {
|
||||
super.onPageStarted(view, url, favicon);
|
||||
Log.i(LogTag, "打开网页:"+url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
|
||||
super.onReceivedError(view, errorCode, description, failingUrl);
|
||||
Log.e(LogTag, "打开网页失败:"+description+" url:"+failingUrl);
|
||||
}
|
||||
});
|
||||
|
||||
//网页权限请求处理
|
||||
webView.setWebChromeClient(new WebChrome());
|
||||
|
||||
webView.setBackgroundColor(0xffff6600);
|
||||
|
||||
String url="https://xiangyuecn.github.io/Recorder/app-support-sample/";
|
||||
webView.loadUrl(url);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 录音权限处理
|
||||
*/
|
||||
public class WebChrome extends WebChromeClient{
|
||||
@Override
|
||||
public void onPermissionRequest(final PermissionRequest request) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
String key="";
|
||||
final String[] types=request.getResources();
|
||||
for(String s:types) {
|
||||
if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(s)) {
|
||||
key="android.permission.RECORD_AUDIO";
|
||||
}
|
||||
}
|
||||
|
||||
if(key.length()==0){
|
||||
request.deny();
|
||||
return;
|
||||
}
|
||||
|
||||
permissionReq.Request(new String[]{key}, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
request.grant(types);
|
||||
}
|
||||
}, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
request.deny();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onConsoleMessage(ConsoleMessage msg) {
|
||||
switch (msg.messageLevel()){
|
||||
case ERROR:
|
||||
Log.e("Console", msg.sourceId()+":"+msg.lineNumber()+"\n"+msg.message());
|
||||
break;
|
||||
default:
|
||||
Log.i("Console", msg.message());
|
||||
}
|
||||
|
||||
return super.onConsoleMessage(msg);
|
||||
}
|
||||
}
|
||||
private Runnable PermissionCall;
|
||||
private boolean HasPermission;
|
||||
|
||||
static private final int ReqCode=123;
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
|
||||
if(requestCode==ReqCode && PermissionCall!=null) {
|
||||
StringBuilder noGrant=new StringBuilder();
|
||||
for(int i=0; i<permissions.length;i++){
|
||||
String item=permissions[i];
|
||||
if(grantResults[i]!= PackageManager.PERMISSION_GRANTED){
|
||||
if(noGrant.length()>0)noGrant.append(",");
|
||||
noGrant.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
HasPermission=noGrant.length()==0;
|
||||
if(!HasPermission){
|
||||
Toast.makeText(MainActivity.this, "请给app权限:"+noGrant, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
PermissionCall.run();
|
||||
PermissionCall=null;
|
||||
}
|
||||
}
|
||||
|
||||
private void killAndroid8x(){
|
||||
try {
|
||||
String sadiaoName="mActivityInfo";
|
||||
Field field = Activity.class.getDeclaredField(sadiaoName);
|
||||
field.setAccessible(true);
|
||||
ActivityInfo o = (ActivityInfo)field.get(this);
|
||||
o.screenOrientation = -1;
|
||||
field.setAccessible(false);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
855
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/app/src/main/java/com/github/xianyuecn/recorder/RecordAppJsBridge.java
generated
vendored
Normal file
@ -0,0 +1,855 @@
|
||||
package com.github.xianyuecn.recorder;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioRecord;
|
||||
import android.media.MediaRecorder;
|
||||
import android.os.Build;
|
||||
import android.util.Base64;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.WebView;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
/* 录音Hybrid App核心支持文件 https://github.com/xiangyuecn/Recorder
|
||||
|
||||
【使用】
|
||||
1. copy本文件即可使用,其他文件都是多余的
|
||||
2. 在manifest中配置麦克风的权限声明:RECORD_AUDIO、MODIFY_AUDIO_SETTINGS
|
||||
3. 把WebView传进来进行对象注入;实现一个录音权限请求接口;实现一个Log接口,简单内部调用一下android.util.Log.i|e即可
|
||||
4. 调用close进行清理资源和销毁对象
|
||||
|
||||
【为什么不用UserAgent来识别App环境】
|
||||
通过修改WebView的UA来让H5、服务器判断是不是在App里面运行的,此方法非常简单而且实用。但有一个致命缺陷,当UA数据很敏感的场景下,虽然方便了我方H5、服务器来识别这个App,但也同时也暴露给了任何在此WebView中发起的请求,不可避免的会将我们的标识信息随请求而发送给第三方(虽然可通过额外编程把信息抹掉,但代价太大了)。IOS不动UA基本上别人的服务器几乎不太可能识别出我们的App,Android神一样的把包名添加到了X-Requested-With请求头中,还能不能讲理了。
|
||||
*/
|
||||
public class RecordAppJsBridge implements Closeable {
|
||||
//js中定义的名称
|
||||
static private final String JsBridgeName="RecordAppJsBridge";
|
||||
static private final String JsRequestName="AppJsBridgeRequest";
|
||||
|
||||
//默认会把录音数据额外存储到app外部存储的cache目录中,仅供分析调试之用
|
||||
static private final boolean SavePCM_ToLogFile=true;
|
||||
|
||||
static private final String LogTag="RecordAppJsBridge";
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 录音JsBridge,本文件定义了js如何和Android交互,和js可以调用的接口列表
|
||||
* @param webView 要注入的webview
|
||||
* @param usesPermission 权限由外部调用进行处理,本文件不处理这种渣渣
|
||||
* @param log 一个可控的log
|
||||
*/
|
||||
@SuppressLint({"AddJavascriptInterface", "JavascriptInterface"})
|
||||
public RecordAppJsBridge(Context context, WebView webView, UsesPermission usesPermission, ILog log){
|
||||
this.context=context;
|
||||
this.webView=webView;
|
||||
this.usesPermission=usesPermission;
|
||||
this.Log=log;
|
||||
jsObject=new JsObject();
|
||||
|
||||
//注入js对象
|
||||
webView.addJavascriptInterface(jsObject, JsBridgeName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private Context context;
|
||||
private WebView webView;
|
||||
private UsesPermission usesPermission;
|
||||
private ILog Log;
|
||||
private JsObject jsObject;
|
||||
@Override
|
||||
public void close() {
|
||||
context=null;
|
||||
webView=null;
|
||||
usesPermission=null;
|
||||
jsObject=null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 执行js代码
|
||||
*/
|
||||
public void runScript(final String code){
|
||||
webView.post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
webView.evaluateJavascript(code, null);
|
||||
}else {
|
||||
webView.loadUrl("javascript:" + code);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
public class JsObject {
|
||||
/**
|
||||
* 只需提供一个方法,接受请求,然后同步或异步返回响应
|
||||
*/
|
||||
@JavascriptInterface
|
||||
public String request(String data) {
|
||||
JSONObject json;
|
||||
try {
|
||||
json = new JSONObject(data);
|
||||
} catch (JSONException e) {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
response.put("status", "");
|
||||
response.put("message", "请求数据json无效:" + e.getMessage());
|
||||
}catch (JSONException ig){
|
||||
//NOOP
|
||||
}
|
||||
return response.toString();
|
||||
}
|
||||
|
||||
|
||||
JSONObject response = new Request(RecordAppJsBridge.this, json).exec();
|
||||
return response.toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//**********工具类****************************
|
||||
public interface UsesPermission {
|
||||
/**
|
||||
* 请求用户权限,如果keys全部有权限,回调True,任何一个没有权限回调False
|
||||
*/
|
||||
void Request(String[] keys, Runnable True, Runnable False);
|
||||
}
|
||||
public interface ILog {
|
||||
void i(String tag, String msg);
|
||||
void e(String tag, String msg);
|
||||
}
|
||||
public interface Callback<T,V> {
|
||||
T Call(V result, Exception hasError);
|
||||
}
|
||||
static private void JSONSet(JSONObject json, String key, Object val){
|
||||
try{
|
||||
json.put(key, val);
|
||||
}catch (JSONException e){
|
||||
//NOOP
|
||||
}
|
||||
}
|
||||
static private JSONObject GetJSONObject(JSONObject json, String key){
|
||||
JSONObject rtv=json.optJSONObject(key);
|
||||
if(rtv==null){
|
||||
return new JSONObject();
|
||||
}
|
||||
return rtv;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//*****Request.java**处理js请求**************************
|
||||
static private class Request{
|
||||
//接口命名规则:同步方法加Sync前缀,异步加Async,这里几个就手写调用一下,量多用反射调用
|
||||
public JSONObject exec(){
|
||||
try {
|
||||
switch (action) {
|
||||
case "recordPermission":
|
||||
isAsync=true;
|
||||
RecordApis.Async_recordPermission(this);
|
||||
break;
|
||||
case "recordStart":
|
||||
isAsync=true;
|
||||
RecordApis.Async_recordStart(this);
|
||||
break;
|
||||
case "recordStop":
|
||||
isAsync=true;
|
||||
RecordApis.Async_recordStop(this);
|
||||
break;
|
||||
case "recordAlive":
|
||||
isAsync=false;
|
||||
RecordApis.Sync_recordAlive(this);
|
||||
break;
|
||||
default:
|
||||
jsBridge.Log.e(LogTag, "request." + action + "不存在");
|
||||
JSONSet(json, "message", "request." + action + "不存在");
|
||||
__callback(true);
|
||||
return json;
|
||||
}
|
||||
}catch (Exception e){
|
||||
jsBridge.Log.e(LogTag,"request."+action+"执行出错: "+e.getMessage());
|
||||
JSONSet(json,"message", "request."+action+"执行出错");
|
||||
__callback(true);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Request(RecordAppJsBridge jsBridge, JSONObject msg){
|
||||
this.jsBridge=jsBridge;
|
||||
try {
|
||||
this.args = GetJSONObject(msg, "args");
|
||||
this.action = msg.optString("action");
|
||||
|
||||
json = new JSONObject();
|
||||
json.put("status", "");
|
||||
json.put("message", "");
|
||||
json.put("action", action);
|
||||
json.put("callback", msg.optString("callback"));
|
||||
} catch (JSONException ig){
|
||||
//NOOP
|
||||
}
|
||||
}
|
||||
public RecordAppJsBridge jsBridge;
|
||||
private JSONObject json;
|
||||
public JSONObject args;
|
||||
private String action;
|
||||
private boolean isAsync;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 设置接口返回的value,并设置status为success
|
||||
*/
|
||||
public void setValue(Object val){
|
||||
JSONSet(json,"status", "success");
|
||||
_setVal(val);
|
||||
}
|
||||
private void _setVal(Object val){
|
||||
JSONSet(json,"value", val);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅仅设置返回的message数据
|
||||
*/
|
||||
public void setMsg(String msg){
|
||||
JSONSet(json,"message", msg);
|
||||
}
|
||||
/**
|
||||
* 异步方法调用最终执行完毕时调用,异步方法专用
|
||||
*/
|
||||
public void callback(Object val, String errOrNull){
|
||||
if(errOrNull!=null){
|
||||
_setVal(val);
|
||||
setMsg(errOrNull);
|
||||
}else{
|
||||
setValue(val);
|
||||
}
|
||||
|
||||
__callback(false);
|
||||
}
|
||||
private void __callback(boolean isExecCall){
|
||||
jsBridge.runScript(JsRequestName+".Call(" + json.toString() + ");");
|
||||
|
||||
if(!isExecCall && !isAsync){
|
||||
jsBridge.Log.e(LogTag,action+"不是异步方法,但调用了回调");
|
||||
}
|
||||
if(isSend){
|
||||
jsBridge.Log.e(LogTag,action+"重复回调");
|
||||
}
|
||||
isSend=true;
|
||||
}
|
||||
private boolean isSend=false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//*****RecordApis.java*****************************************
|
||||
static public class RecordApis {
|
||||
static private final String LogTag="RecordApis";
|
||||
|
||||
static public void Async_recordPermission(final Request req) {
|
||||
checkPermission(req, new Callback<Object, Object>() {
|
||||
public Object Call(Object result, Exception hasError) {
|
||||
if(result!=null){
|
||||
req.callback(1, null);
|
||||
}else{
|
||||
req.callback(3, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
static private void checkPermission(final Request req, final Callback<Object, Object> callback){
|
||||
req.jsBridge.usesPermission.Request(new String[]{"android.permission.RECORD_AUDIO"}, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.Call("ok", null);
|
||||
}
|
||||
}, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
req.jsBridge.Log.e(LogTag, "用户拒绝了录音权限");
|
||||
callback.Call(null, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
static public void Sync_recordAlive(Request req){
|
||||
if(Current!=null){
|
||||
Current.alive();
|
||||
req.setValue(null);
|
||||
}else{
|
||||
req.setMsg("未开始任何录音");
|
||||
}
|
||||
}
|
||||
static public void Async_recordStart(final Request req){
|
||||
DestroyCurrent();
|
||||
|
||||
checkPermission(req, new Callback<Object, Object>() {
|
||||
public Object Call(Object result, Exception hasError) {
|
||||
if(result==null){
|
||||
req.callback(null, "没有录音权限");
|
||||
return null;
|
||||
}
|
||||
|
||||
ThreadX.Run(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
_Start(req);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
static private void _Start(final Request req){
|
||||
JSONObject param=GetJSONObject(req.args, "param");
|
||||
int sampleRate=param.optInt("sampleRate");
|
||||
if(sampleRate==0){
|
||||
sampleRate=16000;
|
||||
}
|
||||
|
||||
new RecordApis().init(req.jsBridge, sampleRate, new Callback<Object, Object>() {
|
||||
public Object Call(Object result, Exception hasError) {
|
||||
if(hasError!=null){
|
||||
req.jsBridge.Log.e(LogTag, "开始录音失败"+hasError.toString());
|
||||
req.callback(null, "无法开始录音:"+hasError.getMessage());
|
||||
DestroyCurrent();
|
||||
return null;
|
||||
}
|
||||
req.callback(new JSONObject(), null);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
static public void Async_recordStop(final Request req){
|
||||
if(Current==null){
|
||||
req.callback(null, "未开始任何录音");
|
||||
return;
|
||||
}
|
||||
|
||||
Current.stop(new Callback<Object, Object>(){
|
||||
public Object Call(Object result, Exception hasError) {
|
||||
if(hasError!=null){
|
||||
req.jsBridge.Log.e(LogTag, "停止录音失败"+hasError.toString());
|
||||
req.callback(null, "结束录音出错:"+hasError.getMessage());
|
||||
DestroyCurrent();//成功的不要Destroy还要打log
|
||||
return null;
|
||||
}
|
||||
req.callback(new JSONObject(), null);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
static private void DestroyCurrent(){
|
||||
if(Current!=null){
|
||||
Current.destroy();
|
||||
}
|
||||
}
|
||||
static private RecordApis Current;
|
||||
static private final int SampleRate=44100;
|
||||
|
||||
|
||||
|
||||
|
||||
synchronized private void init(RecordAppJsBridge main_, int sampleRateReq, Callback<Object, Object> ready){
|
||||
Current=this;
|
||||
this.main=main_;
|
||||
this.logData= RecordAppJsBridge.SavePCM_ToLogFile;
|
||||
this.sampleRateReq=sampleRateReq;
|
||||
|
||||
if(logData){
|
||||
logStreamFull=new ByteArrayOutputStream();
|
||||
logStreamVal=new ByteArrayOutputStream();
|
||||
}
|
||||
try {
|
||||
rec = new AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC
|
||||
, SampleRate
|
||||
, AudioFormat.CHANNEL_IN_MONO
|
||||
, AudioFormat.ENCODING_PCM_16BIT
|
||||
, AudioRecord.getMinBufferSize(
|
||||
SampleRate
|
||||
, AudioFormat.CHANNEL_IN_MONO
|
||||
, AudioFormat.ENCODING_PCM_16BIT)
|
||||
);
|
||||
|
||||
rec.startRecording();
|
||||
}catch (Exception e){
|
||||
ready.Call(null, e);
|
||||
return;
|
||||
}
|
||||
|
||||
if(rec.getRecordingState()!=AudioRecord.RECORDSTATE_RECORDING){
|
||||
ready.Call(null, new Exception("开启录音失败"));
|
||||
return;
|
||||
}
|
||||
|
||||
ready.Call(null, null);
|
||||
|
||||
isRec=true;
|
||||
alive();
|
||||
main.Log.i(LogTag, "开始录音:"+sampleRateReq);
|
||||
startTime=System.currentTimeMillis();
|
||||
|
||||
ThreadX.Run(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if(isRec) {//Sync Check
|
||||
readThread = Thread.currentThread();
|
||||
try {
|
||||
readAsync();
|
||||
} catch (Exception e) {
|
||||
if(main!=null) {//Sync Check
|
||||
main.Log.e(LogTag, "录音中途出现异常:" + e.toString());
|
||||
}
|
||||
}
|
||||
readThread = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
private RecordAppJsBridge main;
|
||||
private int sampleRateReq;
|
||||
private AudioRecord rec;
|
||||
private boolean isRec;
|
||||
private int aliveInt;
|
||||
private long startTime;
|
||||
private Thread readThread;
|
||||
|
||||
private boolean logData;
|
||||
private ByteArrayOutputStream logStreamFull;
|
||||
private ByteArrayOutputStream logStreamVal;
|
||||
|
||||
synchronized private void destroy(){
|
||||
Current=null;
|
||||
|
||||
isRec=false;
|
||||
main=null;
|
||||
|
||||
ThreadX.ClearTimeout(aliveInt);
|
||||
|
||||
if(readThread!=null){
|
||||
readThread.interrupt();
|
||||
readThread=null;
|
||||
}
|
||||
|
||||
if(logStreamFull!=null){
|
||||
try {
|
||||
logStreamFull.close();
|
||||
logStreamVal.close();
|
||||
}catch (Exception e){
|
||||
//NOOP
|
||||
}
|
||||
logStreamFull=null;
|
||||
logStreamVal=null;
|
||||
}
|
||||
|
||||
if(rec!=null){
|
||||
try {
|
||||
rec.stop();
|
||||
}catch (Exception e){
|
||||
//NOOP
|
||||
}
|
||||
try {
|
||||
rec.release();
|
||||
}catch (Exception e){
|
||||
//NOOP
|
||||
}
|
||||
rec=null;
|
||||
}
|
||||
}
|
||||
|
||||
private void alive(){
|
||||
ThreadX.ClearTimeout(aliveInt);
|
||||
aliveInt=ThreadX.SetTimeout(10 * 1000, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if(main!=null) {//Sync Check
|
||||
main.Log.e(LogTag, "录音超时自动停止:超过10秒未调用alive");
|
||||
}
|
||||
destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void stop(Callback<Object, Object> callback){
|
||||
if(!isRec){
|
||||
callback.Call(null, new Exception("未开始录音"));
|
||||
return;
|
||||
}
|
||||
if(readTotal==0){
|
||||
callback.Call(null, new Exception("未获得任何录音数据"));
|
||||
return;
|
||||
}
|
||||
|
||||
isRec=false;
|
||||
main.Log.i(LogTag, "结束录音,已录制:"+sendCount+"段 "+duration+"ms start到stop:"+(System.currentTimeMillis()-startTime)+"ms");
|
||||
|
||||
callback.Call(null, null);
|
||||
|
||||
if(logData) {
|
||||
try {
|
||||
byte[] fullArr=logStreamFull.toByteArray();
|
||||
savePcmLogFile("record-full.pcm", fullArr);
|
||||
|
||||
lostBytes=new byte[0];
|
||||
savePcmLogFile("record-full2.pcm", sampleData(fullArr, fullArr.length, sampleRateReq, rec.getSampleRate()));
|
||||
|
||||
savePcmLogFile("record-val.pcm", logStreamVal.toByteArray());
|
||||
} catch (Exception e) {
|
||||
main.Log.e(LogTag, "保存文件失败"+e.toString());
|
||||
}
|
||||
}
|
||||
destroy();
|
||||
}
|
||||
private void savePcmLogFile(String name, byte[] data) throws Exception{
|
||||
File folder = main.context.getExternalCacheDir();
|
||||
if (folder == null) {
|
||||
folder = main.context.getCacheDir();
|
||||
}
|
||||
File file=new File(folder, "/recorder/"+name);
|
||||
file.getParentFile().mkdirs();
|
||||
|
||||
FileOutputStream out = new FileOutputStream(file);
|
||||
|
||||
try {
|
||||
out.write(data);
|
||||
}finally {
|
||||
out.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private int readTotal=0;
|
||||
private int duration=0;
|
||||
private int sendCount=0;
|
||||
private void readAsync(){
|
||||
int sampleRateSrc=rec.getSampleRate();
|
||||
int bufferLen=(sampleRateSrc/12)*2;//每秒返回12次,按Int16需要乘2
|
||||
bufferLen+=bufferLen%2;//保证16位
|
||||
byte[] buffer=new byte[bufferLen];
|
||||
|
||||
boolean firstLog=false;
|
||||
|
||||
while (isRec && !Thread.currentThread().isInterrupted()){
|
||||
int count=rec.read(buffer, 0, buffer.length);
|
||||
if(!isRec || Thread.currentThread().isInterrupted()){
|
||||
return;
|
||||
}
|
||||
if(count<1){
|
||||
ThreadX.Sleep(5);
|
||||
continue;
|
||||
}
|
||||
if(logData) {
|
||||
logStreamFull.write(buffer, 0, count);
|
||||
}
|
||||
readTotal+=count;
|
||||
duration=readTotal/(sampleRateSrc/1000)/2;
|
||||
|
||||
|
||||
int sampleRate;
|
||||
byte[] data;
|
||||
//需要的数据小于源采样,重新采样
|
||||
if(sampleRateSrc>sampleRateReq){
|
||||
sampleRate=sampleRateReq;
|
||||
|
||||
data=sampleData(buffer, count, sampleRate, sampleRateSrc);
|
||||
}else{
|
||||
sampleRate=sampleRateSrc;
|
||||
data=new byte[count];
|
||||
System.arraycopy(buffer, 0, data, 0, count);
|
||||
}
|
||||
|
||||
if(logData) {
|
||||
logStreamVal.write(data, 0, data.length);
|
||||
}
|
||||
sendCount++;
|
||||
main.runScript(JsRequestName+".Record(\""+ Base64.encodeToString(data, Base64.NO_WRAP)+"\","+sampleRate+")");
|
||||
|
||||
if(!firstLog){
|
||||
main.Log.i(LogTag, "获取到了第一段录音数据:len:"+data.length+" lenSrc:"+count+" bufferLen:"+bufferLen+" sampleRateReq:"+sampleRateReq+" sampleRateSrc:"+sampleRateSrc+" sampleRateCallback:"+sampleRate);
|
||||
firstLog=true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private byte[] lostBytes=new byte[0];
|
||||
/**
|
||||
* 对采样率进行转换
|
||||
*/
|
||||
private byte[] sampleData(byte[] data, int count, int newSampleRate, int oldSampleRate){
|
||||
//先把字节转成Int16Array(注意是有符号n<<16>>16),省的拼接的麻烦还绕
|
||||
int arrLen=(lostBytes.length+count)/2;
|
||||
int[] arr=new int[arrLen];
|
||||
int dataStart=0,arrIdx=0;
|
||||
for(int i=0,il=lostBytes.length-1;i<=il;){
|
||||
if(i==il){
|
||||
dataStart=1;
|
||||
arr[arrIdx++]=(( (lostBytes[i++]&0xff) | (data[0]&0xff)<<8)<<16)>>16;
|
||||
}else{
|
||||
arr[arrIdx++]=(( (lostBytes[i++]&0xff) |(lostBytes[i++]&0xff)<<8 )<<16)>>16;
|
||||
}
|
||||
}
|
||||
for(int i=dataStart;arrIdx<arrLen;){
|
||||
arr[arrIdx++]=(( (data[i++]&0xff) | (data[i++]&0xff)<<8 )<<16)>>16;
|
||||
}
|
||||
|
||||
|
||||
// https://www.cnblogs.com/xiaoqi/p/6993912.html
|
||||
// 当前点=当前点+到后面一个点之间的增量,音质比直接简单抽样好些
|
||||
double step=1d*oldSampleRate / newSampleRate;
|
||||
|
||||
int size=(int)Math.floor(arr.length/step)*2;
|
||||
byte[] rtv = new byte[size];
|
||||
|
||||
int idx=0;
|
||||
double i=0;
|
||||
while(idx<size){
|
||||
int before = (int)Math.floor(i);
|
||||
int after = (int)Math.ceil(i);
|
||||
double atPoint = i - before;
|
||||
|
||||
int beforeVal=arr[before];
|
||||
int afterVal=arr[after];
|
||||
int newVal=(int)(beforeVal+(afterVal-beforeVal)*atPoint);
|
||||
|
||||
rtv[idx++] = (byte) (newVal & 0xff);
|
||||
rtv[idx++] = (byte) ((newVal >> 8) & 0xff);
|
||||
|
||||
i+=step;//抽样
|
||||
}
|
||||
|
||||
//把剩余的扔给下一回合
|
||||
int lost=count+lostBytes.length-(int)Math.ceil(i)*2;
|
||||
if(lost>0){
|
||||
lostBytes=new byte[lost];
|
||||
System.arraycopy(data,count-lost, lostBytes, 0, lost);
|
||||
}else{
|
||||
lostBytes=new byte[0];
|
||||
}
|
||||
|
||||
return rtv;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//********ThreadX.java***************************
|
||||
static public class ThreadX {
|
||||
static public void Sleep(int millisecond){
|
||||
try {
|
||||
Thread.sleep(millisecond);
|
||||
} catch (InterruptedException e) {
|
||||
//NOOP
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 定时在后台执行任务,返回值可通过ClearTimeout来终止定时任务
|
||||
*/
|
||||
static public int SetTimeout(int timeoutMillisecond, Runnable run){
|
||||
Timeout time=new Timeout(run);
|
||||
|
||||
int idx;
|
||||
synchronized (intTimes){
|
||||
idx=++intIdx;
|
||||
intTimes.put(idx+"",time);
|
||||
}
|
||||
|
||||
time.idx=idx;
|
||||
if(timeoutMillisecond<0){
|
||||
timeoutMillisecond=0;
|
||||
}
|
||||
time.Schedule(timeoutMillisecond);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消定时任务
|
||||
*/
|
||||
static public void ClearTimeout(int set){
|
||||
synchronized (intTimes){
|
||||
Timeout time=intTimes.get(set+"");
|
||||
if(time!=null){
|
||||
time.Cancel();
|
||||
}
|
||||
intTimes.remove(set+"");
|
||||
}
|
||||
}
|
||||
|
||||
static private int intIdx=100;
|
||||
static private final HashMap<String, Timeout> intTimes=new HashMap<>();
|
||||
private static class Timeout extends TimerTask implements Runnable {
|
||||
public Timeout(Runnable run){
|
||||
this.run=run;
|
||||
}
|
||||
|
||||
private Runnable run;
|
||||
private int idx;
|
||||
private boolean isCancel;
|
||||
private Timer scheduleTimer;
|
||||
|
||||
public void Cancel(){
|
||||
run=null;
|
||||
isCancel=true;
|
||||
|
||||
if(scheduleTimer !=null){
|
||||
scheduleTimer.cancel();
|
||||
scheduleTimer =null;
|
||||
}
|
||||
}
|
||||
public void Schedule(int timeoutMillisecond){
|
||||
scheduleTimer =new Timer();
|
||||
scheduleTimer.schedule(this, timeoutMillisecond);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if(isCancel){
|
||||
return;
|
||||
}
|
||||
Runnable fn=run;
|
||||
ClearTimeout(idx);
|
||||
|
||||
fn.run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台运行任务
|
||||
*/
|
||||
static public void Run(Runnable run){
|
||||
new Thread(run).start();
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/app/src/main/res/drawable/icon.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
31
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/app/src/main/res/layout/activity_main.xml
generated
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:orientation="horizontal">
|
||||
<EditText
|
||||
android:id="@+id/main_logs"
|
||||
android:inputType="textMultiLine"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000"
|
||||
android:textColor="#fff"
|
||||
android:gravity="top"/>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<WebView
|
||||
android:id="@+id/main_webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
8
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/app/src/main/res/values/styles.xml
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
|
||||
<style name="AppTheme" parent="android:Theme.Light.NoTitleBar">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
9
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/app/src/main/res/xml/network_security_config.xml
generated
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system"/>
|
||||
<certificates src="user"/> <!-- 没意思 比这个薄弱的地方多了去了 -->
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
27
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/build.gradle
generated
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.3.2'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
15
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/gradle.properties
generated
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
|
||||
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/gradle/wrapper/gradle-wrapper.jar
generated
vendored
Normal file
6
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/gradle/wrapper/gradle-wrapper.properties
generated
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
#Wed Aug 14 15:51:37 CST 2019
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip
|
||||
172
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/gradlew
generated
vendored
Normal file
@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=$(save "$@")
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||
cd "$(dirname "$0")"
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
84
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/gradlew.bat
generated
vendored
Executable file
@ -0,0 +1,84 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
1
WebRoot/node_modules/Recorder-master/app-support-sample/demo_android/settings.gradle
generated
vendored
Normal file
@ -0,0 +1 @@
|
||||
include ':app'
|
||||
52
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/README.md
generated
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
[Recorder](https://github.com/xiangyuecn/Recorder/) | [RecordApp](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample)
|
||||
|
||||
# :open_book:IOS Hybrid App
|
||||
|
||||
本目录内包含IOS App测试源码,和核心文件 [RecordAppJsBridge.swift](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/demo_ios/recorder/RecordAppJsBridge.swift) ;clone后用`xcode`打开后编译运行(没有Mac OS? [装个黑苹果](https://www.jianshu.com/p/cbde4ec9f742) )。本demo为swift代码,兼容IOS 9.0+,已测试IOS 12.3。
|
||||
|
||||
本Demo是对[/app-support-sample/native-config.js](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/native-config.js)配置示例中定义的JsBridge接口的实现。
|
||||
|
||||
可以直接copy目录内`RecordAppJsBridge.swift`使用,此文件为核心文件,其他文件都是没什么价值的;支持新开发WKWebView界面,或对已有的WKWebView实例升级支持RecordApp。
|
||||
|
||||
**xcode测试项目clone后请修改`PRODUCT_BUNDLE_IDENTIFIER`,不然这个测试id被抢来抢去要闲置7天才能被使用,嫌弃苹果公司工程师水准**
|
||||
|
||||
|
||||
## 【截图】
|
||||

|
||||
|
||||
|
||||
## 【限制】
|
||||
|
||||
- 未做古董版本UIWebView适配,理论上并不需要太大改动就能支持,并不打算进行支持
|
||||
- 未测试在OC中调用此swift文件,并不打算去写OC代码(学不动)
|
||||
|
||||
|
||||
|
||||
|
||||
# :open_book:原理
|
||||
|
||||
通过userContentController往WKWebView注入一个全局对象`RecordAppJsBridgeIsSet`,js中通过`webkit.messageHandlers.RecordAppJsBridgeIsSet`来访问,只有存在这个对象,就代表是在App中;但并不通过这个对象来进行数据交互,因为它仅支持异步操作;数据交互需要一个同步方法来进行支持,因为同步可以实现异步,仅支持异步的只能异步到底,所以选择重写WebView的prompt弹框方法,进行数据的交互。
|
||||
|
||||
|
||||
## 数据交互
|
||||
swift收到js发起的prompt弹框请求,解析弹框携带的数据参数,并调用参数中接口对应的swift方法,同步执行完后把数据返回给prompt弹框,如果方法是异步的,将在异步操作完成后swift将调用网页的js方法`AppJsBridgeRequest.Call`将数据异步返回。
|
||||
|
||||
|
||||
## 录音接口
|
||||
接口对应的方法使用的`AVAudioRecorder`来录音,`AVAudioRecorder`会把录音PCM数据写入到文件,因此我们实时从这个文件中读取出数据,然后定时调用`AppJsBridgeRequest.Record`把数据返回给js端即可完成完整的录音功能。
|
||||
|
||||
可能是因为`AVAudioRecorder`存在文件写入缓存的原因,数据并非实时的flush到文件的,因此实时发送给js的数据存在300ms左右的滞后;`AudioQueue`、`AudioUnit`之类的更强大的工具文章又少,代码又多,本质上是因为不会用,所以就成这样了。
|
||||
|
||||
|
||||
## 需要权限
|
||||
在plist中配置麦克风的权限声明:`NSMicrophoneUsageDescription`。
|
||||
|
||||
|
||||
## 如何接入使用
|
||||
请阅读[RecordAppJsBridge.swift](https://github.com/xiangyuecn/Recorder/blob/master/app-support-sample/demo_ios/recorder/RecordAppJsBridge.swift)文件开头的注释文档,可直接copy此文件到你的项目中使用;支持新开发WKWebView界面,或对已有的WKWebView实例升级支持RecordApp。
|
||||
|
||||
|
||||
## 为什么不用UserAgent来识别App环境
|
||||
|
||||
通过修改WebView的UA来让H5、服务器判断是不是在App里面运行的,此方法非常简单而且实用。但有一个致命缺陷,当UA数据很敏感的场景下,虽然方便了我方H5、服务器来识别这个App,但也同时也暴露给了任何在此WebView中发起的请求,不可避免的会将我们的标识信息随请求而发送给第三方(虽然可通过额外编程把信息抹掉,但代价太大了)。IOS不动UA基本上别人的服务器几乎不太可能识别出我们的App,Android神一样的把包名添加到了X-Requested-With请求头中,还能不能讲理了。
|
||||
|
||||
353
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder.xcodeproj/project.pbxproj
generated
vendored
Normal file
@ -0,0 +1,353 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 50;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
222D69332305856D0095E833 /* RecordAppJsBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222D69322305856D0095E833 /* RecordAppJsBridge.swift */; };
|
||||
227CEBBA230584320076C1F7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227CEBB9230584320076C1F7 /* AppDelegate.swift */; };
|
||||
227CEBBC230584320076C1F7 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227CEBBB230584320076C1F7 /* MainView.swift */; };
|
||||
227CEBBF230584320076C1F7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 227CEBBD230584320076C1F7 /* Main.storyboard */; };
|
||||
227CEBC1230584350076C1F7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 227CEBC0230584350076C1F7 /* Assets.xcassets */; };
|
||||
227CEBC4230584350076C1F7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 227CEBC2230584350076C1F7 /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
222D69322305856D0095E833 /* RecordAppJsBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordAppJsBridge.swift; sourceTree = "<group>"; };
|
||||
227CEBB6230584310076C1F7 /* recorder.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = recorder.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
227CEBB9230584320076C1F7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
227CEBBB230584320076C1F7 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||
227CEBBE230584320076C1F7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
227CEBC0230584350076C1F7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
227CEBC3230584350076C1F7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
227CEBC5230584350076C1F7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
227CEBB3230584310076C1F7 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
227CEBAD230584310076C1F7 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
227CEBB8230584320076C1F7 /* recorder */,
|
||||
227CEBB7230584310076C1F7 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
227CEBB7230584310076C1F7 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
227CEBB6230584310076C1F7 /* recorder.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
227CEBB8230584320076C1F7 /* recorder */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
222D69322305856D0095E833 /* RecordAppJsBridge.swift */,
|
||||
227CEBBB230584320076C1F7 /* MainView.swift */,
|
||||
227CEBCB2305844E0076C1F7 /* ios */,
|
||||
);
|
||||
path = recorder;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
227CEBCB2305844E0076C1F7 /* ios */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
227CEBB9230584320076C1F7 /* AppDelegate.swift */,
|
||||
227CEBC0230584350076C1F7 /* Assets.xcassets */,
|
||||
227CEBC5230584350076C1F7 /* Info.plist */,
|
||||
227CEBC2230584350076C1F7 /* LaunchScreen.storyboard */,
|
||||
227CEBBD230584320076C1F7 /* Main.storyboard */,
|
||||
);
|
||||
path = ios;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
227CEBB5230584310076C1F7 /* recorder */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 227CEBC8230584350076C1F7 /* Build configuration list for PBXNativeTarget "recorder" */;
|
||||
buildPhases = (
|
||||
227CEBB2230584310076C1F7 /* Sources */,
|
||||
227CEBB3230584310076C1F7 /* Frameworks */,
|
||||
227CEBB4230584310076C1F7 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = recorder;
|
||||
productName = recorder;
|
||||
productReference = 227CEBB6230584310076C1F7 /* recorder.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
227CEBAE230584310076C1F7 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 1020;
|
||||
LastUpgradeCheck = 1020;
|
||||
ORGANIZATIONNAME = macos;
|
||||
TargetAttributes = {
|
||||
227CEBB5230584310076C1F7 = {
|
||||
CreatedOnToolsVersion = 10.2.1;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 227CEBB1230584310076C1F7 /* Build configuration list for PBXProject "recorder" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 227CEBAD230584310076C1F7;
|
||||
productRefGroup = 227CEBB7230584310076C1F7 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
227CEBB5230584310076C1F7 /* recorder */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
227CEBB4230584310076C1F7 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
227CEBC4230584350076C1F7 /* LaunchScreen.storyboard in Resources */,
|
||||
227CEBC1230584350076C1F7 /* Assets.xcassets in Resources */,
|
||||
227CEBBF230584320076C1F7 /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
227CEBB2230584310076C1F7 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
227CEBBC230584320076C1F7 /* MainView.swift in Sources */,
|
||||
227CEBBA230584320076C1F7 /* AppDelegate.swift in Sources */,
|
||||
222D69332305856D0095E833 /* RecordAppJsBridge.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
227CEBBD230584320076C1F7 /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
227CEBBE230584320076C1F7 /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
227CEBC2230584350076C1F7 /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
227CEBC3230584350076C1F7 /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
227CEBC6230584350076C1F7 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
227CEBC7230584350076C1F7 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
227CEBC9230584350076C1F7 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = W55ZG96NPS;
|
||||
INFOPLIST_FILE = "$(SRCROOT)/recorder/ios/Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.xianyuecn.recorder.edit-to-your-id;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
227CEBCA230584350076C1F7 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = W55ZG96NPS;
|
||||
INFOPLIST_FILE = "$(SRCROOT)/recorder/ios/Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.xianyuecn.recorder.edit-to-your-id;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
227CEBB1230584310076C1F7 /* Build configuration list for PBXProject "recorder" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
227CEBC6230584350076C1F7 /* Debug */,
|
||||
227CEBC7230584350076C1F7 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
227CEBC8230584350076C1F7 /* Build configuration list for PBXNativeTarget "recorder" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
227CEBC9230584350076C1F7 /* Debug */,
|
||||
227CEBCA230584350076C1F7 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 227CEBAE230584310076C1F7 /* Project object */;
|
||||
}
|
||||
7
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:recorder.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
231
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/MainView.swift
generated
vendored
Normal file
@ -0,0 +1,231 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
import AVFoundation
|
||||
|
||||
/* 录音Hybrid App Demo界面 https://github.com/xiangyuecn/Recorder
|
||||
没什么有用的东西 */
|
||||
class MainView: UIViewController {
|
||||
static private let LogTag="MainView";
|
||||
|
||||
//实现prompt webview uiDelegate
|
||||
public class WebUI:NSObject,WKUIDelegate {
|
||||
weak var View:MainView?;
|
||||
public init(_ view:MainView){
|
||||
View=view;
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
|
||||
//如果是js bridge的请求,就接管并处理它
|
||||
if let data=View?.jsBridge.acceptPrompt(prompt) {
|
||||
completionHandler(data);
|
||||
return;
|
||||
}
|
||||
|
||||
//此方法还需实现prompt弹框
|
||||
completionHandler(nil);
|
||||
}
|
||||
//此类还需实现alert,confirm弹框
|
||||
}
|
||||
|
||||
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
Log=LogX(self);
|
||||
let microphoneUsesPermission:RecordAppJsBridge.MicrophoneUsesPermission={ [weak self] call in
|
||||
self?.reqMicrophonePermission(call);
|
||||
};
|
||||
let config=WKWebViewConfiguration();
|
||||
|
||||
//******调用核心方法*********************
|
||||
//注入JsBridge, 实现api接口
|
||||
jsBridge=RecordAppJsBridge(config, microphoneUsesPermission, Log){ [weak self] code in
|
||||
self?.webView.evaluateJavaScript(code, completionHandler: nil);
|
||||
};
|
||||
//*******以下内容无关紧要*****************
|
||||
|
||||
|
||||
config.requiresUserActionForMediaPlayback=false;
|
||||
config.allowsInlineMediaPlayback=true;
|
||||
config.allowsAirPlayForMediaPlayback=true;
|
||||
|
||||
webView=WKWebView(frame: CGRect(x: 0, y: 0, width:self.view.bounds.width,height:self.view.bounds.height), configuration: config);
|
||||
webViewBox.addSubview(webView);
|
||||
|
||||
webView.scrollView.bounces=false;
|
||||
webView.isOpaque=false;
|
||||
webView.backgroundColor=UIColor(red: 0xff/0xff, green: 0x66/0xff, blue: 0x00/0xff, alpha: 0xff/0xff);
|
||||
|
||||
webUI=WebUI(self);
|
||||
webNav=WebNav(self);
|
||||
webView.uiDelegate = webUI;
|
||||
webView.navigationDelegate = webNav;
|
||||
|
||||
let url="https://xiangyuecn.github.io/Recorder/app-support-sample/";
|
||||
webView.load(URLRequest(url: URL(string: url)!));
|
||||
|
||||
|
||||
|
||||
logs.text="日志输出已开启\n"+MainView.cmds;
|
||||
logsChange=LogsChange(self);
|
||||
logs.delegate=logsChange;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@IBOutlet weak var logs: UITextView!
|
||||
@IBOutlet weak var webViewBox: UIView!
|
||||
|
||||
public var webView: WKWebView!;
|
||||
private var webUI: WebUI!;
|
||||
private var webNav: WebNav!;
|
||||
private var logsChange: LogsChange!;
|
||||
private var jsBridge: RecordAppJsBridge!;
|
||||
private var Log: RecordApp_ILog!;
|
||||
|
||||
deinit {
|
||||
jsBridge?.close();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//输入命令处理
|
||||
static let cmds="支持命令(首行输入,不含引号):\n`:url:网址::`导航到此地址\n`:reload:::`重新加载当前页面\n`:js:js代码::`执行js";
|
||||
public class LogsChange:NSObject, UITextViewDelegate{
|
||||
public init(_ view:MainView){
|
||||
View=view;
|
||||
}
|
||||
weak private var View:MainView!;
|
||||
|
||||
public func textViewDidChange(_ textView: UITextView) {
|
||||
let s=textView.text!;
|
||||
if s.count>0 && s.starts(with: ":") {
|
||||
var txt="\n\n命令已执行,"+MainView.cmds+"\n\n";
|
||||
let exp=try! NSRegularExpression(pattern: "^:(.+?):(.*)::", options: []);
|
||||
let match=exp.firstMatch(in: s, options: [], range: NSRange(location: 0, length:s.count));
|
||||
if let m=match {
|
||||
let r1=m.range(at: 1),r2=m.range(at: 2);
|
||||
let m1=String(s[s.index(s.startIndex, offsetBy: r1.location)..<s.index(s.startIndex, offsetBy: r1.location+r1.length)]);
|
||||
let m2=String(s[s.index(s.startIndex, offsetBy:r2.location)..<s.index(s.startIndex, offsetBy:r2.location+r2.length)]);
|
||||
switch(m1) {
|
||||
case "url":
|
||||
if let url=URL(string: m2) {
|
||||
View.webView.load(URLRequest(url: url));
|
||||
}
|
||||
break;
|
||||
case "reload":
|
||||
View.webView.reload();
|
||||
break;
|
||||
case "js":
|
||||
View.webView.evaluateJavaScript(m2, completionHandler: nil);
|
||||
break;
|
||||
default:
|
||||
txt+=",未知命令"+m1;
|
||||
}
|
||||
txt+="\n"+s;
|
||||
textView.text=txt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//录音权限处理
|
||||
private func reqMicrophonePermission(_ call:@escaping (Bool)->Void){
|
||||
let statues = AVAudioSession.sharedInstance().recordPermission
|
||||
if statues == .undetermined {
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { [weak self] (granted) in
|
||||
if granted {
|
||||
call(true);
|
||||
} else {
|
||||
self?.reqMicrophonePermission(call);
|
||||
}
|
||||
}
|
||||
} else if statues == .granted {
|
||||
call(true);
|
||||
} else {
|
||||
self.Log.e(MainView.LogTag, "没有录音权限,请到设置中允许访问麦克风,demo就不弹到设置的对话框了");
|
||||
|
||||
call(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//日志输出
|
||||
public class LogX:RecordApp_ILog{
|
||||
weak var View:MainView?;
|
||||
public init(_ view:MainView){
|
||||
View=view;
|
||||
}
|
||||
|
||||
public func i(_ tag: String, _ msg: String) {
|
||||
printX("[i]["+tag+"]"+msg);
|
||||
}
|
||||
public func e(_ tag: String, _ msg: String) {
|
||||
printX("[e]["+tag+"]"+msg);
|
||||
}
|
||||
|
||||
var msgs="";
|
||||
var waitInt=0;
|
||||
private func printX(_ s:String){
|
||||
let msg="["+time()+"]"+s;
|
||||
print(msg);
|
||||
|
||||
msgs+="\n\n"+msg;
|
||||
|
||||
//延迟在主线程更新日志文本框
|
||||
if(waitInt==0) {
|
||||
waitInt = RecordAppJsBridge.ThreadX.SetTimeout(500){
|
||||
self.waitInt=0;
|
||||
RecordAppJsBridge.ThreadX.UI {
|
||||
var txt=self.View?.logs.text ?? "";
|
||||
if(txt.count>250*1024){
|
||||
txt=String(txt.suffix(200*1024));
|
||||
}
|
||||
txt=txt+self.msgs;
|
||||
self.msgs="";
|
||||
|
||||
self.View?.logs.text=txt;
|
||||
self.View?.logs.scrollRangeToVisible(NSRange.init(location:txt.count, length: 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var f:DateFormatter?;
|
||||
private func time()->String{
|
||||
if(f==nil){
|
||||
f = DateFormatter();
|
||||
f!.locale = Locale.init(identifier: "zh_CN");
|
||||
f!.dateFormat = "HH:mm:ss";
|
||||
}
|
||||
return f!.string(from: Date());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//监控网页请求
|
||||
public class WebNav:NSObject, WKNavigationDelegate{
|
||||
public init(_ main:MainView){
|
||||
view=main;
|
||||
}
|
||||
weak var view:MainView!;
|
||||
|
||||
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
view.Log.i(MainView.LogTag, "打开网页:"+(webView.url?.absoluteString ?? ""));
|
||||
}
|
||||
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
view.Log.e(MainView.LogTag, "打开网页失败:"+error.localizedDescription+" url:"+(webView.url?.absoluteString ?? ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
678
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/RecordAppJsBridge.swift
generated
vendored
Normal file
@ -0,0 +1,678 @@
|
||||
import WebKit
|
||||
import AVFoundation
|
||||
|
||||
/* 录音Hybrid App核心支持文件 https://github.com/xiangyuecn/Recorder
|
||||
|
||||
【使用】
|
||||
1. copy本文件即可使用,其他文件都是多余的
|
||||
2. 在plist中配置麦克风的权限声明:NSMicrophoneUsageDescription
|
||||
3. 把WKWebView的configuration传进来进行对象注入;实现一个麦克风权限请求接口;实现一个Log接口,简单内部调用一下print即可
|
||||
4. 重写WKWebView的prompt弹框,函数内部调用一下acceptPrompt进行请求识别和处理
|
||||
|
||||
【为什么不用UserAgent来识别App环境】
|
||||
通过修改WebView的UA来让H5、服务器判断是不是在App里面运行的,此方法非常简单而且实用。但有一个致命缺陷,当UA数据很敏感的场景下,虽然方便了我方H5、服务器来识别这个App,但也同时也暴露给了任何在此WebView中发起的请求,不可避免的会将我们的标识信息随请求而发送给第三方(虽然可通过额外编程把信息抹掉,但代价太大了)。IOS不动UA基本上别人的服务器几乎不太可能识别出我们的App,Android神一样的把包名添加到了X-Requested-With请求头中,还能不能讲理了。
|
||||
*/
|
||||
public class RecordAppJsBridge {
|
||||
//js中定义的名称
|
||||
static private let JsBridgeName="RecordAppJsBridge";
|
||||
static private let JsRequestName="AppJsBridgeRequest";
|
||||
|
||||
//默认会把录音数据额外存储到app的cache目录中,仅供分析调试之用
|
||||
static private let SavePCM_ToLogFile=true;
|
||||
|
||||
static private let LogTag="RecordAppJsBridge";
|
||||
|
||||
public typealias MicrophoneUsesPermission = (@escaping (Bool)->Void)->Void;
|
||||
|
||||
public init(_ config:WKWebViewConfiguration, _ microphoneUsesPermission:@escaping MicrophoneUsesPermission, _ log:RecordApp_ILog, _ runScript:@escaping (String)->Void){
|
||||
Log=log;
|
||||
microphoneUsesPermissionFn=microphoneUsesPermission;
|
||||
runScriptFn=runScript;
|
||||
|
||||
//底层识别,浏览器通过判断是否存在这个对象来识别app环境。这里不承载数据交互功能。数据交互在通过重写浏览器的prompt,并在prompt中调用下面的acceptPrompt方法
|
||||
config.userContentController.add(WebViewJsMatchObj(), name: RecordAppJsBridge.JsBridgeName+"IsSet");
|
||||
}
|
||||
public class WebViewJsMatchObj:NSObject, WKScriptMessageHandler{
|
||||
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
//NOOP
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var Log:RecordApp_ILog!;
|
||||
public var microphoneUsesPermissionFn:MicrophoneUsesPermission!;
|
||||
private var runScriptFn:((String)->Void)!;
|
||||
|
||||
public func runScript(_ code:String){
|
||||
ThreadX.UI {
|
||||
self.runScriptFn?(code);
|
||||
}
|
||||
}
|
||||
public func close(){
|
||||
Log=nil;
|
||||
microphoneUsesPermissionFn=nil;
|
||||
runScriptFn=nil;
|
||||
}
|
||||
|
||||
/*如果prompt弹窗提示内容为json格式,就接管*/
|
||||
public func acceptPrompt(_ prompt:String)->String?{
|
||||
if prompt.hasPrefix("{") && prompt.hasSuffix("}") {
|
||||
let json=Code.ParseDic(prompt);
|
||||
let res=Request(self, json).exec();
|
||||
return Code.ToJson(res);
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//*****Request.swift**处理js请求**************************
|
||||
public class Request{
|
||||
static private let LogTag="Request";
|
||||
|
||||
//api定义在哪些类里面,省的去查找类,都在这里统一进行静态初始化
|
||||
static private var IsInit=false;
|
||||
public typealias Api=(Request)->Void;
|
||||
static private var ActionMethodMapping:[String: Api]=[:];
|
||||
static public func Init(){
|
||||
if(IsInit){
|
||||
return;
|
||||
}
|
||||
objc_sync_enter(LogTag);
|
||||
defer{
|
||||
objc_sync_exit(LogTag);
|
||||
}
|
||||
if(IsInit){
|
||||
return;
|
||||
}
|
||||
|
||||
Add(RecordApis.Init());
|
||||
|
||||
IsInit=true;
|
||||
}
|
||||
static private func Add(_ apis:[String: Api]){
|
||||
for (k,v) in apis {
|
||||
if ActionMethodMapping.index(forKey: k) != nil {
|
||||
print(LogTag+":重复接口:"+k);
|
||||
}
|
||||
ActionMethodMapping[k]=v;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
weak public var jsBridge:RecordAppJsBridge!;
|
||||
private var json:[String:Any?]!;
|
||||
public var args:[String:Any?]!;
|
||||
private var action:String!;
|
||||
private var isAsync:Bool!;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 设置接口返回的value,并设置status为success
|
||||
*/
|
||||
public func setValue(_ val:Any?){
|
||||
json["status"]="success";
|
||||
_setVal(val);
|
||||
}
|
||||
private func _setVal(_ val:Any?){
|
||||
json["value"]=val;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅仅设置返回的message数据
|
||||
*/
|
||||
public func setMsg(_ msg:String){
|
||||
json["message"]=msg;
|
||||
}
|
||||
/**
|
||||
* 异步方法调用最终执行完毕时调用,异步方法专用
|
||||
*/
|
||||
public func callback(_ val:Any?, _ errOrNull:String?){
|
||||
if(errOrNull != nil){
|
||||
_setVal(val);
|
||||
setMsg(errOrNull!);
|
||||
}else{
|
||||
setValue(val);
|
||||
}
|
||||
__callback(false);
|
||||
}
|
||||
private func __callback(_ isExecCall:Bool){
|
||||
jsBridge?.runScript(RecordAppJsBridge.JsRequestName+".Call("+Code.ToJson(json)+")");
|
||||
|
||||
if !isExecCall && !isAsync {
|
||||
jsBridge?.Log?.e(Request.LogTag,action+"不是异步方法,但调用了回调");
|
||||
}
|
||||
if isSend {
|
||||
jsBridge?.Log?.e(Request.LogTag,action+"重复回调");
|
||||
}
|
||||
isSend=true;
|
||||
}
|
||||
private var isSend=false;
|
||||
|
||||
|
||||
|
||||
public init(_ jsBridge:RecordAppJsBridge, _ msg:[String:Any?]){
|
||||
Request.Init();
|
||||
self.jsBridge=jsBridge;
|
||||
args=Code.GetDic(msg,"args");
|
||||
action=Code.GetString(msg,"action");
|
||||
|
||||
json=[:];
|
||||
json["status"]="";
|
||||
json["message"]="";
|
||||
json["action"]=action;
|
||||
json["callback"]=Code.GetString(msg,"callback");
|
||||
}
|
||||
public func exec()->[String:Any?]{
|
||||
let syncName="Sync_"+action;
|
||||
let asyncName="Async_"+action;
|
||||
|
||||
var findMethodName:String?=nil;
|
||||
var fn:Api?=nil;
|
||||
if Request.ActionMethodMapping.index(forKey: syncName) != nil {
|
||||
isAsync=false;
|
||||
findMethodName=syncName;
|
||||
fn = Request.ActionMethodMapping[findMethodName!];
|
||||
}else if Request.ActionMethodMapping.index(forKey: asyncName) != nil {
|
||||
isAsync=true;
|
||||
findMethodName=asyncName;
|
||||
fn = Request.ActionMethodMapping[findMethodName!];
|
||||
}
|
||||
if fn==nil {
|
||||
jsBridge?.Log?.e(Request.LogTag,"request."+action+"不存在");
|
||||
json["message"]="request."+action+"不存在";
|
||||
__callback(true);
|
||||
return json;
|
||||
}
|
||||
|
||||
fn!(self);
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public class RecordApis{
|
||||
static private let LogTag="RecordApis"
|
||||
static public func Init()->[String:Request.Api]{
|
||||
return [
|
||||
"Async_recordPermission":{ req in
|
||||
req.jsBridge.microphoneUsesPermissionFn(){ has in
|
||||
if(has){
|
||||
req.callback(1, nil);
|
||||
}else{
|
||||
req.callback(3, nil);
|
||||
}
|
||||
};
|
||||
}
|
||||
, "Sync_recordAlive":{ req in
|
||||
if(Current != nil){
|
||||
Current.alive();
|
||||
req.setValue(nil);
|
||||
}else{
|
||||
req.setMsg("未开始任何录音");
|
||||
}
|
||||
}
|
||||
, "Async_recordStart":{ req in
|
||||
DestroyCurrent();
|
||||
|
||||
req.jsBridge.microphoneUsesPermissionFn(){ has in
|
||||
if !has {
|
||||
req.callback(nil, "没有录音权限");
|
||||
return
|
||||
}
|
||||
|
||||
ThreadX.Run {
|
||||
let param=Code.GetDic(req.args,"param");
|
||||
var sampleRate=Code.GetInt(param,"sampleRate");
|
||||
if(sampleRate==0){
|
||||
sampleRate=16000;
|
||||
}
|
||||
|
||||
_=RecordApis(req.jsBridge, sampleRate){ err in
|
||||
if err != nil {
|
||||
req.jsBridge.Log.e(LogTag, "开始录音失败:"+err!);
|
||||
req.callback(nil, "无法开始录音:"+err!);
|
||||
DestroyCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
req.callback(Dictionary<String,Any?>(), nil);
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
, "Async_recordStop":{ req in
|
||||
if(Current==nil){
|
||||
req.callback(nil, "未开始任何录音");
|
||||
return;
|
||||
}
|
||||
|
||||
Current.stop({ err in
|
||||
if(err != nil){
|
||||
req.jsBridge.Log.e(LogTag, "停止录音失败:"+err!);
|
||||
req.callback(nil, "结束录音出错:"+err!);
|
||||
DestroyCurrent();//成功的不要Destroy还要打log
|
||||
return;
|
||||
}
|
||||
req.callback(Dictionary<String,Any?>(), nil);
|
||||
});
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
static private func DestroyCurrent(){
|
||||
if(Current != nil){
|
||||
Current.destroy();
|
||||
}
|
||||
}
|
||||
static private var Current:RecordApis!;
|
||||
|
||||
private let Lock="";
|
||||
private init(_ main_:RecordAppJsBridge, _ sampleRateReq:Int, _ ready:@escaping (String?)->Void){
|
||||
objc_sync_enter(Lock);
|
||||
defer{
|
||||
objc_sync_exit(Lock);
|
||||
}
|
||||
|
||||
self.main=main_;
|
||||
sampleRate=sampleRateReq;
|
||||
logData=RecordAppJsBridge.SavePCM_ToLogFile;
|
||||
if logData {
|
||||
logStreamFull=NSMutableData();
|
||||
}
|
||||
|
||||
RecordApis.Current=self;
|
||||
|
||||
cacheFile=NSTemporaryDirectory() + "/record.tmp.pcm"
|
||||
try? FileManager.default.removeItem(atPath: cacheFile);
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(AVAudioSession.Category.playAndRecord)
|
||||
try session.setActive(true)
|
||||
} catch let err {
|
||||
main.Log.e(RecordApis.LogTag, "设置录音环境出错:"+err.localizedDescription);
|
||||
ready("设置录音环境出错:"+err.localizedDescription);
|
||||
return;
|
||||
}
|
||||
|
||||
let sets: [String: Any] = [
|
||||
AVSampleRateKey: NSNumber(value: sampleRate),//采样率
|
||||
AVFormatIDKey: NSNumber(value: kAudioFormatLinearPCM),//音频格式
|
||||
AVLinearPCMBitDepthKey: NSNumber(value: 16),//采样位数
|
||||
AVNumberOfChannelsKey: NSNumber(value: 1),//通道数
|
||||
AVEncoderAudioQualityKey: NSNumber(value: AVAudioQuality.high.rawValue)//录音质量
|
||||
];
|
||||
//开始录音
|
||||
do {
|
||||
let url = URL(fileURLWithPath: cacheFile)
|
||||
rec = try AVAudioRecorder.init(url: url, settings: sets)
|
||||
rec.prepareToRecord()
|
||||
rec.record()
|
||||
} catch let err {
|
||||
main.Log.e(RecordApis.LogTag, "开始录音出错:"+err.localizedDescription);
|
||||
ready("开始录音出错:"+err.localizedDescription);
|
||||
return;
|
||||
}
|
||||
|
||||
ready(nil);
|
||||
|
||||
isRec=true;
|
||||
alive();
|
||||
main.Log.i(RecordApis.LogTag, "开始录音:"+String(sampleRate));
|
||||
startTime=Code.GetMS();
|
||||
|
||||
ThreadX.Run {
|
||||
self.readAsync();
|
||||
}
|
||||
}
|
||||
private var main:RecordAppJsBridge!;
|
||||
private var sampleRate:Int;
|
||||
private var rec:AVAudioRecorder!;
|
||||
private var isRec=false;
|
||||
private var aliveInt=0;
|
||||
private var startTime=Int64(0);
|
||||
|
||||
private var cacheFile:String!;
|
||||
|
||||
private var logData:Bool;
|
||||
private var logStreamFull:NSMutableData!;
|
||||
|
||||
private func destroy(){
|
||||
objc_sync_enter(Lock);
|
||||
defer{
|
||||
objc_sync_exit(Lock);
|
||||
}
|
||||
|
||||
RecordApis.Current=nil;
|
||||
|
||||
isRec=false;
|
||||
main=nil;
|
||||
|
||||
ThreadX.ClearTimeout(aliveInt);
|
||||
|
||||
logStreamFull=nil;
|
||||
|
||||
if(rec != nil){
|
||||
rec.stop();
|
||||
rec=nil;
|
||||
}
|
||||
}
|
||||
private func alive(){
|
||||
ThreadX.ClearTimeout(aliveInt);
|
||||
aliveInt=ThreadX.SetTimeout(10 * 1000){
|
||||
if self.main != nil {//Sync Check
|
||||
self.main.Log.e(RecordApis.LogTag, "录音超时自动停止:超过10秒未调用alive");
|
||||
}
|
||||
self.destroy();
|
||||
};
|
||||
}
|
||||
|
||||
private func stop(_ callback:@escaping (String?)->Void){
|
||||
if(!isRec){
|
||||
callback("未开始录音");
|
||||
return;
|
||||
}
|
||||
if(readTotal==0){
|
||||
callback("未获得任何录音数据");
|
||||
return;
|
||||
}
|
||||
|
||||
isRec=false;
|
||||
main.Log.i(RecordApis.LogTag, "结束录音,已录制:"+String(sendCount)+"段 "+String(duration)+"ms start到stop:"+String(Code.GetMS()-startTime)+"ms");
|
||||
|
||||
callback(nil);
|
||||
|
||||
if(logData) {
|
||||
let dir = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).last!
|
||||
let fullFile=dir+"/record-full.pcm";
|
||||
|
||||
if !logStreamFull.write(toFile: fullFile, atomically: false) {
|
||||
main.Log.e(RecordApis.LogTag, "保存文件失败"+fullFile);
|
||||
}
|
||||
}
|
||||
destroy();
|
||||
}
|
||||
|
||||
private var readTotal=0;
|
||||
private var duration=0;
|
||||
private var sendCount=0;
|
||||
private func readAsync(){
|
||||
let sampleRateSrc=sampleRate;
|
||||
var bufferLen=(sampleRateSrc/12)*2;//每秒返回12次,按Int16需要乘2
|
||||
bufferLen+=bufferLen%2;//保证16位
|
||||
|
||||
let itemMs=1000/12;
|
||||
var prevTime=Int64(0);
|
||||
|
||||
let cache=NSMutableData();
|
||||
|
||||
let input=FileHandle.init(forReadingAtPath: cacheFile);
|
||||
var skiped=false;
|
||||
let skipLen=4*1024;//照抄的别人的
|
||||
var firstLog=false;
|
||||
while isRec {
|
||||
var data=input!.readData(ofLength: 256);
|
||||
|
||||
if data.count==0 {
|
||||
ThreadX.Sleep(5);
|
||||
continue;
|
||||
}
|
||||
if !isRec {
|
||||
break;
|
||||
}
|
||||
|
||||
cache.append(data);
|
||||
if skiped {
|
||||
//读取到了回调数量的数据
|
||||
if cache.count>=bufferLen {
|
||||
let d1=cache.subdata(with: NSMakeRange(0, bufferLen));
|
||||
let d2=cache.subdata(with: NSMakeRange(bufferLen, cache.count-bufferLen));
|
||||
cache.setData(d2);
|
||||
|
||||
if(logData) {
|
||||
logStreamFull.append(d1);
|
||||
}
|
||||
readTotal+=d1.count;
|
||||
duration=readTotal/(sampleRateSrc/1000)/2;
|
||||
sendCount+=1;
|
||||
main.runScript(RecordAppJsBridge.JsRequestName+".Record(\"\(d1.base64EncodedString())\",\(sampleRateSrc))");
|
||||
|
||||
if(!firstLog){
|
||||
main.Log.i(RecordApis.LogTag, "获取到了第一段录音数据:len:\(d1.count) bufferLen:\(bufferLen) sampleRate:\(sampleRateSrc)");
|
||||
firstLog=true;
|
||||
}
|
||||
|
||||
|
||||
//进行匀速回调,因为AVAudioRecorder写入的数据有蛮大延迟,所以最终结果也会有蛮大延迟,用AudioQueue、AudioUnit低级OC api会好很多,但太低级了,复杂难用。
|
||||
let delay=Int64(itemMs-10) - (Code.GetMS()-prevTime)
|
||||
if delay>0 {
|
||||
ThreadX.Sleep(Int(delay));
|
||||
}
|
||||
prevTime=Code.GetMS();
|
||||
}
|
||||
}else{
|
||||
//跳过caf文件头
|
||||
if cache.count>=skipLen {
|
||||
skiped=true;
|
||||
let d2=cache.subdata(with: NSMakeRange(skipLen, cache.count-skipLen));
|
||||
cache.setData(d2);
|
||||
}
|
||||
}
|
||||
}
|
||||
input?.closeFile();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/****Code.swift 公共函数****/
|
||||
private class Code{
|
||||
static public func GetMS(_ date:Date=Date())->Int64{
|
||||
return Int64(round(date.timeIntervalSince1970*1000));
|
||||
}
|
||||
|
||||
static public func ParseDic(_ json:String)->Dictionary<String,Any?>{
|
||||
var rtv:[String:Any?]?=nil;
|
||||
let data = json.data(using: .utf8)!
|
||||
|
||||
let dic = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers)
|
||||
if dic != nil {
|
||||
rtv=dic as? [String:Any?];
|
||||
}
|
||||
return rtv ?? [:];
|
||||
}
|
||||
|
||||
|
||||
//****extension Dictionary where Key == String*****
|
||||
|
||||
static public func ToJson<T>(_ Self:Dictionary<String,T>)->String{
|
||||
var rtv:String?=nil;
|
||||
do {
|
||||
let data=try JSONSerialization.data(withJSONObject: Self, options:[]);
|
||||
rtv=String.init(data: data, encoding: .utf8);
|
||||
} catch {
|
||||
//NOOP
|
||||
}
|
||||
return rtv ?? "{}";
|
||||
}
|
||||
|
||||
static public func GetDic<T>(_ Self:Dictionary<String,T>, _ key:String)->Dictionary<String, Any?>{
|
||||
let valO:Any?=Self[key];
|
||||
if let val=valO {
|
||||
return (val as? Dictionary<String, Any?>) ?? [:];
|
||||
}else{
|
||||
return [:];
|
||||
}
|
||||
}
|
||||
|
||||
static public func GetString<T>(_ Self:Dictionary<String,T>, _ key:String)->String{
|
||||
let val=Self[key];
|
||||
if val != nil {
|
||||
let obj=val as AnyObject;
|
||||
return "\(obj)";
|
||||
}else{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
static public func GetInt<T>(_ Self:Dictionary<String,T>, _ key:String)->Int{
|
||||
let valO:Any?=Self[key];
|
||||
if let val = valO {
|
||||
if val is Int {
|
||||
return val as! Int;
|
||||
}
|
||||
if val is Int32 {
|
||||
return Int(val as! Int32);
|
||||
}
|
||||
|
||||
return Int(GetString(Self, key)) ?? 0;
|
||||
}else{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//*********ThreadX.swift******************
|
||||
public class ThreadX{
|
||||
private init(){};
|
||||
|
||||
static public func Sleep(_ millisecond:Int){
|
||||
Thread.sleep(forTimeInterval: TimeInterval(Double(millisecond)/1000.0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台运行任务
|
||||
*/
|
||||
static public func Run(_ run:@escaping ()->Void){
|
||||
DispatchQueue.global().async {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在主ui线程执行
|
||||
*/
|
||||
static public func UI(_ run:@escaping ()->Void){
|
||||
DispatchQueue.main.async {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时在后台执行任务,返回值可通过ClearTimeout来终止定时任务
|
||||
*/
|
||||
static public func SetTimeout(_ timeoutMillisecond:Int, _ run:@escaping ()->Void)->Int{
|
||||
return __SetTimeout(DispatchQueue.global(), timeoutMillisecond, run);
|
||||
}
|
||||
static private func __SetTimeout(_ queue:DispatchQueue, _ timeoutMillisecond:Int, _ run:@escaping ()->Void)->Int{
|
||||
let timer=DispatchSource.makeTimerSource(flags: [], queue: queue);
|
||||
|
||||
objc_sync_enter(Lock);
|
||||
defer{
|
||||
objc_sync_exit(Lock);
|
||||
}
|
||||
let obj=Timeout();
|
||||
obj.timer=timer;
|
||||
intIdx+=1;
|
||||
obj.idx=intIdx;
|
||||
intTimes.updateValue(obj, forKey: obj.idx);
|
||||
|
||||
timer.schedule(deadline: .now()+Double(timeoutMillisecond)/1000);
|
||||
timer.setEventHandler{
|
||||
obj.timer?.cancel();
|
||||
obj.timer=nil;
|
||||
if(obj.isCancel){
|
||||
return;
|
||||
}
|
||||
ClearTimeout(obj.idx);
|
||||
|
||||
run();
|
||||
};
|
||||
timer.resume();
|
||||
return obj.idx;
|
||||
}
|
||||
/**
|
||||
* 取消定时任务
|
||||
*/
|
||||
static public func ClearTimeout(_ set:Int){
|
||||
objc_sync_enter(Lock);
|
||||
defer{
|
||||
objc_sync_exit(Lock);
|
||||
}
|
||||
|
||||
if let obj=intTimes.removeValue(forKey: set) {
|
||||
obj.timer?.cancel();
|
||||
obj.timer=nil;
|
||||
obj.isCancel=true;
|
||||
}
|
||||
}
|
||||
|
||||
static private var intIdx=100;
|
||||
static private let Lock="";
|
||||
static private var intTimes: [Int:Timeout]=[:];
|
||||
private class Timeout{
|
||||
public var timer:DispatchSourceTimer? = nil;
|
||||
public var isCancel:Bool = false;
|
||||
public var idx:Int=0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public protocol RecordApp_ILog{
|
||||
func i(_ tag:String, _ msg:String);
|
||||
func e(_ tag:String, _ msg:String);
|
||||
}
|
||||
38
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/AppDelegate.swift
generated
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
import UIKit
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
158
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/Contents.json
generated
vendored
Normal file
@ -0,0 +1,158 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"size": "20x20",
|
||||
"idiom": "iphone",
|
||||
"filename": "icon-20@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "20x20",
|
||||
"idiom": "iphone",
|
||||
"filename": "icon-20@3x.png",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"size": "29x29",
|
||||
"idiom": "iphone",
|
||||
"filename": "icon-29.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "29x29",
|
||||
"idiom": "iphone",
|
||||
"filename": "icon-29@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "29x29",
|
||||
"idiom": "iphone",
|
||||
"filename": "icon-29@3x.png",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"size": "40x40",
|
||||
"idiom": "iphone",
|
||||
"filename": "icon-40@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "40x40",
|
||||
"idiom": "iphone",
|
||||
"filename": "icon-40@3x.png",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"size": "57x57",
|
||||
"idiom": "iphone",
|
||||
"filename": "icon-57.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "57x57",
|
||||
"idiom": "iphone",
|
||||
"filename": "icon-57@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "60x60",
|
||||
"idiom": "iphone",
|
||||
"filename": "icon-60@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "60x60",
|
||||
"idiom": "iphone",
|
||||
"filename": "icon-60@3x.png",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"size": "20x20",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-20-ipad.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "20x20",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-20@2x-ipad.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "29x29",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-29-ipad.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "29x29",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-29@2x-ipad.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "40x40",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-40.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "40x40",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-40@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "50x50",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-50.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "50x50",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-50@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "72x72",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-72.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "72x72",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-72@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "76x76",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-76.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "76x76",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-76@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "83.5x83.5",
|
||||
"idiom": "ipad",
|
||||
"filename": "icon-83.5@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "1024x1024",
|
||||
"idiom": "ios-marketing",
|
||||
"filename": "icon-1024.png",
|
||||
"scale": "1x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xiangyuecn"
|
||||
}
|
||||
}
|
||||
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-1024.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-20-ipad.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 817 B |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-29-ipad.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-29.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-40.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-50.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-50@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-57.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-57@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-72.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-72@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-76.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
6
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Assets.xcassets/Contents.json
generated
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
31
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Base.lproj/LaunchScreen.storyboard
generated
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina6_1" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="9gs-UL-vZc"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="cAL-tn-Bfn"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" red="1" green="0.40000000000000002" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
60
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Base.lproj/Main.storyboard
generated
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina6_1" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Main View-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="MainView" customModule="recorder" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="J6Y-SV-BAx"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="7v9-m6-cJz"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jgm-hi-xg7">
|
||||
<rect key="frame" x="0.0" y="44" width="414" height="80"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="80" id="ctb-1v-xbW"/>
|
||||
</constraints>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="v6B-l9-iVV">
|
||||
<rect key="frame" x="0.0" y="132" width="414" height="730"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="jgm-hi-xg7" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" id="239-rt-CJJ"/>
|
||||
<constraint firstItem="v6B-l9-iVV" firstAttribute="bottom" secondItem="7v9-m6-cJz" secondAttribute="top" id="4bF-sI-PfP"/>
|
||||
<constraint firstItem="jgm-hi-xg7" firstAttribute="leading" secondItem="v6B-l9-iVV" secondAttribute="leading" id="Y2N-PR-Fzg"/>
|
||||
<constraint firstAttribute="trailing" secondItem="jgm-hi-xg7" secondAttribute="trailing" id="f2T-sE-nDR"/>
|
||||
<constraint firstItem="jgm-hi-xg7" firstAttribute="top" secondItem="J6Y-SV-BAx" secondAttribute="bottom" id="mhu-Yl-DfU"/>
|
||||
<constraint firstItem="jgm-hi-xg7" firstAttribute="trailing" secondItem="v6B-l9-iVV" secondAttribute="trailing" id="snd-iE-ekH"/>
|
||||
<constraint firstItem="v6B-l9-iVV" firstAttribute="top" secondItem="jgm-hi-xg7" secondAttribute="bottom" constant="8" symbolic="YES" id="xoE-Nr-SRY"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="logs" destination="jgm-hi-xg7" id="3ce-r2-I0S"/>
|
||||
<outlet property="webViewBox" destination="v6B-l9-iVV" id="jwE-un-Day"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="37.681159420289859" y="18.75"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
51
WebRoot/node_modules/Recorder-master/app-support-sample/demo_ios/recorder/ios/Info.plist
generated
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>要录音,不给权限要弹小jj的</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Recorder Demo</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleLightContent</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
938
WebRoot/node_modules/Recorder-master/app-support-sample/index.html
generated
vendored
Normal file
@ -0,0 +1,938 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||
<link rel="shortcut icon" type="image/png" DEF="/*=:=*/" href="../assets/icon.png"
|
||||
DEF="/*<@ href='https://xiangyuecn.github.io/Recorder/assets/icon.png' @>*/">
|
||||
|
||||
<title>RecordApp测试</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="main">
|
||||
|
||||
<script>
|
||||
//这里有几处编译指令,可以忽略掉,因为这个文件可以双击打开、github.io打开;主要的还是通过我的代理服务器来打开,这时候后面的值会生效,因为微信JsSDK只能在绑定的域名下调用。
|
||||
var PageSet_RecordAppBaseFolder=/*=:=*/ "../src/" /*<@ "https://xiangyuecn.github.io/Recorder/src/" @>*/;
|
||||
|
||||
/*=:=*/ /*<@
|
||||
//crossorigin
|
||||
document.body._addChild=document.body.appendChild;
|
||||
document.body.appendChild=function(s){
|
||||
this._addChild(s);
|
||||
if(s.tagName=="SCRIPT"){
|
||||
s.crossOrigin="anonymous";
|
||||
};
|
||||
return this;
|
||||
};
|
||||
@>*/
|
||||
</script>
|
||||
|
||||
<!-- 加载独立配置文件,免得修改app.js -->
|
||||
<script
|
||||
DEF="/*=:=*/" src="../app-support-sample/native-config.js"
|
||||
DEF="/*<@ crossorigin='anonymous' src='https://xiangyuecn.github.io/Recorder/app-support-sample/native-config.js' @>*/"
|
||||
></script>
|
||||
<script
|
||||
DEF="/*=:=*/" src="../app-support-sample/ios-weixin-config.js"
|
||||
DEF="/*<@ crossorigin='anonymous' src='https://xiangyuecn.github.io/Recorder/app-support-sample/ios-weixin-config.js' @>*/"
|
||||
></script>
|
||||
|
||||
<!-- 加载app.js -->
|
||||
<script
|
||||
DEF="/*=:=*/" src="../src/app-support/app.js"
|
||||
DEF="/*<@ crossorigin='anonymous' src='https://xiangyuecn.github.io/Recorder/src/app-support/app.js' @>*/"
|
||||
></script>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
//注册可选扩展库
|
||||
var paths=RecordApp.Platforms.Default.Config.paths;
|
||||
paths.push({
|
||||
url:PageSet_RecordAppBaseFolder+"extensions/waveview.js"
|
||||
,lazyBeforeStart:1 //开启延迟加载,在Start调用前任何时间进行加载都行
|
||||
,check:function(){return !Recorder.WaveView}
|
||||
});
|
||||
paths.push({
|
||||
url:PageSet_RecordAppBaseFolder+"extensions/wavesurfer.view.js"
|
||||
,lazyBeforeStart:1
|
||||
,check:function(){return !Recorder.WaveSurferView}
|
||||
});
|
||||
paths.push({
|
||||
url:PageSet_RecordAppBaseFolder+"extensions/lib.fft.js"
|
||||
,lazyBeforeStart:1
|
||||
,check:function(){return !Recorder.LibFFT}
|
||||
});
|
||||
paths.push({
|
||||
url:PageSet_RecordAppBaseFolder+"extensions/frequency.histogram.view.js"
|
||||
,lazyBeforeStart:1
|
||||
,check:function(){return !Recorder.FrequencyHistogramView}
|
||||
});
|
||||
|
||||
//可以设置组件是否进行延迟加载,默认会延迟加载,不会阻塞Install
|
||||
RecordApp.UseLazyLoad=!(+localStorage["RecordApp_UseLazyLoadDisable"]||0);
|
||||
|
||||
//立即加载环境,自动把Recorder加载进来
|
||||
RecordApp.Install(function(){
|
||||
console.log("RecordApp.Install成功");
|
||||
isInstall=true;
|
||||
window.onInstall&&onInstall();
|
||||
},function(err){
|
||||
var msg="RecordApp.Install出错:"+err;
|
||||
console.log(msg);
|
||||
alert(msg);
|
||||
});
|
||||
|
||||
})();
|
||||
var isInstall=false;
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
body{
|
||||
word-wrap: break-word;
|
||||
background:#f5f5f5 center top no-repeat;
|
||||
background-size: auto 680px;
|
||||
}
|
||||
pre{
|
||||
white-space:pre-wrap;
|
||||
}
|
||||
a{
|
||||
text-decoration: none;
|
||||
color:#06c;
|
||||
}
|
||||
a:hover{
|
||||
color:#f00;
|
||||
}
|
||||
|
||||
.main{
|
||||
max-width:700px;
|
||||
margin:0 auto;
|
||||
padding-bottom:80px
|
||||
}
|
||||
|
||||
.mainBox{
|
||||
margin-top:12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
--border: 1px solid #f60;
|
||||
box-shadow: 2px 2px 3px #aaa;
|
||||
}
|
||||
|
||||
|
||||
.mainBtn{
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: #f60;
|
||||
color:#fff;
|
||||
padding: 0 15px;
|
||||
margin:3px 20px 3px 0;
|
||||
line-height: 36px;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mainBtn:active{
|
||||
background: #f00;
|
||||
}
|
||||
|
||||
.recwaveChoice{
|
||||
cursor: pointer;
|
||||
display:inline-block;
|
||||
vertical-align: bottom;
|
||||
border-right:1px solid #ccc;
|
||||
background:#ddd;
|
||||
line-height:28px;
|
||||
font-size:12px;
|
||||
color:#666;
|
||||
padding:0 5px;
|
||||
}
|
||||
.recwaveChoice:first-child{
|
||||
border-radius: 99px 0 0 99px;
|
||||
}
|
||||
.recwaveChoice:last-child{
|
||||
border-radius: 0 99px 99px 0;
|
||||
border-right:none;
|
||||
}
|
||||
.recwaveChoice.slc,.recwaveChoice:hover{
|
||||
background:#f60;
|
||||
color:#fff;
|
||||
}
|
||||
|
||||
.lb{
|
||||
display:inline-block;
|
||||
vertical-align: middle;
|
||||
background:#00940e;
|
||||
color:#fff;
|
||||
font-size:14px;
|
||||
padding:2px 8px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
|
||||
.pd{
|
||||
padding:0 0 6px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
//兼容环境
|
||||
var PageLM="2020-1-12 11:44:28";
|
||||
|
||||
function RandomKey(){
|
||||
return "randomkey"+(RandomKey.idx++);
|
||||
};
|
||||
RandomKey.idx=0;
|
||||
</script>
|
||||
<script
|
||||
DEF="/*=:=*/" src="../assets/ztest-jquery.min-1.9.1.js"
|
||||
DEF="/*<@ crossorigin='anonymous' src='https://xiangyuecn.github.io/Recorder/assets/ztest-jquery.min-1.9.1.js' @>*/"
|
||||
></script>
|
||||
|
||||
|
||||
<div class="mainBox">
|
||||
<style>
|
||||
.navItem{
|
||||
display:inline-block;
|
||||
width:45%;
|
||||
max-width:300px;
|
||||
vertical-align: top;
|
||||
background:#eee;
|
||||
border-bottom: 5px solid #ccc;
|
||||
box-shadow: 2px 2px 3px #ddd;
|
||||
color:#666;
|
||||
text-decoration:none;
|
||||
border-radius: 8px;
|
||||
padding: 0 5px 3px;
|
||||
}
|
||||
.navItem.slc{
|
||||
border-bottom: 5px solid #00940e;
|
||||
color:#f60;
|
||||
}
|
||||
.navItem:hover{
|
||||
color:#d44;
|
||||
}
|
||||
.navTitle{
|
||||
text-align: center;
|
||||
font-size:18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.navItem.slc .navDesc{
|
||||
color:#00940e;
|
||||
}
|
||||
.navDesc{
|
||||
font-size:12px;
|
||||
}
|
||||
</style>
|
||||
<a class="navItem" style="margin-right:2%;" href="https://xiangyuecn.github.io/Recorder/">
|
||||
<div class="navTitle">Recorder H5</div>
|
||||
<div class="navDesc">Recorder H5使用简单,功能丰富,支持PC、Android,但IOS上仅Safari支持录音</div>
|
||||
</a>
|
||||
|
||||
<a class="navItem slc" href="https://jiebian.life/web/h5/github/recordapp.aspx">
|
||||
<div class="navTitle">RecordApp</div>
|
||||
<div class="navDesc">RecordApp除Recorder支持的外,支持Hybrid App,IOS上支持微信网页和小程序web-view</div>
|
||||
</a>
|
||||
|
||||
<div style="margin-top:8px" class="pd">
|
||||
<span style="font-size:18px;color:#ef6ea8">似乎仅为仅为兼容IOS而生</span>
|
||||
RecordApp会加载Recorder,因此算是完全兼容Recorder。在开启了原生App支持(Platforms.Native)的情况下,Hybrid App内会走App原生录音;在开启IOS微信支持(Platforms.IOS-Weixin)的情况下,在IOS微信内会走微信JsSDK录音;其他情况走Recorder。
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<span class="lb">GitHub :</span> <a href="https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample">https://github.com/xiangyuecn/Recorder/ => app-support-sample</a>
|
||||
</div>
|
||||
<div style="margin-top:6px;">
|
||||
<span class="lb">QuickStart :</span>
|
||||
<a href="https://jiebian.life/web/h5/github/recordapp.aspx?path=/app-support-sample/QuickStart.html" target="_blank">/app-support-sample/QuickStart.html</a>
|
||||
<span style="font-size:12px;color:#999">(Copy+后端微信接口即可使用,更适合入门学习)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="mainBox">
|
||||
<div class="pd types">
|
||||
<span class="lb">类型 :</span>
|
||||
<label><input type="radio" name="type" value="mp3" engine="mp3,mp3-engine" class="initType" checked>mp3</label>
|
||||
<label><input type="radio" name="type" value="wav" engine="wav">wav</label>
|
||||
<label><input type="radio" name="type" value="ogg" engine="beta-ogg,beta-ogg-engine">ogg(beta)</label>
|
||||
<label><input type="radio" name="type" value="webm" engine="beta-webm">webm(beta)</label>
|
||||
<label><input type="radio" name="type" value="amr" engine="beta-amr,beta-amr-engine,wav">amr(beta)</label>
|
||||
</div>
|
||||
<div class="pd">
|
||||
<span class="lb">提示 :</span> <span class="typeTips">-</span>
|
||||
</div>
|
||||
<div class="pd">
|
||||
<span class="lb">比特率 :</span> <input type="text" class="bit" value="16" style="width:60px"> kbps,越大音质越好
|
||||
</div>
|
||||
<div class="pd">
|
||||
<span class="lb">采样率 :</span> <input type="text" class="sample" value="16000" style="width:60px"> hz,越大细节越丰富
|
||||
</div>
|
||||
<div class="pd">
|
||||
<span class="lb">JsSDK :</span> <label><input type="checkbox" class="alwaysUseWeixinJS">Android微信内也用JsSDK</label>
|
||||
</div>
|
||||
<div>
|
||||
<span class="lb">AppUseJS :</span> <label><input type="checkbox" class="alwaysAppUseJS">App里面总是使用Recorder H5录音</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mainBox">
|
||||
<div class="pd">
|
||||
<button class="mainBtn"onclick="recreq()">请求权限</button>
|
||||
|
||||
<button class="mainBtn" onclick="recstart()">录制</button>
|
||||
<button class="mainBtn" onclick="recstop()" style="margin-right:80px;">停止</button>
|
||||
|
||||
<button onclick="recstopX()" style="margin-right:40px;">停止(仅清理)</button>
|
||||
|
||||
<span style="display: inline-block;">
|
||||
<button onclick="recstartAndAutoStop()">录制+定时停止</button><input value="0" class="autoStopTime" style="width:60px;">ms
|
||||
</span>
|
||||
</div>
|
||||
<div class="pd recpower">
|
||||
<div style="height:40px;width:300px;background:#999;position:relative;">
|
||||
<div class="recpowerx" style="height:40px;background:#0B1;position:absolute;"></div>
|
||||
<div class="recpowert" style="padding-left:50px; line-height:40px; position: relative;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pd">
|
||||
<div style="border:1px solid #ccc;display:inline-block"><div style="height:100px;width:300px;" class="recwave"></div></div>
|
||||
|
||||
<span style="font-size:0">
|
||||
<span class="recwaveChoice" key="WaveView">WaveView</span>
|
||||
<span class="recwaveChoice" key="SurferView">SurferView</span>
|
||||
<span class="recwaveChoice" key="Histogram1">Histogram1</span>
|
||||
<span class="recwaveChoice" key="Histogram2">H...2</span>
|
||||
<span class="recwaveChoice" key="Histogram3">H...3</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pd">
|
||||
<label><input type="checkbox" class="realTimeSendSet">模拟准实时编码传输(H5版语音通话聊天)</label>
|
||||
,发送间隔<input type="text" class="realTimeSend" value="996" style="width:60px">ms
|
||||
<div class="webrtcView" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="">
|
||||
<label><input type="checkbox" class="takeoffEncodeChunkSet">接管编码器输出(takeoffEncodeChunk)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mainBox">
|
||||
<audio class="recPlay" style="width:100%"></audio>
|
||||
<div class="reclog"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mainBox">
|
||||
<span class="lb">测试App :</span>
|
||||
IOS Demo App:<a href="https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample/demo_ios">下载源码</a> 自行编译
|
||||
|
||||
,Android Demo App:<a href="https://xiangyuecn.github.io/Recorder/app-support-sample/demo_android/app-debug.apk.zip" download="app-debug.apk">下载APK</a> (40kb,删除.zip后缀,<a href="https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample/demo_android">源码</a>)
|
||||
</div>
|
||||
|
||||
<div class="mainBox">
|
||||
<span class="lb">浏览器环境情况 :</span>
|
||||
<pre class="recinfoCode">
|
||||
IsWx:${!!RecordApp.IsWx}
|
||||
IsApp:${isApp}
|
||||
Platforms:${RecordApp.Current.Key}
|
||||
|
||||
AudioContext:${!!window.AudioContext}
|
||||
webkitAudioContext:${!!window.webkitAudioContext}
|
||||
mediaDevices:${!!navigator.mediaDevices}
|
||||
mediaDevices.getUserMedia:${!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia)}
|
||||
navigator.getUserMedia:${!!navigator.getUserMedia}
|
||||
navigator.webkitGetUserMedia:${!!navigator.webkitGetUserMedia}
|
||||
|
||||
URL:${location.href.replace(/#.+/g,"")}
|
||||
UA:${navigator.userAgent}
|
||||
|
||||
RecordApp库修改时间(有可能修改了忘改):${RecordApp.LM}
|
||||
Recorder库修改时间(有可能修改了忘改):${Recorder.LM}
|
||||
本页面修改时间(有可能修改了忘改):${PageLM}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
function reclog(s,color){
|
||||
var now=new Date();
|
||||
var t=("0"+now.getHours()).substr(-2)
|
||||
+":"+("0"+now.getMinutes()).substr(-2)
|
||||
+":"+("0"+now.getSeconds()).substr(-2);
|
||||
$(".reclog").prepend('<div style="color:'+(!color?"":color==1?"red":color==2?"#0b1":color)+'">['+t+']'+s+'</div>');
|
||||
};
|
||||
window.onerror=function(message, url, lineNo, columnNo, error){
|
||||
//https://www.cnblogs.com/xianyulaodi/p/6201829.html
|
||||
reclog('<span style="color:red">【Uncaught Error】'+message+'<pre>'+"at:"+lineNo+":"+columnNo+" url:"+url+"\n"+(error&&error.stack||"不能获得错误堆栈")+'</pre></span>');
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
function baseSet(){
|
||||
var alwaysUseWeixinJS=$(".alwaysUseWeixinJS")[0].checked;
|
||||
if(!!RecordApp.alwaysUseWeixinJSPrev!=alwaysUseWeixinJS){
|
||||
reclog("JsSDK选项变更,已重置RecordApp,请先进行权限测试");
|
||||
RecordApp.Current=null;
|
||||
RecordApp.alwaysUseWeixinJSPrev=alwaysUseWeixinJS;
|
||||
};
|
||||
RecordApp.AlwaysUseWeixinJS=alwaysUseWeixinJS;
|
||||
|
||||
var alwaysAppUseJS=$(".alwaysAppUseJS")[0].checked;
|
||||
if(!!RecordApp.alwaysAppUseJSPrev!=alwaysAppUseJS){
|
||||
reclog("AppUseJS选项变更,已重置RecordApp,请先进行权限测试");
|
||||
RecordApp.Current=null;
|
||||
RecordApp.alwaysAppUseJSPrev=alwaysAppUseJS;
|
||||
};
|
||||
RecordApp.AlwaysAppUseJS=alwaysAppUseJS;
|
||||
|
||||
cancelAutoStop();
|
||||
};
|
||||
function recreq(){
|
||||
baseSet();
|
||||
reclog("开始请求授权...");
|
||||
if(!isInstall){
|
||||
reclog("还在初始化环境加载js库,可能比较慢,初始化完成后将调用请求授权","#f60");
|
||||
};
|
||||
|
||||
|
||||
dialogInt=setTimeout(function(){//定时8秒后打开弹窗,主要用于监测浏览器没有发起权限请求的情况,在RequestPermission前放置定时器利于收到了回调能及时取消(不管open是同步还是异步回调的)
|
||||
showDialog();
|
||||
},8000);
|
||||
|
||||
RecordApp.RequestPermission(function(){
|
||||
dialogCancel();
|
||||
reclog(RecordApp.Current.Key+"已授权",2);
|
||||
},function(err,isUserNotAllow){
|
||||
dialogCancel();
|
||||
reclog(RecordApp.Current.Key+(isUserNotAllow?" UserNotAllow,":"")+" 授权失败:"+err,1);
|
||||
});
|
||||
|
||||
window.waitDialogClick=function(){
|
||||
dialogCancel();
|
||||
reclog("打开失败:权限请求被忽略,<span style='color:#f00'>用户主动点击的弹窗</span>");
|
||||
};
|
||||
};
|
||||
|
||||
//我们可以选择性的弹一个对话框:为了防止移动端浏览器存在第三种情况:用户忽略,并且(或者国产系统UC系)浏览器没有任何回调
|
||||
var showDialog=function(){
|
||||
if(!/mobile/i.test(navigator.userAgent)){
|
||||
return;//只在移动端开启没有权限请求的检测
|
||||
};
|
||||
dialogCancel();
|
||||
$("body").append(''
|
||||
+'<div class="waitDialog" style="z-index:99999;width:100%;height:100%;top:0;left:0;position:fixed;background:rgba(0,0,0,0.3);">'
|
||||
+'<div style="display:flex;height:100%;align-items:center;">'
|
||||
+'<div style="flex:1;"></div>'
|
||||
+'<div style="width:240px;background:#fff;padding:15px 20px;border-radius: 10px;">'
|
||||
+'<div style="padding-bottom:10px;">录音功能需要麦克风权限,请允许;如果未看到任何请求,请点击忽略~</div>'
|
||||
+'<div style="text-align:center;"><a onclick="waitDialogClick()" style="color:#0B1">忽略</a></div>'
|
||||
+'</div>'
|
||||
+'<div style="flex:1;"></div>'
|
||||
+'</div>'
|
||||
+'</div>');
|
||||
};
|
||||
var dialogInt;
|
||||
var dialogCancel=function(){
|
||||
clearTimeout(dialogInt);
|
||||
$(".waitDialog").remove();
|
||||
};
|
||||
//弹框End
|
||||
|
||||
|
||||
var curSet,autoStopTimer;
|
||||
function recstartAndAutoStop(){
|
||||
var time=+$(".autoStopTime").val()||0;
|
||||
if(time<100){
|
||||
reclog("定时不能小于100ms",1);
|
||||
return;
|
||||
};
|
||||
recstart(function(msg){
|
||||
if(msg){
|
||||
msg&&reclog(msg,1);
|
||||
return;
|
||||
};
|
||||
reclog("定时"+time+"ms后自动停止录音");
|
||||
autoStopTimer=setTimeout(function(){
|
||||
autoStopTimer=0;
|
||||
reclog("定时时间到,开始自动调用停止...");
|
||||
recstop();
|
||||
},time);
|
||||
});
|
||||
};
|
||||
var cancelAutoStop=function(){
|
||||
if(autoStopTimer){
|
||||
reclog("已取消定时停止",1);
|
||||
clearTimeout(autoStopTimer);
|
||||
autoStopTimer=0;
|
||||
};
|
||||
};
|
||||
function recstart(call){
|
||||
call||(call=function(msg){
|
||||
msg&&reclog(msg,1);
|
||||
});
|
||||
baseSet();
|
||||
if(!RecordApp.Current){
|
||||
call("需先调用RequestPermission");
|
||||
return;
|
||||
};
|
||||
if(RecordApp.Current==RecordApp.Platforms.Weixin){
|
||||
reclog("正在使用微信JsSDK,录音过程中不会有任何回调,不要惊慌");
|
||||
}else if(RecordApp.Current==RecordApp.Platforms.Native){
|
||||
reclog("正在使用Native录音,底层由App原生层提供支持");
|
||||
}else{
|
||||
reclog("正在使用H5录音,底层由Recorder直接提供支持");
|
||||
};
|
||||
|
||||
var type=$("[name=type]:checked").val();
|
||||
var bit=+$(".bit").val();
|
||||
var sample=+$(".sample").val();
|
||||
window.waveStore={};
|
||||
window.takeoffChunks=[];
|
||||
|
||||
var realTimeSendSet=$(".realTimeSendSet")[0].checked;
|
||||
var realTimeSendTime=+$(".realTimeSend").val();
|
||||
window.realTimeSendTryReset&&realTimeSendTryReset();
|
||||
if(realTimeSendSet&&!RecordApp.Current.CanProcess()){
|
||||
reclog("当前环境"+RecordApp.Current.Key+"不支持实时回调,不能模拟实时编码传输",1);
|
||||
};
|
||||
|
||||
var takeoffEncodeChunkSet=$(".takeoffEncodeChunkSet")[0].checked;
|
||||
|
||||
|
||||
var set={
|
||||
type:type
|
||||
,bitRate:bit
|
||||
,sampleRate:sample
|
||||
,onProcess:function(buffers,powerLevel,duration,sampleRate){
|
||||
$(".recpowerx").css("width",powerLevel+"%");
|
||||
$(".recpowert").text(formatMs(duration,1)+" / "+powerLevel);
|
||||
|
||||
//可视化图形绘制
|
||||
if(waveStore[recwaveChoiceKey]){
|
||||
if(waveStore.choice!=recwaveChoiceKey){
|
||||
waveStore.choice=recwaveChoiceKey;
|
||||
$(".recwave").html("").append(waveStore[recwaveChoiceKey].elem);
|
||||
};
|
||||
waveStore[recwaveChoiceKey].input(buffers[buffers.length-1],powerLevel,sampleRate);
|
||||
};
|
||||
|
||||
//实时传输
|
||||
if(realTimeSendSet&&window.realTimeSendTry){
|
||||
realTimeSendTry(set,realTimeSendTime,buffers,sampleRate);
|
||||
};
|
||||
}
|
||||
,takeoffEncodeChunk:!takeoffEncodeChunkSet?null:function(chunkBytes){
|
||||
takeoffChunks.push(chunkBytes);
|
||||
}
|
||||
};
|
||||
curSet=null;
|
||||
reclog(RecordApp.Current.Key+"正在打开...");
|
||||
RecordApp.Start(set,function(){
|
||||
curSet=set;
|
||||
reclog(RecordApp.Current.Key+"已打开:"+type+" "+bit+"kbps",2);
|
||||
|
||||
//此处创建这些音频可视化图形绘制浏览器支持妥妥的
|
||||
waveStore.WaveView=Recorder.WaveView({elem:".recwave"});
|
||||
waveStore.SurferView=Recorder.WaveSurferView({elem:".recwave"});
|
||||
waveStore.Histogram1=Recorder.FrequencyHistogramView({elem:".recwave"});
|
||||
waveStore.Histogram2=Recorder.FrequencyHistogramView({
|
||||
elem:".recwave"
|
||||
,lineCount:90
|
||||
,position:0
|
||||
,minHeight:1
|
||||
,stripeEnable:false
|
||||
});
|
||||
waveStore.Histogram3=Recorder.FrequencyHistogramView({
|
||||
elem:".recwave"
|
||||
,lineCount:10
|
||||
,position:0
|
||||
,minHeight:1
|
||||
,fallDuration:400
|
||||
,stripeEnable:false
|
||||
,mirrorEnable:true
|
||||
,linear:[0,"#0ac",1,"#0ac"]
|
||||
});
|
||||
|
||||
call();
|
||||
},function(err){
|
||||
call(RecordApp.Current.Key+"打开失败:"+err);
|
||||
});
|
||||
};
|
||||
function recstopX(){
|
||||
cancelAutoStop();
|
||||
RecordApp.Stop(
|
||||
null //success传null就只会清理资源,不会进行转码
|
||||
,function(msg){
|
||||
reclog("已清理,错误信息:"+msg);
|
||||
}
|
||||
);
|
||||
};
|
||||
var recblob={};
|
||||
function recstop(call){
|
||||
var set=curSet;
|
||||
recstopFn(call,true,function(err,blob,time){
|
||||
setTimeout(function(){
|
||||
window.realTimeSendTryStop&&realTimeSendTryStop(set);
|
||||
|
||||
if(!err && set.takeoffEncodeChunk){
|
||||
reclog("启用takeoffEncodeChunk后Stop返回的blob长度为0不提供音频数据","#f60");
|
||||
reclog("takeoffEncodeChunk接收到"+takeoffChunks.length+"片音频片段,正在合并成一个音频文件...");
|
||||
var len=0;
|
||||
for(var i=0;i<takeoffChunks.length;i++){
|
||||
len+=takeoffChunks[i].length;
|
||||
};
|
||||
var chunkData=new Uint8Array(len);
|
||||
for(var i=0,idx=0;i<takeoffChunks.length;i++){
|
||||
var itm=takeoffChunks[i];
|
||||
chunkData.set(itm,idx);
|
||||
idx+=itm.length;
|
||||
};
|
||||
var blob=new Blob([chunkData],{type:"audio/"+set.type});
|
||||
addRecLog(time,"合并",blob,set,Date.now());
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
function recstopFn(call,isClick,endCall,rec){
|
||||
cancelAutoStop();
|
||||
call||(call=function(msg){
|
||||
msg&&reclog(msg,1);
|
||||
});
|
||||
|
||||
var t1=Date.now();
|
||||
if(!isClick){
|
||||
rec.stop(function(blob,time){
|
||||
var tag=endCall("",blob,time);
|
||||
if(tag==-1){
|
||||
return;
|
||||
};
|
||||
|
||||
addRecLog(time,tag||"已录制",blob,rec.set,t1);
|
||||
},function(s){
|
||||
reclog("失败:"+s);
|
||||
endCall(s);
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
var setData=curSet;
|
||||
curSet=null;
|
||||
|
||||
if(!setData){
|
||||
//没有开始?不管,stop清理资源
|
||||
setData={};
|
||||
}else{
|
||||
reclog(RecordApp.Current.Key+"正在结束"+setData.type+"...");
|
||||
};
|
||||
|
||||
RecordApp.Stop(function(blob,time){
|
||||
endCall("",blob,time);
|
||||
|
||||
var wxData=setData.DownWxMediaData;//微信JsSDK 下载到的音频源文件
|
||||
if(wxData){
|
||||
var list=wxData.list;
|
||||
delete wxData.list;
|
||||
|
||||
reclog("<span style='color:#0b1'>发现微信JsSDK录制的源文件,共"+list.length+"段,时间统计:"+JSON.stringify(wxData)+"</span>");
|
||||
if(!Recorder.AMR){
|
||||
reclog("<span style='color:#fb0'>播放需要上面点击换到amr类型,加载amr解码器,目前未加载<span>");
|
||||
};
|
||||
|
||||
var t1x=t1;
|
||||
t1=Date.now();
|
||||
for(var i=0;i<list.length;i++){
|
||||
var obj=list[i];
|
||||
var bstr=atob(obj.data),n=bstr.length,u8arr=new Uint8Array(n);
|
||||
while(n--){
|
||||
u8arr[n]=bstr.charCodeAt(n);
|
||||
};
|
||||
var blob2=new Blob([u8arr.buffer], {type:obj.mime});
|
||||
addRecLog(obj.duration,"<span style='color:#0b1'>微信源片段"+(i+1)+"</span>",blob2,{type:/\/(\w+)/.exec(obj.mime)[1]},t1);
|
||||
};
|
||||
t1=t1x;
|
||||
};
|
||||
addRecLog(time,"已录制",blob,setData,t1);
|
||||
call(null,{data:blob,duration:time});
|
||||
},function(s){
|
||||
endCall(s);
|
||||
call("失败:"+s);
|
||||
});
|
||||
};
|
||||
var addRecLog=function(time,tag,blob,set,t1){
|
||||
var id=RandomKey(16);
|
||||
recblob[id]={blob:blob,set:$.extend({},set),time:time};
|
||||
reclog(tag+":"+intp(set.bitRate,3)+"kbps "+intp(set.sampleRate,5)+"hz 花"+intp(Date.now()-t1,4)+"ms编码"+intp(blob.size,6)+"b ["+set.type+"]"+formatMs(time)+'ms <button onclick="recdown(\''+id+'\')">下载</button> <button onclick="recplay(\''+id+'\')">播放</button> <span class="p'+id+'"></span> <span class="d'+id+'"></span>');
|
||||
};
|
||||
var intp=function(s,len){
|
||||
s=s==null?"-":s+"";
|
||||
if(s.length>=len)return s;
|
||||
return ("_______"+s).substr(-len);
|
||||
};
|
||||
var formatMs=function(ms,all){
|
||||
var f=Math.floor(ms/60000),m=Math.floor(ms/1000)%60;
|
||||
var s=(all||f>0?(f<10?"0":"")+f+":":"")
|
||||
+(all||f>0||m>0?("0"+m).substr(-2)+"″":"")
|
||||
+("00"+ms%1000).substr(-3);
|
||||
return s;
|
||||
};
|
||||
function recplay(key){
|
||||
var o=recblob[key];
|
||||
if(o){
|
||||
var audio=$(".recPlay")[0];
|
||||
audio.controls=true;
|
||||
if(!(audio.ended || audio.paused)){
|
||||
audio.pause();
|
||||
};
|
||||
o.play=(o.play||0)+1;
|
||||
var logmsg=function(msg){
|
||||
$(".p"+key).html('<span style="color:green">'+o.play+'</span> '+new Date().toLocaleTimeString()+" "+msg);
|
||||
};
|
||||
logmsg("");
|
||||
audio.onerror=function(e){
|
||||
logmsg('<span style="color:red">播放失败['+audio.error.code+']'+audio.error.message+'</span>');
|
||||
};
|
||||
|
||||
var end=function(blob){
|
||||
audio.src=(window.URL||webkitURL).createObjectURL(blob);
|
||||
audio.play();
|
||||
};
|
||||
var wav=Recorder[o.set.type+"2wav"];
|
||||
if(wav){
|
||||
logmsg("正在转码成wav...");
|
||||
wav(o.blob,function(blob){
|
||||
end(blob);
|
||||
logmsg("已转码成wav播放");
|
||||
},function(msg){
|
||||
logmsg("转码成wav失败:"+msg);
|
||||
});
|
||||
}else{
|
||||
end(o.blob);
|
||||
};
|
||||
};
|
||||
};
|
||||
function recdown(key){
|
||||
var o=recblob[key];
|
||||
if(o){
|
||||
var cls=RandomKey(16);
|
||||
var name="rec-"+o.time+"ms-"+o.set.bitRate+"kbps-"+o.set.sampleRate+"hz."+o.set.type;
|
||||
|
||||
o.down=(o.down||0)+1;
|
||||
$(".d"+key).html('<span style="color:red">'+o.down+'</span> 点击 <span class="'+cls+'"> 下载,或复制文本<button onclick="recdown64(\''+key+'\',\''+cls+'\')">生成Base64文本</button></span>');
|
||||
|
||||
var downA=document.createElement("A");
|
||||
downA.innerHTML="下载 "+name;
|
||||
downA.href=(window.URL||webkitURL).createObjectURL(o.blob);
|
||||
downA.download=name;
|
||||
$("."+cls).prepend(downA);
|
||||
//downA.click(); 某些软件内会跳转页面到恶心推广页
|
||||
};
|
||||
};
|
||||
function recdown64(key, cls){
|
||||
var o=recblob[key];
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onloadend = function() {
|
||||
var id=RandomKey(16);
|
||||
$("."+cls).append('<textarea class="'+id+'"></textarea>');
|
||||
$("."+id).val(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(o.blob);
|
||||
};
|
||||
|
||||
|
||||
var s="https://github.com/xiangyuecn/Recorder/blob/master/src/extensions/";
|
||||
var extensionsInfo={
|
||||
WaveView:'<b>WaveView</b> (<a href="'+s+'waveview.js">waveview.js</a> 动态波形)'
|
||||
,SurferView:'<b>WaveSurferView</b> (<a href="'+s+'wavesurfer.view.js">wavesurfer.view.js</a> 音频可视化波形)'
|
||||
,Histogram:'<b>FrequencyHistogramView</b> (<a href="'+s+'frequency.histogram.view.js">frequency.histogram.view.js</a> + <a href="'+s+'lib.fft.js">lib.fft.js</a> 音频可视化频率直方图)'
|
||||
,Sonic:'<b>Sonic</b> (<a href="'+s+'sonic.js">sonic.js</a> 变速变调)'
|
||||
};
|
||||
var recwaveChoiceKey=localStorage["RecWaveChoiceKey"]||"WaveView";
|
||||
$(".recwaveChoice").bind("click",function(e){
|
||||
var elem=$(e.target);
|
||||
$(".recwaveChoice").removeClass("slc");
|
||||
var val=elem.addClass("slc").attr("key");
|
||||
var info=extensionsInfo[val.replace(/\d+$/,"")];
|
||||
if(recwaveChoiceKey!=val){
|
||||
reclog("已切换波形显示为:"+info);
|
||||
};
|
||||
recwaveChoiceKey=val;
|
||||
localStorage["RecWaveChoiceKey"]=recwaveChoiceKey;
|
||||
});
|
||||
if(!$(".recwaveChoice[key="+recwaveChoiceKey+"]").length){
|
||||
recwaveChoiceKey="WaveView";
|
||||
};
|
||||
$(".recwaveChoice[key="+recwaveChoiceKey+"]").click();
|
||||
|
||||
|
||||
reclog("点击录制开始哦");
|
||||
reclog('已启用Extensions:'
|
||||
+extensionsInfo.WaveView
|
||||
+'、'+extensionsInfo.SurferView
|
||||
+'、'+extensionsInfo.Histogram);
|
||||
|
||||
if(window.top!=window){
|
||||
var isSelf=false;
|
||||
try{
|
||||
window.top.aa=123;
|
||||
isSelf=true;
|
||||
}catch(e){};
|
||||
|
||||
reclog("<span style='color:#f60'>当前页面处在在iframe中,但故意未进行任何处理,"+(isSelf?"当前是同域":"并且已发生跨域,未设置相应策略H5录音权限永远是拒绝的")+"</span>");
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
//实时传输数据模拟开关
|
||||
$(".realTimeSendSet").bind("change",function(e){
|
||||
var open=e.target.checked;
|
||||
$(".webrtcView")[open?"show":"hide"]();
|
||||
if(open && !window.webrtcCreate){
|
||||
var file="zdemo.index.webrtc.js";
|
||||
reclog("正在加载"+file+" ...");
|
||||
|
||||
var elem=document.createElement("script");
|
||||
elem.setAttribute("type","text/javascript");
|
||||
elem.setAttribute("src",PageSet_RecordAppBaseFolder.replace(/src\/$/,"assets/")+file);
|
||||
$("head")[0].appendChild(elem);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
if(/mobile/i.test(navigator.userAgent)){
|
||||
//移动端加载控制台组件
|
||||
var elem=document.createElement("script");
|
||||
elem.setAttribute("type","text/javascript");
|
||||
elem.setAttribute("src","https://cdn.bootcss.com/eruda/1.5.4/eruda.min.js");
|
||||
$("head")[0].appendChild(elem);
|
||||
elem.onload=function(){
|
||||
eruda.init();
|
||||
};
|
||||
};
|
||||
</script>
|
||||
<div style="padding:100px;"></div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
$(function(){
|
||||
var prev;
|
||||
$(".types").bind("click",function(e){
|
||||
var input=$(e.target);
|
||||
if(input[0].nodeName=="LABEL"){
|
||||
input=$(input).find("input");
|
||||
};
|
||||
if(prev!=input[0]){
|
||||
prev=input[0];
|
||||
loadEngine($(input));
|
||||
};
|
||||
});
|
||||
});
|
||||
function loadEngine(input){
|
||||
if(input.length&&input[0].nodeName=="INPUT"){
|
||||
var type=input.val();
|
||||
var engines=input.attr("engine").split(",");
|
||||
var end=function(){
|
||||
var enc=Recorder.prototype["enc_"+type];
|
||||
var tips=[!enc?"这个编码器无提示信息":type+"编码器"+(enc.stable?"稳定版":"beta版")+",<span style='color:"+(type=="wav"?"#0b1'>wav转码超快":Recorder.prototype[type+"_start"]?"#0b1'>支持边录边转码(Worker)":"red'>仅支持标准UI线程转码")+"</span>,"+enc.testmsg];
|
||||
tips.push('<div style="color:green">');
|
||||
tips.push("【使用"+type+"录音需要在app.js Platforms.Default内注册】:");
|
||||
tips.push("src/recorder-core.js, src/engine/");
|
||||
tips.push(engines.join(".js, src/engine/"));
|
||||
tips.push(".js</div>");
|
||||
|
||||
$(".typeTips").html(tips.join(""));
|
||||
};
|
||||
if(!Recorder.prototype[type]){
|
||||
reclog("<span style='color:#f60'>正在加载"+type+"编码器,请勿操作...</span>");
|
||||
var i=-1;
|
||||
var load=function(){
|
||||
i++;
|
||||
if(i>=engines.length){
|
||||
reclog("<span style='color:#0b1'>"+type+"编码器已加载,可以录音了</span>");
|
||||
end();
|
||||
return;
|
||||
}
|
||||
var elem=document.createElement("script");
|
||||
elem.setAttribute("type","text/javascript");
|
||||
elem.setAttribute("src",PageSet_RecordAppBaseFolder+"engine/"+engines[i]+".js");
|
||||
elem.onload=function(){
|
||||
load();
|
||||
};
|
||||
$("head")[0].appendChild(elem);
|
||||
};
|
||||
load();
|
||||
}else{
|
||||
end();
|
||||
};
|
||||
};
|
||||
};
|
||||
function onInstall(){
|
||||
var isApp;
|
||||
RecordApp.Platforms.Native.Config.IsApp(function(v){isApp=v;});
|
||||
|
||||
$(".recinfoCode").text($(".recinfoCode").text().replace(/\$\{(.+?)\}/g,function(a,b){return eval(b)}));
|
||||
if(!/:\/\/[^\/]*jiebian.life(\/|$)/.test(location.href)){
|
||||
reclog("<span style='color:red'>当前域名不在微信JsSDK绑定的域名中,无法使用JsSDK相关功能</span>");
|
||||
}else{
|
||||
reclog("此页面由服务器代理,源网址<a href='https://xiangyuecn.github.io/Recorder/app-support-sample/'>https://xiangyuecn.github.io/Recorder/app-support-sample/</a>");
|
||||
};
|
||||
|
||||
reclog("提示:部分组件可能会设置在Install成功后进行延迟加载,Start、Stop操作在组件未加载完时会等待OnLazyReady事件触发,可通过设置RecordApp.UseLazyLoad=false关闭此特性(会阻塞Install导致RequestPermission变慢),<span style='color:"+(RecordApp.UseLazyLoad?"#0b1'>当前已启用":"red'>当前已关闭")+"此特性</span><button onclick='changeUseLazyLoad()'>"+(RecordApp.UseLazyLoad?"关闭":"启用")+"</button>");
|
||||
window.changeUseLazyLoad=function(){
|
||||
localStorage["RecordApp_UseLazyLoadDisable"]=RecordApp.UseLazyLoad?1:0;
|
||||
location.reload();
|
||||
};
|
||||
RecordApp.Current.OnLazyReady(function(){
|
||||
reclog("OnLazyReady触发,所有组件已完成加载",2);
|
||||
loadEngine($(".initType"));
|
||||
});
|
||||
reclog("Install成功,环境:"+RecordApp.Current.Key+",可以录音了",2);
|
||||
};
|
||||
reclog("<span style='color:#f60'>正在执行Install,请勿操作...</span>");
|
||||
if(isInstall){
|
||||
onInstall();
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- 加载打赏挂件 -->
|
||||
<script DEF="/*=:=*/" src="../assets/zdemo.widget.donate.js"
|
||||
DEF="/*<@ crossorigin='anonymous' src='https://xiangyuecn.github.io/Recorder/assets/zdemo.widget.donate.js' @>*/"></script>
|
||||
<script>
|
||||
DonateWidget({
|
||||
log:function(msg){reclog(msg)}
|
||||
,mobElem:$(".reclog").append('<div class="DonateView"></div>').find(".DonateView")[0]
|
||||
});
|
||||
</script>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
273
WebRoot/node_modules/Recorder-master/app-support-sample/ios-weixin-config.js
generated
vendored
Normal file
@ -0,0 +1,273 @@
|
||||
/*
|
||||
app-support/app.js中IOS-Weixin测试用的配置例子,用于支持ios的微信中使用微信JsSDK来录音
|
||||
|
||||
【本文件的作用】:实现app.js内IOS-Weixin中Config的两个标注为需实现的接口(这几个接口是app-ios-weixin-support.js需要的),提供本文件可免去修改app.js源码。
|
||||
|
||||
此文件需要在app.js之前进行加载,【注意】【本文件需要修改后才能用到你的网站】
|
||||
|
||||
https://github.com/xiangyuecn/Recorder
|
||||
*/
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
/******简化后的使用配置修改项******/
|
||||
//可简单修改此处配置即可正常使用。当然参考本例子全部自己写是最佳选择,可能需要多花点时间。
|
||||
|
||||
/**【需修改】请使用自己的js文件目录,不要用github的不稳定。RecordApp会自动从这个目录内进行加载相关的实现文件、Recorder核心、编码引擎,会自动默认加载哪些文件,请查阅app.js内所有Platform的paths配置;如果这些文件你已手动全部加载,这个目录配置可以不用**/
|
||||
window.RecordAppBaseFolder=window.PageSet_RecordAppBaseFolder||"https://xiangyuecn.github.io/Recorder/src/";
|
||||
|
||||
/**【需修改】请使用自己的微信JsSDK签名接口、素材下载接口,不能用这个,微信【强制】要【绑安全域名】,别的站用不了。下面ajax相关调用的请求参数、和响应结果格式也需要调整为自己的格式。
|
||||
后端签名接口参考文档:微信JsSDK wx.config需使用到后端接口进行签名,文档: https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html 阅读:通过config接口注入权限验证配置、附录1-JS-SDK使用权限签名算法
|
||||
后端素材下载接口参考文档: https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1444738727
|
||||
**/
|
||||
var MyWxApi=window.PageSet_RecordAppWxApi||"https://jiebian.life/api/weixin/git_record"; /*本例子提供的这个api接口:
|
||||
会实现两个功能,ajax POST请求参数如下(都是两个参数,完整细节看下面ajax调用):
|
||||
功能一、action="sign" //JsSDK签名
|
||||
url="https://x.com/page" //当前页面url地址,需要对这个地址进行签名
|
||||
功能二、action="wxdown" //素材下载
|
||||
mediaID="abcd" //需下载的素材ID
|
||||
响应内容(JSON Object):
|
||||
{
|
||||
c:0 //code,0:正常,其他:错误
|
||||
,m:"" //errMsg code!=0时的错误描述
|
||||
,v:{} //返回结果value,为JSON Object
|
||||
//sign时:v={appid:"", timestamp:"", noncestr:"", signature:""} 就是返回wx.config需要的签名相关参数
|
||||
//wxdown时:v={mime:"audio/amr", data:"base64文本"} 就是返回素材下载的音频文件base64编码数据
|
||||
}*/
|
||||
/******END******/
|
||||
|
||||
|
||||
|
||||
//Install Begin:在RecordApp准备好时执行这些代码
|
||||
window.OnRecordAppInstalled=window.IOS_Weixin_RecordApp_Config=function(){
|
||||
console.log("ios-weixin-config install");
|
||||
|
||||
window.IOS_Weixin_RecordApp_Config=null;
|
||||
window.Native_RecordApp_Config&&Native_RecordApp_Config();//如果native-config.js也引入了的话,也需要初始化
|
||||
|
||||
var App=RecordApp;
|
||||
var platform=App.Platforms.Weixin;
|
||||
var config=platform.Config;
|
||||
|
||||
var win=window.top;//微信JsSDK让顶层去加载,免得iframe各种麻烦
|
||||
|
||||
|
||||
/*********实现app.js内IOS-Weixin中Config的接口*************/
|
||||
config.Enable=function(call){
|
||||
//是否启用微信支持,默认启用,如果要禁用就回调call(false)
|
||||
call(true);
|
||||
};
|
||||
config.WxReady=function(call){
|
||||
//此方法已实现在微信JsSDK wx.config好后调用call(wx,err)函数
|
||||
//微信JsSDK wx.config需使用到后端接口进行签名,文档: https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html 阅读:通过config接口注入权限验证配置、附录1-JS-SDK使用权限签名算法
|
||||
if(!win.WxReady){
|
||||
win.eval("var InitJsSDK="+InitJsSDK.toString()+";InitJsSDK")(App,MyWxApi,ajax);
|
||||
};
|
||||
|
||||
win.WxReady(call);
|
||||
};
|
||||
config.DownWxMedia=function(param,success,fail){
|
||||
/*下载微信录音素材,服务器端接口文档: https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1444738727
|
||||
param:{//接口调用参数
|
||||
mediaId:"" 录音接口上传得到的微信服务器上的ID,用于下载单个素材(如果录音分了多段,会循环调用DownWxMedia);如果服务器会进行转码,请忽略这个参数
|
||||
|
||||
transform_mediaIds:"mediaId,mediaId,mediaId" 1个及以上mediaId,半角逗号分隔,用于服务器端进行转码用的,正常情况下这个参数用不到。如果服务器端会进行转码,需要把这些素材全部下载下来,然后按顺序合并为一个音频文件
|
||||
transform_type:"mp3" 录音set中的类型,用于转码结果类型,正常情况下这个参数用不到。如果服务器端会进行转码,接口返回的mime必须是:audio/type(如:audio/mp3)。
|
||||
transform_bitRate:123 建议的比特率,转码用的,同transform_type
|
||||
transform_sampleRate:123 建议的采样率,转码用的,同transform_type
|
||||
|
||||
* 素材下载的amr音质很渣,也许可以通过高清接口获得清晰点的speex音频,那么transform_*参数就有用武之地;直接下载的amr只需用mediaId参数就可以了。
|
||||
}
|
||||
success: fn(obj) 下载成功返回结果
|
||||
obj:{
|
||||
mime:"audio/amr" //这个值是服务器端请求临时素材接口返回的Content-Type响应头,未转码必须是audio/amr;如果服务器进行了转码,是转码后的类型mime,并且提供duration
|
||||
,data:"base64文本" //服务器端下载到或转码的文件二进制内容进行base64编码
|
||||
|
||||
,duration:0 //音频时长,如果服务器端进行了转码,必须返回这个参数并且>0,否则不要提供或者直接给0
|
||||
}
|
||||
fail: fn(msg) 下载出错回调
|
||||
*/
|
||||
|
||||
ajax(MyWxApi,{
|
||||
action:"wxdown"
|
||||
,mediaID:param.mediaId
|
||||
,transform_mediaIds:param.transform_mediaIds
|
||||
,transform_type:param.transform_type
|
||||
,transform_bitRate:param.transform_bitRate
|
||||
,transform_sampleRate:param.transform_sampleRate
|
||||
},function(data){
|
||||
success(data.v);
|
||||
},function(msg){
|
||||
fail("下载音频失败:"+msg);
|
||||
});
|
||||
};
|
||||
/*********接口实现END*************/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//手撸一个ajax
|
||||
var ajax=function(url,data,True,False){
|
||||
var xhr=new XMLHttpRequest();
|
||||
xhr.timeout=20000;
|
||||
xhr.open("POST",url);
|
||||
xhr.onreadystatechange=function(){
|
||||
if(xhr.readyState==4){
|
||||
if(xhr.status==200){
|
||||
var o=JSON.parse(xhr.responseText);
|
||||
if(o.c){
|
||||
False(o.m);
|
||||
return;
|
||||
};
|
||||
True(o);
|
||||
}else{
|
||||
False("请求失败["+xhr.status+"]");
|
||||
}
|
||||
}
|
||||
};
|
||||
var arr=[];
|
||||
for(var k in data){
|
||||
arr.push(k+"="+encodeURIComponent(data[k]));
|
||||
};
|
||||
xhr.setRequestHeader("Content-Type","application/x-www-form-urlencoded");
|
||||
xhr.send(arr.join("&"));
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
/*********JsSDK*************/
|
||||
var InitJsSDK=function(App,MyWxApi,ajax){
|
||||
var wxOjbK=function(call){
|
||||
if(errMsg){
|
||||
call(null,errMsg);
|
||||
return;
|
||||
};
|
||||
|
||||
wxConfig(function(){
|
||||
call(wx);
|
||||
},function(msg){
|
||||
call(wx,"请求微信接口失败: "+msg);
|
||||
});
|
||||
};
|
||||
|
||||
//微信环境准备完毕
|
||||
window.WxReady=function(call){
|
||||
if(isReady){
|
||||
wxOjbK(call);
|
||||
}else{
|
||||
calls.push(call);
|
||||
};
|
||||
};
|
||||
var isReady=false;
|
||||
var calls=[];
|
||||
var errMsg="";
|
||||
|
||||
var jsEnd=function(){
|
||||
isReady=true;
|
||||
var arr=calls;
|
||||
calls=[];
|
||||
for(var i=0;i<arr.length;i++){
|
||||
wxOjbK(arr[i]);
|
||||
};
|
||||
};
|
||||
App.Js([{url:"https://res.wx.qq.com/open/js/jweixin-1.4.0.js",check:function(){return !window.wx||!wx.config}}],function(){
|
||||
console.log("weixin jssdk加载好了");
|
||||
jsEnd();
|
||||
},function(msg){
|
||||
errMsg="加载微信JsSDK失败,请刷新页面:"+msg;
|
||||
console.error("weixin jssdk加载失败:"+msg);
|
||||
jsEnd();
|
||||
},window);
|
||||
|
||||
|
||||
|
||||
//等等完成签名
|
||||
var wxConfigStatus=0;
|
||||
var wxConfigErr="";
|
||||
var wxConfigCalls=[];
|
||||
var wxConfig=function(True,False){
|
||||
if(wxConfigStatus==6){
|
||||
True();
|
||||
return;
|
||||
}else if(wxConfigStatus==5){
|
||||
False(wxConfigErr);
|
||||
return;
|
||||
};
|
||||
wxConfigCalls.push({t:True,f:False});
|
||||
var end=function(err){
|
||||
if(wxConfigStatus<5){
|
||||
wxConfigErr=err?"微信config失败,请刷新页面重试:"+err:"";
|
||||
wxConfigStatus=err?5:6;
|
||||
for(var i=0;i<wxConfigCalls.length;i++){
|
||||
var o=wxConfigCalls[i];
|
||||
if(err){
|
||||
o.f(wxConfigErr);
|
||||
}else{
|
||||
o.t();
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
if(wxConfigStatus!=0){
|
||||
return;
|
||||
};
|
||||
wxConfigStatus=1;
|
||||
|
||||
var config=function(data){
|
||||
wx.config({
|
||||
debug:false
|
||||
,appId:data.appid
|
||||
,timestamp:data.timestamp
|
||||
,nonceStr:data.noncestr
|
||||
,signature:data.signature
|
||||
,jsApiList:("getLocation"
|
||||
+",startRecord,stopRecord,onVoiceRecordEnd"
|
||||
+",playVoice,pauseVoice,stopVoice,onVoicePlayEnd"
|
||||
+",uploadVoice,downloadVoice"
|
||||
).split(",")
|
||||
});
|
||||
wx.error(function(res){
|
||||
console.error("wx.config",res);
|
||||
end(res.errMsg);
|
||||
});
|
||||
wx.ready(function(){
|
||||
console.log("微信JsSDK签名配置完成");
|
||||
end();
|
||||
});
|
||||
};
|
||||
ajax(MyWxApi,{
|
||||
action:"sign"
|
||||
,url:location.href.replace(/#.*/g,"")
|
||||
},function(data){
|
||||
config(data.v);
|
||||
},end);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
};
|
||||
//Install End
|
||||
|
||||
|
||||
//如果已加载RecordApp,手动进行触发
|
||||
if(window.RecordApp){
|
||||
OnRecordAppInstalled();
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
|
||||
|
||||
|
||||
console.error("【注意】本网站正在使用RecordApp的ios-weixin-config.js测试用的配置例子,这个配置如果要使用到你的网站,需要自己重写或修改后才能使用");
|
||||
//别的站点引用弹窗醒目提示
|
||||
if(!/^file:|:\/\/[^\/]*(jiebian.life|github.io)(\/|$)/.test(location.href)
|
||||
&& !localStorage["DisableAppSampleAlert"]
|
||||
&& !window.AppSampleAlert){
|
||||
window.AppSampleAlert=1;
|
||||
alert("【注意】当前网站正在使用RecordApp测试用的配置例子*.config.js,需要自己重写或修改后才能使用");
|
||||
};
|
||||
188
WebRoot/node_modules/Recorder-master/app-support-sample/native-config.js
generated
vendored
Normal file
@ -0,0 +1,188 @@
|
||||
/*
|
||||
app-support/app.js中Native测试用的配置例子,用于调用App的原生接口来录音
|
||||
|
||||
【本文件的作用】:实现app.js内Native中Config的四个标注为需实现的接口(这几个接口是app-native-support.js需要的),提供本文件可免去修改app.js源码。
|
||||
|
||||
本例子提供了一个JsBridge实现,并且本文件所在目录内还有Android和IOS的demo项目,app原生层已实现相应的接口,copy源码改改就能用。
|
||||
|
||||
此文件需要在app.js之前进行加载,【注意】【如果你App原生层实现不是用的demo中提供的接口文件,需自行重写本配置代码】
|
||||
|
||||
https://github.com/xiangyuecn/Recorder
|
||||
*/
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
/**【需修改】请使用自己的js文件目录,不要用github的不稳定。RecordApp会自动从这个目录内进行加载相关的实现文件、Recorder核心、编码引擎,会自动默认加载哪些文件,请查阅app.js内所有Platform的paths配置;如果这些文件你已手动全部加载,这个目录配置可以不用**/
|
||||
window.RecordAppBaseFolder=window.PageSet_RecordAppBaseFolder||"https://xiangyuecn.github.io/Recorder/src/";
|
||||
|
||||
|
||||
|
||||
//Install Begin:在RecordApp准备好时执行这些代码
|
||||
window.OnRecordAppInstalled=window.Native_RecordApp_Config=function(){
|
||||
console.log("native-config install");
|
||||
|
||||
window.Native_RecordApp_Config=null;
|
||||
window.IOS_Weixin_RecordApp_Config&&IOS_Weixin_RecordApp_Config();//如果ios-weixin-config.js也引入了的话,也需要初始化
|
||||
|
||||
|
||||
var App=RecordApp;
|
||||
var platform=App.Platforms.Native;
|
||||
var config=platform.Config;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/******JsBridge简易版*******/
|
||||
/*JsBridge名称定义
|
||||
Android为addJavascriptInterface注入到全局,提供RecordAppJsBridge.request(jsonString)
|
||||
IOS为userContentController绑定对象实现(此对象仅供识别,由于messageHandlers没有同步返回值(同步能实现异步,异步只能异步到死),因此不能参与数据交互,数据交互使用重写prompt实现)
|
||||
*/
|
||||
var JsBridgeName="RecordAppJsBridge";
|
||||
|
||||
var AppJsBridgeRequest=window.AppJsBridgeRequest=function(action,args,call){
|
||||
var p=GetParent();
|
||||
if(p!=window && p.AppJsBridgeRequest){//让iframe上层去处理,直到top层
|
||||
return p.AppJsBridgeRequest(action,args,call);
|
||||
};
|
||||
|
||||
args||(args={});
|
||||
|
||||
var callback="";
|
||||
if(call){
|
||||
callback=Callbacks.length+"";
|
||||
Callbacks.push(call);
|
||||
};
|
||||
|
||||
var json={status:"",message:"",callback:callback,value:null};//接口调用返回数据格式标准
|
||||
|
||||
var data=JSON.stringify({action:action,args:args,callback:callback});
|
||||
|
||||
//APP差异化处理
|
||||
var val="";
|
||||
if(window[JsBridgeName]){//Android
|
||||
val=window[JsBridgeName].request(data);
|
||||
}else if( ((window.webkit||{}).messageHandlers||{})[JsBridgeName+"IsSet"] ){//IOS
|
||||
val=prompt(data);
|
||||
}else{//非App环境
|
||||
json.message="非app,不能调用接口";
|
||||
};
|
||||
val=val&&JSON.parse(val)||json;
|
||||
|
||||
return val;//同步返回结果,异步返回会走AppJsBridgeRequest.Call
|
||||
};
|
||||
var GetParent=function(){
|
||||
try{
|
||||
var p=window.parent;
|
||||
p.x;
|
||||
return p;
|
||||
}catch(e){
|
||||
return window;
|
||||
};
|
||||
};
|
||||
var Callbacks=[""];
|
||||
|
||||
//app异步回调
|
||||
AppJsBridgeRequest.Call=function(msg){
|
||||
if(Callbacks[msg.callback]){
|
||||
Callbacks[msg.callback](msg);
|
||||
Callbacks[msg.callback]=null;
|
||||
}else{
|
||||
//NOOP
|
||||
};
|
||||
};
|
||||
//app事件回调
|
||||
AppJsBridgeRequest.Record=function(pcmDataBase64,sampleRate){
|
||||
NativeRecordReceivePCM(pcmDataBase64,sampleRate);
|
||||
};
|
||||
/******JsBridge简单实现 End*******/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*********实现app.js内Native中Config的接口*************/
|
||||
config.IsApp=function(call){
|
||||
/*识别为app环境*/
|
||||
if(window[JsBridgeName]||((window.webkit||{}).messageHandlers||{})[JsBridgeName+"IsSet"]){
|
||||
call(true);
|
||||
return;
|
||||
};
|
||||
call(false);//非app
|
||||
};
|
||||
config.JsBridgeRequestPermission=function(success,fail){
|
||||
//异步接口,录音权限检测,返回值int:1支持,2不支持,3用户拒绝
|
||||
AppJsBridgeRequest("recordPermission",{},function(json){
|
||||
if(json.status!="success"){
|
||||
fail(json.message);
|
||||
return;
|
||||
};
|
||||
if(json.value==1){
|
||||
success();
|
||||
}else if(json.value==3){
|
||||
fail("用户拒绝了录音权限",true);
|
||||
}else{
|
||||
fail("不支持录音");
|
||||
};
|
||||
});
|
||||
};
|
||||
var aliveInt;
|
||||
config.JsBridgeStart=function(set,success,fail){
|
||||
//异步接口,开始录音
|
||||
AppJsBridgeRequest("recordStart",{param:set},function(json){
|
||||
if(json.status!="success"){
|
||||
fail(json.message);
|
||||
return;
|
||||
};
|
||||
|
||||
success();
|
||||
|
||||
//激活定时心跳,如果超过10秒未发心跳,app将会停止录音,防止未stop导致泄露
|
||||
aliveInt=setInterval(function(){
|
||||
//同步接口
|
||||
var val=AppJsBridgeRequest("recordAlive");
|
||||
//console.log("心跳已发出:"+JSON.stringify(val));
|
||||
},5000);
|
||||
});
|
||||
};
|
||||
config.JsBridgeStop=function(success,fail){
|
||||
clearInterval(aliveInt);//关掉定时心跳
|
||||
|
||||
//异步接口,结束录音
|
||||
AppJsBridgeRequest("recordStop",{},function(json){
|
||||
if(json.status!="success"){
|
||||
fail(json.message);
|
||||
return;
|
||||
};
|
||||
success();
|
||||
});
|
||||
};
|
||||
/*********接口实现END*************/
|
||||
|
||||
|
||||
|
||||
};
|
||||
//Install End
|
||||
|
||||
|
||||
//如果已加载RecordApp,手动进行触发
|
||||
if(window.RecordApp){
|
||||
OnRecordAppInstalled();
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
|
||||
console.error("【注意】本网站正在使用RecordApp的native-config.js测试用的配置例子,这个配置如果要使用到你的网站,需要自己重写或修改后才能使用");
|
||||
//别的站点引用弹窗醒目提示
|
||||
if(!/^file:|:\/\/[^\/]*(jiebian.life|github.io)(\/|$)/.test(location.href)
|
||||
&& !localStorage["DisableAppSampleAlert"]
|
||||
&& !window.AppSampleAlert){
|
||||
window.AppSampleAlert=1;
|
||||
alert("【注意】当前网站正在使用RecordApp测试用的配置例子*.config.js,需要自己重写或修改后才能使用");
|
||||
};
|
||||
BIN
WebRoot/node_modules/Recorder-master/assets/audio/bgm-晕倒.mp3
generated
vendored
Normal file
BIN
WebRoot/node_modules/Recorder-master/assets/audio/bgm-爆笑.mp3
generated
vendored
Normal file
BIN
WebRoot/node_modules/Recorder-master/assets/audio/bgm-转场.mp3
generated
vendored
Normal file
BIN
WebRoot/node_modules/Recorder-master/assets/audio/movie-sexy-rabbit-baby.mp4
generated
vendored
Normal file
BIN
WebRoot/node_modules/Recorder-master/assets/audio/music-在人间-张韶涵.mp3
generated
vendored
Normal file
BIN
WebRoot/node_modules/Recorder-master/assets/audio/music-阿刁-张韶涵.mp3
generated
vendored
Normal file
BIN
WebRoot/node_modules/Recorder-master/assets/audio/rec-4000ms-12.8kbps-8000hz.amr
generated
vendored
Normal file
BIN
WebRoot/node_modules/Recorder-master/assets/audio/rec-4000ms-64kbps-16000hz.mp3
generated
vendored
Normal file
BIN
WebRoot/node_modules/Recorder-master/assets/audio/rec-4000ms-64kbps-16000hz.ogg
generated
vendored
Normal file
BIN
WebRoot/node_modules/Recorder-master/assets/audio/rec-4000ms-64kbps-16000hz.webm
generated
vendored
Normal file
BIN
WebRoot/node_modules/Recorder-master/assets/audio/rec-4000ms-8kbps-16000hz.wav
generated
vendored
Normal file
BIN
WebRoot/node_modules/Recorder-master/assets/demo-recordapp.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
12
WebRoot/node_modules/Recorder-master/assets/demo-vue/README.md
generated
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
# vue+webpack测试
|
||||
|
||||
[在线测试](https://xiangyuecn.github.io/Recorder/assets/demo-vue),主要文件为[component/main.vue](https://github.com/xiangyuecn/Recorder/blob/master/assets/demo-vue/component/main.vue)
|
||||
|
||||
# 运行方法
|
||||
## 【1】编译vue源码
|
||||
```
|
||||
cnpm install
|
||||
npm run build-dev
|
||||
```
|
||||
## 【2】浏览器访问
|
||||
然后就可以打开index.html查看效果了
|
||||
318
WebRoot/node_modules/Recorder-master/assets/demo-vue/component/main.vue
generated
vendored
Normal file
@ -0,0 +1,318 @@
|
||||
<style>
|
||||
body{
|
||||
word-wrap: break-word;
|
||||
background:#f5f5f5 center top no-repeat;
|
||||
background-size: auto 680px;
|
||||
}
|
||||
pre{
|
||||
white-space:pre-wrap;
|
||||
}
|
||||
a{
|
||||
text-decoration: none;
|
||||
color:#06c;
|
||||
}
|
||||
a:hover{
|
||||
color:#f00;
|
||||
}
|
||||
|
||||
.main{
|
||||
max-width:700px;
|
||||
margin:0 auto;
|
||||
padding-bottom:80px
|
||||
}
|
||||
|
||||
.mainBox{
|
||||
margin-top:12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
--border: 1px solid #0b1;
|
||||
box-shadow: 2px 2px 3px #aaa;
|
||||
}
|
||||
|
||||
|
||||
.mainBtn{
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: #0b1;
|
||||
color:#fff;
|
||||
padding: 0 15px;
|
||||
margin-right:20px;
|
||||
line-height: 36px;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mainBtn:active{
|
||||
background: #0a1;
|
||||
}
|
||||
.ctrlBtn{
|
||||
margin-top:10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="main">
|
||||
<slot name="top"></slot>
|
||||
|
||||
<div class="mainBox">
|
||||
<div>
|
||||
类型:{{ type }}
|
||||
<span style="margin:0 20px">
|
||||
比特率: <input type="text" v-model="bitRate" style="width:60px"> kbps
|
||||
</span>
|
||||
采样率: <input type="text" v-model="sampleRate" style="width:60px"> hz
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="mainBtn ctrlBtn" @click="recOpen">打开录音,请求权限</button>
|
||||
<button class="mainBtn ctrlBtn" @click="recStart">开始录音</button>
|
||||
<button class="mainBtn ctrlBtn" @click="recStop">结束录音,并释放资源</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mainBox">
|
||||
<div style="height:100px;width:300px;border:1px solid #ccc;box-sizing: border-box;display:inline-block;vertical-align:bottom" class="ctrlProcessWave"></div>
|
||||
<div style="height:40px;width:300px;display:inline-block;background:#999;position:relative;vertical-align:bottom">
|
||||
<div class="ctrlProcessX" style="height:40px;background:#0B1;position:absolute;" :style="{width:powerLevel+'%'}"></div>
|
||||
<div class="ctrlProcessT" style="padding-left:50px; line-height:40px; position: relative;">{{ duration+"/"+powerLevel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mainBox">
|
||||
<audio ref="LogAudioPlayer" style="width:100%"></audio>
|
||||
|
||||
<div class="mainLog">
|
||||
<div v-for="obj in logs" :key="obj.idx">
|
||||
<div :style="{color:obj.color==1?'red':obj.color==2?'green':obj.color}">
|
||||
<!-- <template v-once> 在v-for里存在的bug,参考:https://v2ex.com/t/625317 -->
|
||||
<span v-once>[{{ getTime() }}]</span><span v-html="obj.msg"/>
|
||||
|
||||
<template v-if="obj.res">
|
||||
{{ intp(obj.res.rec.set.bitRate,3) }}kbps
|
||||
{{ intp(obj.res.rec.set.sampleRate,5) }}hz
|
||||
编码{{ intp(obj.res.blob.size,6) }}b
|
||||
[{{ obj.res.rec.set.type }}]{{ intp(obj.res.duration,6) }}ms
|
||||
|
||||
<button @click="recdown(obj.idx)">下载</button>
|
||||
<button @click="recplay(obj.idx)">播放</button>
|
||||
|
||||
<span v-html="obj.playMsg"></span>
|
||||
<span v-if="obj.down">
|
||||
<span style="color:red">{{ obj.down }}</span>
|
||||
|
||||
没弹下载?试一下链接或复制文本<button @click="recdown64(obj.idx)">生成Base64文本</button>
|
||||
|
||||
<textarea v-if="obj.down64Val" v-model="obj.down64Val"></textarea>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recOpenDialogShow" style="z-index:99999;width:100%;height:100%;top:0;left:0;position:fixed;background:rgba(0,0,0,0.3);">
|
||||
<div style="display:flex;height:100%;align-items:center;">
|
||||
<div style="flex:1;"></div>
|
||||
<div style="width:240px;background:#fff;padding:15px 20px;border-radius: 10px;">
|
||||
<div style="padding-bottom:10px;">录音功能需要麦克风权限,请允许;如果未看到任何请求,请点击忽略~</div>
|
||||
<div style="text-align:center;"><a @click="waitDialogClick" style="color:#0B1">忽略</a></div>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="bottom"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
import Recorder from 'recorder-core'
|
||||
|
||||
module.exports={
|
||||
data(){
|
||||
return {
|
||||
type:"mp3"
|
||||
,bitRate:16
|
||||
,sampleRate:16000
|
||||
|
||||
,rec:0
|
||||
,duration:0
|
||||
,powerLevel:0
|
||||
|
||||
,recOpenDialogShow:0
|
||||
,logs:[]
|
||||
}
|
||||
}
|
||||
,methods:{
|
||||
recOpen:function(){
|
||||
var This=this;
|
||||
var rec=this.rec=Recorder({
|
||||
type:This.type
|
||||
,bitRate:This.bitRate
|
||||
,sampleRate:This.sampleRate
|
||||
,onProcess:function(buffers,powerLevel,duration,sampleRate){
|
||||
This.duration=duration;
|
||||
This.powerLevel=powerLevel;
|
||||
|
||||
This.wave.input(buffers[buffers.length-1],powerLevel,sampleRate);
|
||||
}
|
||||
});
|
||||
|
||||
This.dialogInt=setTimeout(function(){//定时8秒后打开弹窗,用于监测浏览器没有发起权限请求的情况
|
||||
This.showDialog();
|
||||
},8000);
|
||||
|
||||
rec.open(function(){
|
||||
This.dialogCancel();
|
||||
This.reclog("已打开:"+This.type+" "+This.sampleRate+"hz "+This.bitRate+"kbps",2);
|
||||
|
||||
This.wave=Recorder.WaveView({elem:".ctrlProcessWave"});
|
||||
},function(msg,isUserNotAllow){
|
||||
This.dialogCancel();
|
||||
This.reclog((isUserNotAllow?"UserNotAllow,":"")+"打开失败:"+msg,1);
|
||||
});
|
||||
|
||||
This.waitDialogClickFn=function(){
|
||||
This.dialogCancel();
|
||||
This.reclog("打开失败:权限请求被忽略,用户主动点击的弹窗",1);
|
||||
};
|
||||
}
|
||||
,recStart:function(){
|
||||
if(!this.rec){
|
||||
this.reclog("未打开录音",1);
|
||||
return;
|
||||
}
|
||||
this.rec.start();
|
||||
|
||||
var set=this.rec.set;
|
||||
this.reclog("录制中:"+set.type+" "+set.sampleRate+"hz "+set.bitRate+"kbps");
|
||||
}
|
||||
,recStop:function(){
|
||||
var This=this;
|
||||
var rec=This.rec;
|
||||
This.rec=null;
|
||||
if(!rec){
|
||||
This.reclog("未打开录音",1);
|
||||
return;
|
||||
}
|
||||
|
||||
rec.stop(function(blob,duration){
|
||||
This.reclog("已录制:","",{
|
||||
blob:blob
|
||||
,duration:duration
|
||||
,rec:rec
|
||||
});
|
||||
},function(s){
|
||||
This.reclog("结束出错:"+s,1);
|
||||
},true);//自动close
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
,reclog:function(msg,color,res){
|
||||
this.logs.splice(0,0,{
|
||||
idx:this.logs.length
|
||||
,msg:msg
|
||||
,color:color
|
||||
,res:res
|
||||
|
||||
,playMsg:""
|
||||
,down:0
|
||||
,down64Val:""
|
||||
});
|
||||
}
|
||||
,recplay:function(idx){
|
||||
var This=this;
|
||||
var o=this.logs[this.logs.length-idx-1];
|
||||
o.play=(o.play||0)+1;
|
||||
var logmsg=function(msg){
|
||||
o.playMsg='<span style="color:green">'+o.play+'</span> '+This.getTime()+" "+msg;
|
||||
};
|
||||
logmsg("");
|
||||
|
||||
var audio=this.$refs.LogAudioPlayer;
|
||||
audio.controls=true;
|
||||
if(!(audio.ended || audio.paused)){
|
||||
audio.pause();
|
||||
};
|
||||
audio.onerror=function(e){
|
||||
logmsg('<span style="color:red">播放失败['+audio.error.code+']'+audio.error.message+'</span>');
|
||||
};
|
||||
audio.src=(window.URL||webkitURL).createObjectURL(o.res.blob);
|
||||
audio.play();
|
||||
}
|
||||
,recdown:function(idx){
|
||||
var This=this;
|
||||
var o=this.logs[this.logs.length-idx-1];
|
||||
o.down=(o.down||0)+1;
|
||||
|
||||
o=o.res;
|
||||
var name="rec-"+o.duration+"ms-"+(o.rec.set.bitRate||"-")+"kbps-"+(o.rec.set.sampleRate||"-")+"hz."+(o.rec.set.type||(/\w+$/.exec(o.blob.type)||[])[0]||"unknown");
|
||||
var downA=document.createElement("A");
|
||||
downA.href=(window.URL||webkitURL).createObjectURL(o.blob);
|
||||
downA.download=name;
|
||||
downA.click();
|
||||
}
|
||||
,recdown64:function(idx){
|
||||
var This=this;
|
||||
var o=this.logs[this.logs.length-idx-1];
|
||||
var reader = new FileReader();
|
||||
reader.onloadend = function() {
|
||||
o.down64Val=reader.result;
|
||||
};
|
||||
reader.readAsDataURL(o.res.blob);
|
||||
}
|
||||
,getTime:function(){
|
||||
var now=new Date();
|
||||
var t=("0"+now.getHours()).substr(-2)
|
||||
+":"+("0"+now.getMinutes()).substr(-2)
|
||||
+":"+("0"+now.getSeconds()).substr(-2);
|
||||
return t;
|
||||
}
|
||||
,intp:function(s,len){
|
||||
s=s==null?"-":s+"";
|
||||
if(s.length>=len)return s;
|
||||
return ("_______"+s).substr(-len);
|
||||
}
|
||||
|
||||
|
||||
,showDialog:function(){
|
||||
//我们可以选择性的弹一个对话框:为了防止移动端浏览器存在第三种情况:用户忽略,并且(或者国产系统UC系)浏览器没有任何回调
|
||||
if(!/mobile/i.test(navigator.userAgent)){
|
||||
return;//只在移动端开启没有权限请求的检测
|
||||
};
|
||||
this.recOpenDialogShow=1;
|
||||
}
|
||||
,dialogCancel:function(){
|
||||
clearTimeout(this.dialogInt);
|
||||
this.recOpenDialogShow=0;
|
||||
}
|
||||
,waitDialogClick:function(){
|
||||
this.dialogCancel();
|
||||
this.waitDialogClickFn();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
6
WebRoot/node_modules/Recorder-master/assets/demo-vue/dist/index.js
generated
vendored
Normal file
30
WebRoot/node_modules/Recorder-master/assets/demo-vue/index.html
generated
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||
<link rel="shortcut icon" type="image/png" href="../icon.png">
|
||||
|
||||
<title>Recorder vue+webpack测试</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="rootView"></div>
|
||||
<script src="dist/index.js"></script>
|
||||
|
||||
<!-- 控制台组件 -->
|
||||
<script src="https://cdn.bootcss.com/eruda/1.5.4/eruda.min.js"></script>
|
||||
<script>eruda.init();</script>
|
||||
|
||||
<!-- 加载打赏挂件 -->
|
||||
<script src="../zdemo.widget.donate.js"></script>
|
||||
<script>
|
||||
var donateView=document.createElement("div");
|
||||
document.querySelector(".mainLog").appendChild(donateView);
|
||||
DonateWidget({
|
||||
log:function(msg){vue_main.reclog(msg)}
|
||||
,mobElem:donateView
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
66
WebRoot/node_modules/Recorder-master/assets/demo-vue/index.js
generated
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
//加载必须要的core,demo简化起见采用的直接加载类库,实际使用时应当采用异步按需加载
|
||||
import Import_Recorder from 'recorder-core'
|
||||
//需要使用到的音频格式编码引擎的js文件统统加载进来
|
||||
import 'recorder-core/src/engine/mp3'
|
||||
import 'recorder-core/src/engine/mp3-engine'
|
||||
//可选的扩展
|
||||
import 'recorder-core/src/extensions/waveview'
|
||||
|
||||
|
||||
//使用带模板编译的Vue构建UI
|
||||
import Vue from 'vue/dist/vue.esm';
|
||||
import MainView from './component/main.vue';
|
||||
|
||||
var root=new Vue({
|
||||
el: ".rootView"
|
||||
,data:{
|
||||
Rec:Import_Recorder
|
||||
}
|
||||
,components:{
|
||||
MainView:MainView
|
||||
}
|
||||
,template:`
|
||||
<MainView ref="mainView">
|
||||
<template #top>
|
||||
<div class="mainBox">
|
||||
<span style="font-size:32px;color:#0b1;">Recorder vue+webpack测试</span>
|
||||
<a href="https://github.com/xiangyuecn/Recorder">GitHub >></a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<div class="mainBox">
|
||||
<div>本测试的码源码在<a href="https://github.com/xiangyuecn/Recorder/tree/master/assets/demo-vue">/assets/demo-vue</a>目录内,主要的文件为<a href="https://github.com/xiangyuecn/Recorder/blob/master/assets/demo-vue/component/main.vue">/assets/demo-vue/component/main.vue</a></div>
|
||||
|
||||
<div style="margin-top:15px">源码修改后测试方法:
|
||||
<pre style="background:green;color:#fff;padding:10px;">
|
||||
> cnpm install
|
||||
> npm run build-dev
|
||||
</pre>
|
||||
然后就可以打开index.html查看效果了。</div>
|
||||
</div>
|
||||
</template>
|
||||
</MainView>
|
||||
`
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//皮一下,这种难看调用逻辑验证
|
||||
var mainRef=root.$refs.mainView;
|
||||
mainRef.reclog(`<span style="color:green">绿油油的一大片,真有食欲</span>${unescape('%uD83D%uDE02')} 当前浏览器<span style="color:${root.Rec.Support()?'green">支持录音':'red">不支持录音'}</span>`);
|
||||
var logMeta=function(n,v){
|
||||
mainRef.reclog('<span style="color:#f60">'+n+":</span> <span style='color:#999'>"+v+"</span>");
|
||||
};
|
||||
logMeta(`Vue`,Vue.version);
|
||||
logMeta(`UA`,navigator.userAgent);
|
||||
logMeta(`URL`,location.href.replace(/#.*/g,""));
|
||||
mainRef.reclog("点击打开录音,然后再点击开始录音",2);
|
||||
|
||||
|
||||
window.vue_root=root;
|
||||
window.vue_main=mainRef;
|
||||
console.log("Vue",Vue);
|
||||
console.log("Recorder",Import_Recorder);
|
||||
23
WebRoot/node_modules/Recorder-master/assets/demo-vue/package.json
generated
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "recorder-vue-demo",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build": "webpack --progress --mode production --config package.webpack.build.js"
|
||||
,"build-dev": "webpack --progress --mode development --config package.webpack.build.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"recorder-core": "*"
|
||||
|
||||
,"vue": "2.6.10"
|
||||
,"webpack": "4.41.2"
|
||||
,"webpack-cli": "3.3.10"
|
||||
|
||||
,"vue-loader": "15.7.2"
|
||||
,"vue-template-compiler": "2.6.10"
|
||||
,"style-loader": "1.0.1"
|
||||
,"css-loader": "3.2.0"
|
||||
,"babel-core": "6.26.3"
|
||||
,"babel-loader": "7.1.5"
|
||||
,"babel-preset-env": "1.7.0"
|
||||
}
|
||||
}
|
||||
29
WebRoot/node_modules/Recorder-master/assets/demo-vue/package.webpack.build.js
generated
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
var path = require('path');
|
||||
var VueLoaderPlugin = require('vue-loader/lib/plugin')
|
||||
|
||||
module.exports = {
|
||||
entry: './index.js',
|
||||
output: {
|
||||
path: path.resolve(__dirname, './dist'),
|
||||
filename: 'index.js'
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
use:['vue-loader']
|
||||
},
|
||||
{
|
||||
test:/\.js$/,
|
||||
use:['babel-loader?presets[]=env']
|
||||
},
|
||||
{
|
||||
test:/\.css$/,
|
||||
use:['style-loader','css-loader']
|
||||
}
|
||||
]
|
||||
}
|
||||
,plugins: [
|
||||
new VueLoaderPlugin()
|
||||
]
|
||||
};
|
||||
BIN
WebRoot/node_modules/Recorder-master/assets/demo.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
WebRoot/node_modules/Recorder-master/assets/donate-alipay.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
WebRoot/node_modules/Recorder-master/assets/donate-weixin.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
WebRoot/node_modules/Recorder-master/assets/icon.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
WebRoot/node_modules/Recorder-master/assets/jiebian.life-web.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
WebRoot/node_modules/Recorder-master/assets/jiebian.life-xcx.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
136
WebRoot/node_modules/Recorder-master/assets/npm-home/README.md
generated
vendored
Normal file
@ -0,0 +1,136 @@
|
||||
# Recorder:recorder-core 用于html5录音
|
||||
|
||||
GitHub: [https://github.com/xiangyuecn/Recorder](https://github.com/xiangyuecn/Recorder),详细使用方法和支持请参考Recorder的GitHub仓库。npm recorder这个名字已被使用,因此在Recorder基础上增加后缀-core,就命名为recorder-core,和Recorder核心文件同名。 @@Ref 编辑提醒@@ @@Ref 编辑提醒@@ @@Ref 编辑提醒@@
|
||||
|
||||
@@Ref README.Desc@@
|
||||
|
||||
|
||||
# 如何使用
|
||||
|
||||
**注意:[需要在https等安全环境下才能进行录音](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#Privacy_and_security)**
|
||||
|
||||
## 【1】通过npm安装
|
||||
```
|
||||
npm install recorder-core
|
||||
```
|
||||
|
||||
## 【2】引入Recorder库
|
||||
**方式一**:通过import/require引入
|
||||
|
||||
@@Ref README.ImportCode@@
|
||||
|
||||
**方式二**:使用script标签引入
|
||||
|
||||
这种方式和GitHub上的代码使用没有差别,请阅读[GitHub仓库](https://github.com/xiangyuecn/Recorder)获得更详细的使用文档。
|
||||
``` html
|
||||
<script src="你项目中的路径/src/recorder-core.js"></script> <!--必须引入的录音核心-->
|
||||
<script src="你项目中的路径/src/engine/mp3.js"></script> <!--相应格式支持文件-->
|
||||
<script src="你项目中的路径/src/engine/mp3-engine.js"></script> <!--如果此格式有额外的编码引擎的话,也要加上-->
|
||||
|
||||
<script src="你项目中的路径/src/extensions/waveview.js"></script> <!--可选的扩展支持项-->
|
||||
```
|
||||
|
||||
或者在需要录音功能的页面引入压缩好的recorder.xxx.min.js文件减小代码体积
|
||||
``` html
|
||||
<script src="你项目中的路径/recorder.mp3.min.js"></script> <!--已包含recorder-core和mp3格式支持-->
|
||||
```
|
||||
|
||||
## 【3】调用录音
|
||||
@@Ref README.Codes@@
|
||||
|
||||
|
||||
## WaveView的调用方式
|
||||
引入`src/extensions/waveview.js`,再通过Recorder.WaveView调用即可,录音时动态显示波形,详细的使用请参考下面详细的README。
|
||||
|
||||
@@Ref README.WaveView.Codes@@
|
||||
|
||||
【附】部分扩展使用效果图([在线运行观看](https://xiangyuecn.github.io/Recorder/assets/工具-代码运行和静态分发Runtime.html?jsname=test.extensions.visualization)):
|
||||
|
||||

|
||||
|
||||
|
||||
## WaveSurferView的调用方式
|
||||
引入`src/extensions/wavesurfer.view.js`,再通过Recorder.WaveSurferView调用即可,录音时动态显示波形,详细的使用请参考下面详细的README。
|
||||
|
||||
|
||||
## FrequencyHistogramView的调用方式
|
||||
引入`src/extensions/frequency.histogram.view.js`+`lib.fft.js`,再通过Recorder.FrequencyHistogramView调用即可,音频可视化频率直方图显示,详细的使用请参考下面详细的README。
|
||||
|
||||
|
||||
## Sonic的调用方式
|
||||
引入`src/extensions/sonic.js`,再通过Recorder.Sonic调用即可,音频变速变调转换,详细的使用请参考下面详细的README。
|
||||
|
||||
|
||||
## DTMF的调用方式
|
||||
引入`src/extensions/dtmf.decode.js`+`lib.fft.js`:DTMF(电话拨号按键信号)解码器,解码得到按键值;`src/extensions/dtmf.encode.js`:编码生成器,生成按键对应的音频PCM信号;详细的使用请参考下面详细的README。
|
||||
|
||||
|
||||
## RecordApp的调用方式
|
||||
**方式一**:通过import/require引入
|
||||
|
||||
@@Ref RecordApp.README.ImportCode@@
|
||||
|
||||
**方式二**:使用script标签引入
|
||||
|
||||
这种方式和GitHub上的代码使用没有差别,请阅读[GitHub仓库内RecordApp](https://github.com/xiangyuecn/Recorder/tree/master/app-support-sample)获得更详细的使用文档。
|
||||
``` html
|
||||
<!-- 可选的独立配置文件,参考上面import的解释 -->
|
||||
<script src="你的配置文件目录/native-config.js"></script>
|
||||
<script src="你的配置文件目录/ios-weixin-config.js"></script>
|
||||
|
||||
<!-- 在需要录音功能的页面引入`app-support/app.js`文件即可。
|
||||
app.js会自动加载实现文件、Recorder核心、编码引擎,应确保app.js内BaseFolder目录的正确性(参阅RecordAppBaseFolder)。
|
||||
(如何避免自动加载:使用时可以把所有支持文件全部手动引入,或者压缩时可以把所有支持文件压缩到一起,会检测到组件已加载,就不会再进行自动加载;会自动默认加载哪些文件,请查阅app.js内所有Platform的paths配置)
|
||||
(**注意:需要在https等安全环境下才能进行录音**) -->
|
||||
<script src="你项目中的路径/src/app-support/app.js"></script>
|
||||
|
||||
|
||||
<!-- 可选的扩展支持项的引入
|
||||
方法一:我们可以先直接引入Recorder核心,然后再引入扩展支持,这样会自动检测到组件已加载
|
||||
<script src="你项目中的路径/src/recorder-core.js"></script>
|
||||
<script src="你项目中的路径/src/extensions/waveview.js"></script>
|
||||
|
||||
方法二:通过注入到Default实现的paths中让RecordApp去自动加载
|
||||
<script>
|
||||
RecordApp.Platforms.Default.Config.paths.push({
|
||||
url:"你项目中的路径/src/extensions/waveview.js"
|
||||
,lazyBeforeStart:1 //开启延迟加载,在Start调用前任何时间进行加载都行
|
||||
,check:function(){return !Recorder.WaveView} //检测是否需要加载
|
||||
});
|
||||
</script>
|
||||
|
||||
方法三:直接修改app.js源码中RecordApp.Platforms.Default.Config.paths,添加需要加载的js
|
||||
-->
|
||||
```
|
||||
|
||||
### 调用录音
|
||||
@@Ref RecordApp.README.Codes@@
|
||||
|
||||
|
||||
--------
|
||||
> 以下文档为GitHub仓库内的README原文,可能更新不及时,请到[GitHub仓库](https://github.com/xiangyuecn/Recorder)内查看最新文档
|
||||
|
||||
@@Ref README.Raw@@
|
||||
|
||||
|
||||
@@Remove Start@@
|
||||
# 作者自用:本npm包如何编写提交
|
||||
|
||||
1. 运行根目录/src:npm start,进行文件copy
|
||||
2. 进入assets/npm-home/npm-files目录,进行提交
|
||||
|
||||
```
|
||||
//登录,注意一定要使用npmjs的源
|
||||
npm login
|
||||
|
||||
//发布包
|
||||
npm publish
|
||||
|
||||
//查询当前配置的源
|
||||
npm get registry
|
||||
//设置成淘宝源
|
||||
npm config set registry http://registry.npm.taobao.org/
|
||||
//换成原来的源
|
||||
npm config set registry https://registry.npmjs.org/
|
||||
```
|
||||
@@Remove End@@
|
||||
22
WebRoot/node_modules/Recorder-master/assets/npm-home/hash-history.txt
generated
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"sha1": "326682217de610f15e0e456fdfe0725362dc5cd2",
|
||||
"time": "2020-6-26 19:35:22"
|
||||
},
|
||||
{
|
||||
"sha1": "ebbcd0cd7c989445ad1c03a053a75aed4672c10c",
|
||||
"time": "2020-6-25 23:11:10"
|
||||
},
|
||||
{
|
||||
"sha1": "e812541a4a38031fdc279b3ded6a7767cd34c3e0",
|
||||
"time": "2020-6-23 11:56:09"
|
||||
},
|
||||
{
|
||||
"sha1": "8f3a1e18f39f4e401496b6911a40858bcf2a5c55",
|
||||
"time": "2020-5-17 10:28:07"
|
||||
},
|
||||
{
|
||||
"sha1": "0e8571bd082df5986d815921360eda7f64034ae5",
|
||||
"time": "2020-5-16 23:29:15"
|
||||
}
|
||||
]
|
||||
29
WebRoot/node_modules/Recorder-master/assets/npm-home/package.json
generated
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "recorder-core",
|
||||
"version": "1.1.123456.9999",
|
||||
"description": "Recorder库:html5 js 录音 mp3 wav ogg webm amr 格式,支持pc和Android、ios部分浏览器、和Hybrid App(提供Android IOS App源码),微信也是支持的,提供H5版语音通话聊天示例 和DTMF编解码",
|
||||
"homepage": "https://github.com/xiangyuecn/Recorder",
|
||||
"main": "src/recorder-core.js",
|
||||
"keywords": [
|
||||
"recorder",
|
||||
"recordapp",
|
||||
"record",
|
||||
"html录音",
|
||||
"h5录音",
|
||||
"html5",
|
||||
"mp3",
|
||||
"wav",
|
||||
"DTMF",
|
||||
"recording",
|
||||
"webrtc"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/xiangyuecn/Recorder.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/xiangyuecn/Recorder/issues"
|
||||
},
|
||||
"author": "xiangyuecn",
|
||||
"license": "MIT"
|
||||
}
|
||||
BIN
WebRoot/node_modules/Recorder-master/assets/qq_group_421882406.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
WebRoot/node_modules/Recorder-master/assets/qq_group_781036591.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 30 KiB |
53
WebRoot/node_modules/Recorder-master/assets/runtime-codes/fragment.playbuffer.js
generated
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
/******************
|
||||
《【Demo库】PCM Buffer播放》
|
||||
作者:高坚果
|
||||
时间:2020-1-10 19:51:18
|
||||
|
||||
文档:
|
||||
DemoFragment.PlayBuffer(store,buffer,sampleRate)
|
||||
store: {} 随便定义一个空对象即可,每次调用的时候传入,用于存储上下文数据
|
||||
buffer:[Int16,...] pcm数据,一维数组
|
||||
sampleRate:buffer的采样率
|
||||
|
||||
调用本方法前,应当确保Recorder.Support()是支持的。
|
||||
本方法默认会缓冲播放,如果缓冲未满将不会进行播放,小片段偶尔播放应当禁用此特性store.PlayBufferDisable=true。
|
||||
******************/
|
||||
(
|
||||
window.DemoFragment||(window.DemoFragment={})
|
||||
).PlayBuffer=function(store,buffer,sampleRate){
|
||||
var size=store.PlayBufferSize||0;
|
||||
var arr=store.PlayBufferArr||[];
|
||||
var st=store.PlayBufferDisable?0:sampleRate/1000*300;//缓冲播放,不然间隔太短接续爆音明显
|
||||
|
||||
size+=buffer.length;
|
||||
arr.push(buffer);
|
||||
if(size>=st){
|
||||
var ctx=Recorder.Ctx;
|
||||
var audio=ctx.createBuffer(1,size,sampleRate);
|
||||
var channel=audio.getChannelData(0);
|
||||
var sd=sampleRate/1000*1;//1ms的淡入淡出 大幅减弱爆音
|
||||
for(var j=0,idx=0;j<arr.length;j++){
|
||||
var buf=arr[j];
|
||||
for(var i=0,l=buf.length,buf_sd=l-sd;i<l;i++){
|
||||
var factor=1;//淡入淡出因子
|
||||
if(i<sd){
|
||||
factor=i/sd;
|
||||
}else if(i>buf_sd){
|
||||
factor=(l-i)/sd;
|
||||
};
|
||||
|
||||
channel[idx++]=buf[i]/0x7FFF*factor;
|
||||
};
|
||||
};
|
||||
var source=ctx.createBufferSource();
|
||||
source.channelCount=1;
|
||||
source.buffer=audio;
|
||||
source.connect(ctx.destination);
|
||||
if(source.start){source.start()}else{source.noteOn(0)};
|
||||
|
||||
size=0;
|
||||
arr=[];
|
||||
};
|
||||
store.PlayBufferSize=size;
|
||||
store.PlayBufferArr=arr;
|
||||
};
|
||||
200
WebRoot/node_modules/Recorder-master/assets/runtime-codes/lib.merge.mp3_merge.js
generated
vendored
Normal file
@ -0,0 +1,200 @@
|
||||
/******************
|
||||
《【Demo库】【文件合并】-mp3多个片段文件合并》
|
||||
作者:高坚果
|
||||
时间:2019-11-3 23:36:18
|
||||
|
||||
文档:
|
||||
Recorder.Mp3Merge(fileBytesList,True,False)
|
||||
fileBytesList:[Uint8Array,...] 所有mp3文件列表,每项为一个文件Uint8Array二进制数组;仅支持lamejs CBR编码出来的mp3,并且列表内的所有mp3的比特率和采样率必须一致
|
||||
True: fn(fileBytes,duration,info) 合并成功回调
|
||||
fileBytes:Uint8Array 为mp3二进制文件
|
||||
duration:合并后的时长
|
||||
info:{
|
||||
sampleRate:123 //采样率
|
||||
,bitRate:8 16 //比特率
|
||||
}
|
||||
False: fn(errMsg) 出错回调
|
||||
|
||||
测试Tips:可先运行实时转码的demo代码,然后再运行本合并代码,免得人工不好控制片段大小
|
||||
|
||||
mp3片段文件合并原理:mp3格式因为lamejs采用的CBR编码,因此将所有mp3文件片段,通过简单的二进制拼接就能得到完整的长mp3。
|
||||
|
||||
Mp3Merge函数可移植到后端使用。
|
||||
******************/
|
||||
|
||||
//=====mp3文件合并核心函数==========
|
||||
Recorder.Mp3Merge=function(fileBytesList,True,False){
|
||||
//计算所有文件的长度、校验mp3信息
|
||||
var size=0,baseInfo;
|
||||
for(var i=0;i<fileBytesList.length;i++){
|
||||
var file=fileBytesList[i];
|
||||
var info=readMp3Info(file);
|
||||
if(!info){
|
||||
False&&False("第"+(i+1)+"个文件不是lamejs mp3格式音频,无法合并");
|
||||
return;
|
||||
};
|
||||
baseInfo||(baseInfo=info);
|
||||
if(baseInfo.sampleRate!=info.sampleRate || baseInfo.bitRate!=info.bitRate){
|
||||
False&&False("第"+(i+1)+"个文件比特率或采样率不一致");
|
||||
return;
|
||||
};
|
||||
|
||||
size+=file.byteLength;
|
||||
};
|
||||
if(size>50*1024*1024){
|
||||
False&&False("文件大小超过限制");
|
||||
return;
|
||||
};
|
||||
|
||||
//全部直接拼接到一起
|
||||
var fileBytes=new Uint8Array(size);
|
||||
var pos=0;
|
||||
for(var i=0;i<fileBytesList.length;i++){
|
||||
var bytes=fileBytesList[i];
|
||||
fileBytes.set(bytes,pos);
|
||||
pos+=bytes.byteLength;
|
||||
};
|
||||
|
||||
//计算合并后的总时长
|
||||
var duration=Math.round(size*8/baseInfo.bitRate);
|
||||
|
||||
True(fileBytes,duration,baseInfo);
|
||||
};
|
||||
var readMp3Info=function(bytes){
|
||||
if(bytes.byteLength<4){
|
||||
return null;
|
||||
};
|
||||
var byteAt=function(idx,u8){
|
||||
return ("0000000"+((u8||bytes)[idx]||0).toString(2)).substr(-8);
|
||||
};
|
||||
var b2=byteAt(0)+byteAt(1);
|
||||
var b4=byteAt(2)+byteAt(3);
|
||||
|
||||
if(!/^1{11}/.test(b2)){//未发现帧同步
|
||||
return null;
|
||||
};
|
||||
var version=({"00":2.5,"10":2,"11":1})[b2.substr(11,2)];
|
||||
var layer=({"01":3})[b2.substr(13,2)];//仅支持Layer3
|
||||
var sampleRate=({ //lamejs -> Tables.samplerate_table
|
||||
"1":[44100, 48000, 32000]
|
||||
,"2":[22050, 24000, 16000]
|
||||
,"2.5":[11025, 12000, 8000]
|
||||
})[version];
|
||||
sampleRate&&(sampleRate=sampleRate[parseInt(b4.substr(4,2),2)]);
|
||||
var bitRate=[ //lamejs -> Tables.bitrate_table
|
||||
[0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160] //MPEG 2 2.5
|
||||
,[0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320]//MPEG 1
|
||||
][version==1?1:0][parseInt(b4.substr(0,4),2)];
|
||||
|
||||
if(!version || !layer || !bitRate || !sampleRate){
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
version:version //1 2 2.5 -> MPEG1 MPEG2 MPEG2.5
|
||||
,layer:layer//3 -> Layer3
|
||||
,sampleRate:sampleRate //采样率 hz
|
||||
,bitRate:bitRate //比特率 kbps
|
||||
};
|
||||
};
|
||||
//=====END=========================
|
||||
|
||||
|
||||
|
||||
|
||||
//合并测试
|
||||
var test=function(){
|
||||
var audios=Runtime.LogAudios;
|
||||
|
||||
var idx=-1 +1,files=[],exclude=0;
|
||||
var read=function(){
|
||||
idx++;
|
||||
if(idx>=audios.length){
|
||||
if(!files.length){
|
||||
Runtime.Log("至少需要录1段mp3"+(exclude?",已排除"+exclude+"个非mp3文件":""),1);
|
||||
return;
|
||||
};
|
||||
Recorder.Mp3Merge(files,function(file,duration,info){
|
||||
Runtime.Log("合并"+files.length+"个成功"+(exclude?",已排除"+exclude+"个非mp3文件":""),2);
|
||||
info.type="mp3";
|
||||
Runtime.LogAudio(new Blob([file.buffer],{type:"audio/mp3"}),duration,{set:info});
|
||||
},function(msg){
|
||||
Runtime.Log(msg+",请清除日志后重试",1);
|
||||
});
|
||||
return;
|
||||
};
|
||||
if(!/mp3/.test(audios[idx].blob.type)){
|
||||
exclude++;
|
||||
read();
|
||||
return;
|
||||
};
|
||||
var reader=new FileReader();
|
||||
reader.onloadend=function(){
|
||||
files.push(new Uint8Array(reader.result));
|
||||
read();
|
||||
};
|
||||
reader.readAsArrayBuffer(audios[idx].blob);
|
||||
};
|
||||
read();
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//=====以下代码无关紧要,音频数据源,采集原始音频用的==================
|
||||
//加载录音框架
|
||||
Runtime.Import([
|
||||
{url:RootFolder+"/src/recorder-core.js",check:function(){return !window.Recorder}}
|
||||
,{url:RootFolder+"/src/engine/mp3.js",check:function(){return !Recorder.prototype.mp3}}
|
||||
,{url:RootFolder+"/src/engine/mp3-engine.js",check:function(){return !Recorder.lamejs}}
|
||||
]);
|
||||
|
||||
//显示控制按钮
|
||||
Runtime.Ctrls([
|
||||
{name:"mp3录音16khz",click:"recStart16"}
|
||||
,{name:"mp3录音32khz",click:"recStart32"}
|
||||
,{name:"结束录音",click:"recStop"}
|
||||
,{name:"合并日志中所有mp3",click:"test"}
|
||||
]);
|
||||
|
||||
|
||||
//调用录音
|
||||
var rec;
|
||||
function recStart16(){
|
||||
recStart(16);
|
||||
};
|
||||
function recStart32(){
|
||||
recStart(32);
|
||||
};
|
||||
function recStart(num){
|
||||
rec=Recorder({
|
||||
type:"mp3"
|
||||
,sampleRate:num*1000
|
||||
,bitRate:num
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate){
|
||||
Runtime.Process.apply(null,arguments);
|
||||
}
|
||||
});
|
||||
var t=setTimeout(function(){
|
||||
Runtime.Log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)",1);
|
||||
},8000);
|
||||
|
||||
rec.open(function(){//打开麦克风授权获得相关资源
|
||||
clearTimeout(t);
|
||||
rec.start();//开始录音
|
||||
},function(msg,isUserNotAllow){//用户拒绝未授权或不支持
|
||||
clearTimeout(t);
|
||||
Runtime.Log((isUserNotAllow?"UserNotAllow,":"")+"无法录音:"+msg, 1);
|
||||
});
|
||||
};
|
||||
function recStop(){
|
||||
rec.stop(function(blob,duration){
|
||||
rec.close();//释放录音资源
|
||||
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
},function(msg){
|
||||
Runtime.Log("录音失败:"+msg, 1);
|
||||
});
|
||||
};
|
||||
200
WebRoot/node_modules/Recorder-master/assets/runtime-codes/lib.merge.wav_merge.js
generated
vendored
Normal file
@ -0,0 +1,200 @@
|
||||
/******************
|
||||
《【Demo库】【文件合并】-wav多个片段文件合并》
|
||||
作者:高坚果
|
||||
时间:2019-11-3 23:36:18
|
||||
|
||||
文档:
|
||||
Recorder.WavMerge(fileBytesList,True,False)
|
||||
fileBytesList:[Uint8Array,...] 所有wav文件列表,每项为一个文件Uint8Array二进制数组;仅支持raw pcm、单声道、8|16位格式wav,并且列表内的所有wav的位数和采样率必须一致
|
||||
True: fn(fileBytes,duration,info) 合并成功回调
|
||||
fileBytes:Uint8Array 为wav二进制文件
|
||||
duration:合并后的时长
|
||||
info:{
|
||||
sampleRate:123 //采样率
|
||||
,bitRate:8 16 //位数
|
||||
}
|
||||
False: fn(errMsg) 出错回调
|
||||
此函数可移植到后端使用
|
||||
|
||||
测试Tips:可先运行实时转码的demo代码,然后再运行本合并代码,免得人工不好控制片段大小
|
||||
|
||||
wav片段文件合并原理:本库wav格式音频是用44字节wav头+PCM数据来构成的,因此只需要将所有片段去掉44字节后,通过简单的二进制拼接就能得到完整的长pcm数据,最后在加上44字节wav头就能得到完整的wav音频文件。
|
||||
******************/
|
||||
|
||||
//=====wav文件合并核心函数==========
|
||||
Recorder.WavMerge=function(fileBytesList,True,False){
|
||||
var wavHead=new Uint8Array(fileBytesList[0].buffer.slice(0,44));
|
||||
|
||||
//计算所有文件的长度、校验wav头
|
||||
var size=0,baseInfo;
|
||||
for(var i=0;i<fileBytesList.length;i++){
|
||||
var file=fileBytesList[i];
|
||||
var info=readWavInfo(file);
|
||||
if(!info){
|
||||
False&&False("第"+(i+1)+"个文件不是单声道wav raw pcm格式音频,无法合并");
|
||||
return;
|
||||
};
|
||||
baseInfo||(baseInfo=info);
|
||||
if(baseInfo.sampleRate!=info.sampleRate || baseInfo.bitRate!=info.bitRate){
|
||||
False&&False("第"+(i+1)+"个文件位数或采样率不一致");
|
||||
return;
|
||||
};
|
||||
|
||||
size+=file.byteLength-44;
|
||||
};
|
||||
if(size>50*1024*1024){
|
||||
False&&False("文件大小超过限制");
|
||||
return;
|
||||
};
|
||||
|
||||
//去掉wav头后全部拼接到一起
|
||||
var fileBytes=new Uint8Array(44+size);
|
||||
var pos=44;
|
||||
for(var i=0;i<fileBytesList.length;i++){
|
||||
var pcm=new Uint8Array(fileBytesList[i].buffer.slice(44));
|
||||
fileBytes.set(pcm,pos);
|
||||
pos+=pcm.byteLength;
|
||||
};
|
||||
|
||||
//添加新的wav头,直接修改第一个的头就ok了
|
||||
write32(wavHead,4,36+size);
|
||||
write32(wavHead,40,size);
|
||||
fileBytes.set(wavHead,0);
|
||||
|
||||
//计算合并后的总时长
|
||||
var duration=Math.round(size/info.sampleRate*1000/(info.bitRate==16?2:1));
|
||||
|
||||
True(fileBytes,duration,baseInfo);
|
||||
};
|
||||
var write32=function(bytes,pos,int32){
|
||||
bytes[pos]=(int32)&0xff;
|
||||
bytes[pos+1]=(int32>>8)&0xff;
|
||||
bytes[pos+2]=(int32>>16)&0xff;
|
||||
bytes[pos+3]=(int32>>24)&0xff;
|
||||
};
|
||||
var readWavInfo=function(bytes){
|
||||
//检测wav文件头
|
||||
if(bytes.byteLength<44){
|
||||
return null;
|
||||
};
|
||||
var wavView=bytes;
|
||||
var eq=function(p,s){
|
||||
for(var i=0;i<s.length;i++){
|
||||
if(wavView[p+i]!=s.charCodeAt(i)){
|
||||
return false;
|
||||
};
|
||||
};
|
||||
return true;
|
||||
};
|
||||
if(eq(0,"RIFF")&&eq(8,"WAVEfmt ")){
|
||||
if(wavView[20]==1 && wavView[22]==1){//raw pcm 单声道
|
||||
var sampleRate=wavView[24]+(wavView[25]<<8)+(wavView[26]<<16)+(wavView[27]<<24);
|
||||
var bitRate=wavView[34]+(wavView[35]<<8);
|
||||
return {
|
||||
sampleRate:sampleRate
|
||||
,bitRate:bitRate
|
||||
};
|
||||
};
|
||||
};
|
||||
return null;
|
||||
};
|
||||
//=====END=========================
|
||||
|
||||
|
||||
|
||||
|
||||
//合并测试
|
||||
var test=function(){
|
||||
var audios=Runtime.LogAudios;
|
||||
|
||||
var idx=-1 +1,files=[],exclude=0;
|
||||
var read=function(){
|
||||
idx++;
|
||||
if(idx>=audios.length){
|
||||
if(!files.length){
|
||||
Runtime.Log("至少需要录1段wav"+(exclude?",已排除"+exclude+"个非wav文件":""),1);
|
||||
return;
|
||||
};
|
||||
Recorder.WavMerge(files,function(file,duration,info){
|
||||
Runtime.Log("合并"+files.length+"个成功"+(exclude?",排除"+exclude+"个非wav文件":""),2);
|
||||
info.type="wav";
|
||||
Runtime.LogAudio(new Blob([file.buffer],{type:"audio/wav"}),duration,{set:info});
|
||||
},function(msg){
|
||||
Runtime.Log(msg+",请清除日志后重试",1);
|
||||
});
|
||||
return;
|
||||
};
|
||||
if(!/wav/.test(audios[idx].blob.type)){
|
||||
exclude++;
|
||||
read();
|
||||
return;
|
||||
};
|
||||
var reader=new FileReader();
|
||||
reader.onloadend=function(){
|
||||
files.push(new Uint8Array(reader.result));
|
||||
read();
|
||||
};
|
||||
reader.readAsArrayBuffer(audios[idx].blob);
|
||||
};
|
||||
read();
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//=====以下代码无关紧要,音频数据源,采集原始音频用的==================
|
||||
//加载录音框架
|
||||
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}}
|
||||
]);
|
||||
|
||||
//显示控制按钮
|
||||
Runtime.Ctrls([
|
||||
{name:"16位wav录音",click:"recStart16"}
|
||||
,{name:"8位wav录音",click:"recStart8"}
|
||||
,{name:"结束录音",click:"recStop"}
|
||||
,{name:"合并日志中所有wav",click:"test"}
|
||||
]);
|
||||
|
||||
|
||||
//调用录音
|
||||
var rec;
|
||||
function recStart16(){
|
||||
recStart(16);
|
||||
};
|
||||
function recStart8(){
|
||||
recStart(8);
|
||||
};
|
||||
function recStart(bitRate){
|
||||
rec=Recorder({
|
||||
type:"wav"
|
||||
,sampleRate:16000
|
||||
,bitRate:bitRate
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate){
|
||||
Runtime.Process.apply(null,arguments);
|
||||
}
|
||||
});
|
||||
var t=setTimeout(function(){
|
||||
Runtime.Log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)",1);
|
||||
},8000);
|
||||
|
||||
rec.open(function(){//打开麦克风授权获得相关资源
|
||||
clearTimeout(t);
|
||||
rec.start();//开始录音
|
||||
},function(msg,isUserNotAllow){//用户拒绝未授权或不支持
|
||||
clearTimeout(t);
|
||||
Runtime.Log((isUserNotAllow?"UserNotAllow,":"")+"无法录音:"+msg, 1);
|
||||
});
|
||||
};
|
||||
function recStop(){
|
||||
rec.stop(function(blob,duration){
|
||||
rec.close();//释放录音资源
|
||||
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
},function(msg){
|
||||
Runtime.Log("录音失败:"+msg, 1);
|
||||
});
|
||||
};
|
||||
184
WebRoot/node_modules/Recorder-master/assets/runtime-codes/lib.samplerate.raise.js
generated
vendored
Normal file
@ -0,0 +1,184 @@
|
||||
/******************
|
||||
《【Demo库】PCM采样率提升》
|
||||
作者:高坚果
|
||||
时间:2020-1-9 20:48:39
|
||||
|
||||
文档:
|
||||
Recorder.SampleRaise(pcmDatas,pcmSampleRate,newSampleRate)
|
||||
pcmDatas: [[Int16,...]] pcm片段列表,二维数组
|
||||
pcmSampleRate:pcm的采样率
|
||||
newSampleRate:要转换成的采样率
|
||||
|
||||
返回值:{
|
||||
sampleRate:16000 结果的采样率,>=pcmSampleRate
|
||||
data:[Int16,...] 转换后的PCM结果
|
||||
}
|
||||
|
||||
本方法将简单的提升pcm的采样率,如果新采样率低于pcm采样率,将不会进行任何处理。采用的简单算法能力有限,会引入能感知到的轻微杂音。
|
||||
|
||||
Recorder.SampleData只提供降低采样率,不提供提升采样率,因为由低的采样率转成高的采样率没有存在的意义。提升采样率的代码不会作为核心功能提供,但某些场合确实需要提升采样率,可自行编写代码转换一下即可。
|
||||
******************/
|
||||
|
||||
//=====采样率提升核心函数==========
|
||||
Recorder.SampleRaise=function(pcmDatas,pcmSampleRate,newSampleRate){
|
||||
var size=0;
|
||||
for(var i=0;i<pcmDatas.length;i++){
|
||||
size+=pcmDatas[i].length;
|
||||
};
|
||||
|
||||
var step=newSampleRate/pcmSampleRate;
|
||||
if(step<=1){//新采样不高于pcm采样率不处理
|
||||
step=1;
|
||||
newSampleRate=pcmSampleRate;
|
||||
}else{
|
||||
size=Math.floor(size*step);
|
||||
};
|
||||
|
||||
var res=new Int16Array(size);
|
||||
|
||||
//处理数据
|
||||
var posFloat=0,prev=0;
|
||||
for (var index=0,nl=pcmDatas.length;index<nl;index++) {
|
||||
var arr=pcmDatas[index];
|
||||
for(var i=0;i<arr.length;i++){
|
||||
var cur=arr[i];
|
||||
|
||||
var pos=Math.floor(posFloat);
|
||||
posFloat+=step;
|
||||
var end=Math.floor(posFloat);
|
||||
|
||||
//简单的从prev平滑填充到cur,有效减少转换引入的杂音
|
||||
var n=(cur-prev)/(end-pos);
|
||||
for(var j=1;pos<end;pos++,j++){
|
||||
//res[pos]=cur;
|
||||
res[pos]=Math.floor(prev+(j*n));
|
||||
};
|
||||
|
||||
prev=cur;
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
sampleRate:newSampleRate
|
||||
,data:res
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
//************测试************
|
||||
var sampleRaiseInfo=window.sampleRaiseInfo||{from:16000,to:44100};
|
||||
var transform=function(buffers,sampleRate){
|
||||
sampleRaiseInfo.buffers=buffers;
|
||||
sampleRaiseInfo.sampleRate=sampleRate;
|
||||
if(!buffers){
|
||||
Runtime.Log("请先录个音",1);
|
||||
return;
|
||||
};
|
||||
var from=sampleRaiseInfo.from;
|
||||
var to=sampleRaiseInfo.to;
|
||||
|
||||
//准备低采样率数据
|
||||
var pcmFrom=Recorder.SampleData(buffers,sampleRate,from).data;
|
||||
|
||||
//转换成高采样率
|
||||
var pcmTo=Recorder.SampleRaise([pcmFrom],from,to).data;
|
||||
|
||||
var mockFrom=Recorder({type:"wav",sampleRate:from}).mock(pcmFrom,from);
|
||||
mockFrom.stop(function(blob1,duration1){
|
||||
|
||||
var mockTo=Recorder({type:"wav",sampleRate:to}).mock(pcmTo,to);
|
||||
mockTo.stop(function(blob2,duration2){
|
||||
Runtime.Log(from+"->"+to,2);
|
||||
|
||||
Runtime.LogAudio(blob1,duration1,mockFrom,"低采样");
|
||||
Runtime.LogAudio(blob2,duration2,mockTo,"高采样");
|
||||
});
|
||||
|
||||
});
|
||||
};
|
||||
var k8k16=function(){
|
||||
sampleRaiseInfo.from=8000;
|
||||
sampleRaiseInfo.to=16000;
|
||||
|
||||
transform(sampleRaiseInfo.buffers,sampleRaiseInfo.sampleRate);
|
||||
};
|
||||
var k16k441=function(){
|
||||
sampleRaiseInfo.from=16000;
|
||||
sampleRaiseInfo.to=44100;
|
||||
|
||||
transform(sampleRaiseInfo.buffers,sampleRaiseInfo.sampleRate);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
//******音频数据源,采集原始音频用的******
|
||||
//加载录音框架
|
||||
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}}
|
||||
]);
|
||||
|
||||
//显示控制按钮
|
||||
Runtime.Ctrls([
|
||||
{name:"开始录音",click:"recStart"}
|
||||
,{name:"结束录音",click:"recStop"}
|
||||
,{html:"<hr/>"}
|
||||
,{name:"8k转16k",click:"k8k16"}
|
||||
,{name:"16k转44.1k",click:"k16k441"}
|
||||
|
||||
,{choiceFile:{
|
||||
process:function(fileName,arrayBuffer,filesCount,fileIdx,endCall){
|
||||
Runtime.DecodeAudio(fileName,arrayBuffer,function(data){
|
||||
Runtime.LogAudio(data.srcBlob,data.duration,{set:data},"已解码"+fileName);
|
||||
|
||||
rec=null;
|
||||
transform([data.data],data.sampleRate);
|
||||
|
||||
endCall();
|
||||
},function(msg){
|
||||
Runtime.Log(msg,1);
|
||||
endCall();
|
||||
});
|
||||
}
|
||||
}}
|
||||
]);
|
||||
|
||||
|
||||
//调用录音
|
||||
var rec;
|
||||
function recStart(){
|
||||
rec=Recorder({
|
||||
type:"wav"
|
||||
,sampleRate:48000
|
||||
,bitRate:16
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate){
|
||||
Runtime.Process.apply(null,arguments);
|
||||
}
|
||||
});
|
||||
var t=setTimeout(function(){
|
||||
Runtime.Log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)",1);
|
||||
},8000);
|
||||
|
||||
rec.open(function(){//打开麦克风授权获得相关资源
|
||||
clearTimeout(t);
|
||||
rec.start();//开始录音
|
||||
},function(msg,isUserNotAllow){//用户拒绝未授权或不支持
|
||||
clearTimeout(t);
|
||||
Runtime.Log((isUserNotAllow?"UserNotAllow,":"")+"无法录音:"+msg, 1);
|
||||
});
|
||||
};
|
||||
function recStop(){
|
||||
rec.stop(function(blob,duration){
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
|
||||
transform(rec.buffers,rec.srcSampleRate);
|
||||
},function(msg){
|
||||
Runtime.Log("录音失败:"+msg, 1);
|
||||
},true);
|
||||
};
|
||||
|
||||
|
||||
|
||||
Runtime.Log("结束录音转换格式以最后点击的哪个为准");
|
||||
135
WebRoot/node_modules/Recorder-master/assets/runtime-codes/lib.transform.amr2other.js
generated
vendored
Normal file
@ -0,0 +1,135 @@
|
||||
/******************
|
||||
《【Demo库】【格式转换】-amr格式转成其他格式》
|
||||
作者:高坚果
|
||||
时间:2020-6-24 22:54:41
|
||||
|
||||
文档:
|
||||
Recorder.AMR2Other(newSet,amrBlob,True,False)
|
||||
newSet:Recorder的set参数,用来生成新格式,注意:要先加载好新格式的编码引擎
|
||||
amrBlob:amr二进制数据,注意只支持AMR-NB编码(8000hz)
|
||||
True: fn(blob,duration,mockRec) 和Recorder的stop函数参数一致,mockRec为转码时用到的Recorder对象引用
|
||||
False: fn(errMsg) 和Recorder的stop函数参数一致
|
||||
******************/
|
||||
|
||||
//=====amr转其他格式核心函数==========
|
||||
Recorder.AMR2Other=function(newSet,amrBlob,True,False){
|
||||
var reader=new FileReader();
|
||||
reader.onload=function(){
|
||||
var amr=new Uint8Array(reader.result);
|
||||
Recorder.AMR.decode(amr,function(pcm){
|
||||
var rec=Recorder(newSet).mock(pcm,8000);
|
||||
rec.stop(function(blob,duration){
|
||||
True(blob,duration,rec);
|
||||
},False);
|
||||
},False);
|
||||
};
|
||||
reader.readAsArrayBuffer(amrBlob);
|
||||
};
|
||||
//=====END=========================
|
||||
|
||||
|
||||
|
||||
//转换测试
|
||||
var test=function(amrBlob){
|
||||
if(!amrBlob){
|
||||
Runtime.Log("无数据源,请先录音",1);
|
||||
return;
|
||||
};
|
||||
var set={
|
||||
type:"mp3"
|
||||
,sampleRate:16000
|
||||
,bitRate:16
|
||||
};
|
||||
|
||||
//数据格式一 Blob
|
||||
Recorder.AMR2Other(set,amrBlob,function(blob,duration,rec){
|
||||
console.log(blob,(window.URL||webkitURL).createObjectURL(blob));
|
||||
Runtime.Log("amr src blob 转换成 mp3...",2);
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
},function(msg){
|
||||
Runtime.Log(msg,1);
|
||||
});
|
||||
|
||||
//数据格式二 Base64 模拟
|
||||
var reader=new FileReader();
|
||||
reader.onloadend=function(){
|
||||
var base64=(/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result)||[])[1];
|
||||
|
||||
//数据格式二核心代码,以上代码无关紧要
|
||||
var bstr=atob(base64),n=bstr.length,u8arr=new Uint8Array(n);
|
||||
while(n--){
|
||||
u8arr[n]=bstr.charCodeAt(n);
|
||||
};
|
||||
|
||||
Recorder.AMR2Other(set,new Blob([u8arr.buffer]),function(blob,duration,rec){
|
||||
Runtime.Log("amr as base64 转换成 mp3...",2);
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
},function(msg){
|
||||
Runtime.Log(msg,1);
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(amrBlob);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//=====以下代码无关紧要,音频数据源,采集原始音频用的==================
|
||||
//加载录音框架
|
||||
Runtime.Import([
|
||||
{url:RootFolder+"/src/recorder-core.js",check:function(){return !window.Recorder}}
|
||||
,{url:"https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/dist/engine/mp3.js",check:function(){return !Recorder.prototype.mp3}}
|
||||
,{url:"https://cdn.jsdelivr.net/gh/xiangyuecn/Recorder@latest/dist/engine/beta-amr.js",check:function(){return !Recorder.prototype.amr}}
|
||||
]);
|
||||
|
||||
//显示控制按钮
|
||||
Runtime.Ctrls([
|
||||
{name:"开始amr录音",click:"recStart"}
|
||||
,{name:"结束录音并转换",click:"recStop"}
|
||||
|
||||
,{choiceFile:{
|
||||
multiple:false
|
||||
,name:"amr"
|
||||
,mime:"audio/amr"
|
||||
,process:function(fileName,arrayBuffer,filesCount,fileIdx,endCall){
|
||||
test(new Blob([arrayBuffer]));
|
||||
endCall();
|
||||
}
|
||||
}}
|
||||
]);
|
||||
|
||||
|
||||
//调用录音
|
||||
var rec;
|
||||
function recStart(){
|
||||
rec=Recorder({
|
||||
type:"amr"
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate){
|
||||
Runtime.Process.apply(null,arguments);
|
||||
}
|
||||
});
|
||||
var t=setTimeout(function(){
|
||||
Runtime.Log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)",1);
|
||||
},8000);
|
||||
|
||||
rec.open(function(){//打开麦克风授权获得相关资源
|
||||
clearTimeout(t);
|
||||
rec.start();//开始录音
|
||||
},function(msg,isUserNotAllow){//用户拒绝未授权或不支持
|
||||
clearTimeout(t);
|
||||
Runtime.Log((isUserNotAllow?"UserNotAllow,":"")+"无法录音:"+msg, 1);
|
||||
});
|
||||
};
|
||||
function recStop(){
|
||||
rec.stop(function(blob,duration){
|
||||
rec.close();//释放录音资源
|
||||
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
|
||||
test(blob);
|
||||
},function(msg){
|
||||
Runtime.Log("录音失败:"+msg, 1);
|
||||
});
|
||||
};
|
||||
155
WebRoot/node_modules/Recorder-master/assets/runtime-codes/lib.transform.mp32other.js
generated
vendored
Normal file
@ -0,0 +1,155 @@
|
||||
/******************
|
||||
《【Demo库】【格式转换】-mp3格式转成其他格式》
|
||||
作者:高坚果
|
||||
时间:2019-10-22 15:20:57
|
||||
|
||||
文档:
|
||||
Recorder.Mp32Other(newSet,mp3Blob,True,False)
|
||||
newSet:Recorder的set参数,用来生成新格式,注意:要先加载好新格式的编码引擎
|
||||
mp3Blob:mp3二进制数据
|
||||
True: fn(blob,duration,mockRec) 和Recorder的stop函数参数一致,mockRec为转码时用到的Recorder对象引用
|
||||
False: fn(errMsg) 和Recorder的stop函数参数一致
|
||||
******************/
|
||||
|
||||
//=====mp3转其他格式核心函数==========
|
||||
Recorder.Mp32Other=function(newSet,mp3Blob,True,False){
|
||||
if(!Recorder.Support()){//强制激活Recorder.Ctx 不支持大概率也不支持解码
|
||||
False&&False("浏览器不支持mp3解码");
|
||||
return;
|
||||
};
|
||||
|
||||
var reader=new FileReader();
|
||||
reader.onloadend=function(){
|
||||
var ctx=Recorder.Ctx;
|
||||
ctx.decodeAudioData(reader.result,function(raw){
|
||||
var src=raw.getChannelData(0);
|
||||
var sampleRate=raw.sampleRate;
|
||||
|
||||
var pcm=new Int16Array(src.length);
|
||||
for(var i=0;i<src.length;i++){//floatTo16BitPCM
|
||||
var s=Math.max(-1,Math.min(1,src[i]));
|
||||
s=s<0?s*0x8000:s*0x7FFF;
|
||||
pcm[i]=s;
|
||||
};
|
||||
|
||||
var rec=Recorder(newSet).mock(pcm,sampleRate);
|
||||
rec.stop(function(blob,duration){
|
||||
True(blob,duration,rec);
|
||||
},False);
|
||||
},function(e){
|
||||
False&&False("mp3解码失败:"+e.message);
|
||||
});
|
||||
};
|
||||
reader.readAsArrayBuffer(mp3Blob);
|
||||
};
|
||||
//=====END=========================
|
||||
|
||||
|
||||
|
||||
//转换测试
|
||||
var test=function(mp3Blob){
|
||||
if(!mp3Blob){
|
||||
Runtime.Log("无数据源,请先录音",1);
|
||||
return;
|
||||
};
|
||||
var set={
|
||||
type:"wav"
|
||||
,sampleRate:48000
|
||||
,bitRate:16
|
||||
};
|
||||
|
||||
//数据格式一 Blob
|
||||
Recorder.Mp32Other(set,mp3Blob,function(blob,duration,rec){
|
||||
console.log(blob,(window.URL||webkitURL).createObjectURL(blob));
|
||||
Runtime.Log("mp3 src blob 转换成 wav...",2);
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
},function(msg){
|
||||
Runtime.Log(msg,1);
|
||||
});
|
||||
|
||||
//数据格式二 Base64 模拟
|
||||
var reader=new FileReader();
|
||||
reader.onloadend=function(){
|
||||
var base64=(/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result)||[])[1];
|
||||
|
||||
//数据格式二核心代码,以上代码无关紧要
|
||||
var bstr=atob(base64),n=bstr.length,u8arr=new Uint8Array(n);
|
||||
while(n--){
|
||||
u8arr[n]=bstr.charCodeAt(n);
|
||||
};
|
||||
|
||||
Recorder.Mp32Other(set,new Blob([u8arr.buffer]),function(blob,duration,rec){
|
||||
Runtime.Log("mp3 as base64 转换成 wav...",2);
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
},function(msg){
|
||||
Runtime.Log(msg,1);
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(mp3Blob);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//=====以下代码无关紧要,音频数据源,采集原始音频用的==================
|
||||
//加载录音框架
|
||||
Runtime.Import([
|
||||
{url:RootFolder+"/src/recorder-core.js",check:function(){return !window.Recorder}}
|
||||
,{url:RootFolder+"/src/engine/mp3.js",check:function(){return !Recorder.prototype.mp3}}
|
||||
,{url:RootFolder+"/src/engine/mp3-engine.js",check:function(){return !Recorder.lamejs}}
|
||||
,{url:RootFolder+"/src/engine/wav.js",check:function(){return !Recorder.prototype.wav}}
|
||||
]);
|
||||
|
||||
//显示控制按钮
|
||||
Runtime.Ctrls([
|
||||
{name:"开始mp3录音",click:"recStart"}
|
||||
,{name:"结束录音并转换",click:"recStop"}
|
||||
|
||||
,{choiceFile:{
|
||||
multiple:false
|
||||
,name:"mp3"
|
||||
,mime:"audio/mp3"
|
||||
,process:function(fileName,arrayBuffer,filesCount,fileIdx,endCall){
|
||||
test(new Blob([arrayBuffer]));
|
||||
endCall();
|
||||
}
|
||||
}}
|
||||
]);
|
||||
|
||||
|
||||
//调用录音
|
||||
var rec;
|
||||
function recStart(){
|
||||
rec=Recorder({
|
||||
type:"mp3"
|
||||
,sampleRate:32000
|
||||
,bitRate:96
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate){
|
||||
Runtime.Process.apply(null,arguments);
|
||||
}
|
||||
});
|
||||
var t=setTimeout(function(){
|
||||
Runtime.Log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)",1);
|
||||
},8000);
|
||||
|
||||
rec.open(function(){//打开麦克风授权获得相关资源
|
||||
clearTimeout(t);
|
||||
rec.start();//开始录音
|
||||
},function(msg,isUserNotAllow){//用户拒绝未授权或不支持
|
||||
clearTimeout(t);
|
||||
Runtime.Log((isUserNotAllow?"UserNotAllow,":"")+"无法录音:"+msg, 1);
|
||||
});
|
||||
};
|
||||
function recStop(){
|
||||
rec.stop(function(blob,duration){
|
||||
rec.close();//释放录音资源
|
||||
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
|
||||
test(blob);
|
||||
},function(msg){
|
||||
Runtime.Log("录音失败:"+msg, 1);
|
||||
});
|
||||
};
|
||||
174
WebRoot/node_modules/Recorder-master/assets/runtime-codes/lib.transform.wav2other.js
generated
vendored
Normal file
@ -0,0 +1,174 @@
|
||||
/******************
|
||||
《【Demo库】【格式转换】-wav格式转成其他格式》
|
||||
作者:高坚果
|
||||
时间:2019-10-22 13:48:35
|
||||
|
||||
文档:
|
||||
Recorder.Wav2Other(newSet,wavBlob,True,False)
|
||||
newSet:Recorder的set参数,用来生成新格式,注意:要先加载好新格式的编码引擎
|
||||
wavBlob:wav二进制数据
|
||||
True: fn(blob,duration,mockRec) 和Recorder的stop函数参数一致,mockRec为转码时用到的Recorder对象引用
|
||||
False: fn(errMsg) 和Recorder的stop函数参数一致
|
||||
******************/
|
||||
|
||||
//=====wav转其他格式核心函数==========
|
||||
Recorder.Wav2Other=function(newSet,wavBlob,True,False){
|
||||
var reader=new FileReader();
|
||||
reader.onloadend=function(){
|
||||
//检测wav文件头
|
||||
var wavView=new Uint8Array(reader.result);
|
||||
var eq=function(p,s){
|
||||
for(var i=0;i<s.length;i++){
|
||||
if(wavView[p+i]!=s.charCodeAt(i)){
|
||||
return false;
|
||||
};
|
||||
};
|
||||
return true;
|
||||
};
|
||||
var pcm;
|
||||
if(eq(0,"RIFF")&&eq(8,"WAVEfmt ")){
|
||||
if(wavView[20]==1 && wavView[22]==1){//raw pcm 单声道
|
||||
var sampleRate=wavView[24]+(wavView[25]<<8)+(wavView[26]<<16)+(wavView[27]<<24);
|
||||
var bitRate=wavView[34]+(wavView[35]<<8);
|
||||
console.log("wav info",sampleRate,bitRate);
|
||||
if(bitRate==16){
|
||||
pcm=new Int16Array(wavView.buffer.slice(44));
|
||||
}else if(bitRate==8){
|
||||
pcm=new Int16Array(wavView.length-44);
|
||||
//8位转成16位
|
||||
for(var j=44,d=0;j<wavView.length;j++,d++){
|
||||
var b=wavView[j];
|
||||
pcm[d]=(b-128)<<8;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
if(!pcm){
|
||||
False&&False("非单声道wav raw pcm格式音频,无法转码");
|
||||
return;
|
||||
};
|
||||
|
||||
var rec=Recorder(newSet).mock(pcm,sampleRate);
|
||||
rec.stop(function(blob,duration){
|
||||
True(blob,duration,rec);
|
||||
},False);
|
||||
};
|
||||
reader.readAsArrayBuffer(wavBlob);
|
||||
};
|
||||
//=====END=========================
|
||||
|
||||
|
||||
|
||||
//转换测试
|
||||
var test=function(wavBlob){
|
||||
if(!wavBlob){
|
||||
Runtime.Log("无数据源,请先录音",1);
|
||||
return;
|
||||
};
|
||||
var set={
|
||||
type:"mp3"
|
||||
,sampleRate:48000
|
||||
,bitRate:96
|
||||
};
|
||||
|
||||
//数据格式一 Blob
|
||||
Recorder.Wav2Other(set,wavBlob,function(blob,duration,rec){
|
||||
console.log(blob,(window.URL||webkitURL).createObjectURL(blob));
|
||||
Runtime.Log("wav src blob 转换成 mp3...",2);
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
},function(msg){
|
||||
Runtime.Log(msg,1);
|
||||
});
|
||||
|
||||
//数据格式二 Base64 模拟
|
||||
var reader=new FileReader();
|
||||
reader.onloadend=function(){
|
||||
var base64=(/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result)||[])[1];
|
||||
|
||||
//数据格式二核心代码,以上代码无关紧要
|
||||
var bstr=atob(base64),n=bstr.length,u8arr=new Uint8Array(n);
|
||||
while(n--){
|
||||
u8arr[n]=bstr.charCodeAt(n);
|
||||
};
|
||||
|
||||
Recorder.Wav2Other(set,new Blob([u8arr.buffer]),function(blob,duration,rec){
|
||||
Runtime.Log("wav as base64 转换成 mp3...",2);
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
},function(msg){
|
||||
Runtime.Log(msg,1);
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(wavBlob);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//=====以下代码无关紧要,音频数据源,采集原始音频用的==================
|
||||
//加载录音框架
|
||||
Runtime.Import([
|
||||
{url:RootFolder+"/src/recorder-core.js",check:function(){return !window.Recorder}}
|
||||
,{url:RootFolder+"/src/engine/mp3.js",check:function(){return !Recorder.prototype.mp3}}
|
||||
,{url:RootFolder+"/src/engine/mp3-engine.js",check:function(){return !Recorder.lamejs}}
|
||||
,{url:RootFolder+"/src/engine/wav.js",check:function(){return !Recorder.prototype.wav}}
|
||||
]);
|
||||
|
||||
//显示控制按钮
|
||||
Runtime.Ctrls([
|
||||
{name:"16位wav录音",click:"recStart16"}
|
||||
,{name:"8位wav录音",click:"recStart8"}
|
||||
,{name:"结束录音并转换",click:"recStop"}
|
||||
|
||||
,{choiceFile:{
|
||||
multiple:false
|
||||
,name:"wav"
|
||||
,mime:"audio/wav"
|
||||
,process:function(fileName,arrayBuffer,filesCount,fileIdx,endCall){
|
||||
test(new Blob([arrayBuffer]));
|
||||
endCall();
|
||||
}
|
||||
}}
|
||||
]);
|
||||
|
||||
|
||||
//调用录音
|
||||
var rec;
|
||||
function recStart16(){
|
||||
recStart(16);
|
||||
};
|
||||
function recStart8(){
|
||||
recStart(8);
|
||||
};
|
||||
function recStart(bitRate){
|
||||
rec=Recorder({
|
||||
type:"wav"
|
||||
,bitRate:bitRate
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate){
|
||||
Runtime.Process.apply(null,arguments);
|
||||
}
|
||||
});
|
||||
var t=setTimeout(function(){
|
||||
Runtime.Log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)",1);
|
||||
},8000);
|
||||
|
||||
rec.open(function(){//打开麦克风授权获得相关资源
|
||||
clearTimeout(t);
|
||||
rec.start();//开始录音
|
||||
},function(msg,isUserNotAllow){//用户拒绝未授权或不支持
|
||||
clearTimeout(t);
|
||||
Runtime.Log((isUserNotAllow?"UserNotAllow,":"")+"无法录音:"+msg, 1);
|
||||
});
|
||||
};
|
||||
function recStop(){
|
||||
rec.stop(function(blob,duration){
|
||||
rec.close();//释放录音资源
|
||||
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
|
||||
test(blob);
|
||||
},function(msg){
|
||||
Runtime.Log("录音失败:"+msg, 1);
|
||||
});
|
||||
};
|
||||
203
WebRoot/node_modules/Recorder-master/assets/runtime-codes/teach.dtmf.decode_and_encode.js
generated
vendored
Normal file
@ -0,0 +1,203 @@
|
||||
/******************
|
||||
《【教程】DTMF(电话拨号按键信号)解码、编码》
|
||||
作者:高坚果
|
||||
时间:2020-6-24 22:54:41
|
||||
|
||||
通过DTMF(电话拨号按键信号)解码器扩展 /src/extensions/dtmf.decode.js,可实现实时从音频数据流中解码得到电话拨号按键信息。
|
||||
|
||||
通过DTMF(电话拨号按键信号)编码生成器扩展 /src/extensions/dtmf.encode.js,可实现生成按键对应的音频PCM信号。
|
||||
|
||||
解码使用场景:电话录音软解,软电话实时提取DTMF按键信号等
|
||||
编码使用场景:DTMF按键信号生成,软电话实时发送DTMF按键信号等
|
||||
|
||||
其他工具参考:
|
||||
DTMF2NUM命令行工具: http://aluigi.altervista.org/mytoolz.htm#dtmf2num
|
||||
******************/
|
||||
|
||||
var curPCM,curSampleRate;
|
||||
var setPCM=function(pcm,sampleRate){
|
||||
curPCM=pcm;
|
||||
curSampleRate=sampleRate;
|
||||
};
|
||||
|
||||
//*****DTMF解码得到按键信息*******
|
||||
var decodeDTMF=function(){
|
||||
if(!curPCM){
|
||||
Runtime.Log("请先录个音",1);
|
||||
return;
|
||||
};
|
||||
Runtime.Log("开始识别DTMF...",2);
|
||||
|
||||
//数据太长时应当分段延时处理,这顺带假装处理实时音频流
|
||||
var chunk,chunkSize=curSampleRate/12;
|
||||
var idx=0,finds=[];
|
||||
var run=function(){
|
||||
for(var n=0;idx<curPCM.length;n++,idx+=chunkSize){//分块处理,伪装成实时流
|
||||
chunk=decodeStream(curPCM.subarray(idx,idx+chunkSize),curSampleRate,chunk);
|
||||
for(var i=0;i<chunk.keys.length;i++){
|
||||
finds.push(chunk.keys[i].key);
|
||||
};
|
||||
|
||||
if(n==12*10){//10秒数据量延时一下
|
||||
setTimeout(run);
|
||||
return;
|
||||
};
|
||||
};
|
||||
Runtime.Log("识别完毕,"+(finds.length?"发现按键:"+finds.join(""):"未发现按键信息"),2);
|
||||
};
|
||||
run();
|
||||
};
|
||||
var decodeStream=function(pcm,sampleRate,chunk){
|
||||
chunk=Recorder.DTMF_Decode(pcm,sampleRate,chunk);
|
||||
for(var i=0;i<chunk.keys.length;i++){
|
||||
Runtime.Log("发现按键["+chunk.keys[i].key+"],位于"+chunk.keys[i].time+"ms处");
|
||||
};
|
||||
return chunk;
|
||||
};
|
||||
|
||||
|
||||
|
||||
//*****DTMF按键编码,混合到实时语音流中*******
|
||||
var sendKeyClick=function(e){
|
||||
if(e.target.tagName=="TD"){
|
||||
sendKeys(e.target.innerHTML)
|
||||
};
|
||||
};
|
||||
var sendKeysClick=function(){
|
||||
sendKeys("*#1234567890#*");
|
||||
};
|
||||
var sendKeys=function(keys){
|
||||
if(!dtmfMix){
|
||||
dtmfMix=Recorder.DTMF_EncodeMix({
|
||||
duration:100 //按键信号持续时间 ms,最小值为30ms
|
||||
,mute:25 //按键音前后静音时长 ms,取值为0也是可以的
|
||||
,interval:200 //两次按键信号间隔时长 ms,间隔内包含了duration+mute*2,最小值为120ms
|
||||
});
|
||||
};
|
||||
if(!rec){
|
||||
Runtime.Log("没有开始录音,按键会存储到下次录音","#bbb");
|
||||
};
|
||||
dtmfMix.add(keys);
|
||||
//添加过去就不用管了,实时处理时会调用mix方法混入到pcm中。
|
||||
};
|
||||
var dtmfMix=null;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//******音频数据源,采集原始音频用的******
|
||||
//加载录音框架
|
||||
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+"/src/engine/mp3.js",check:function(){return !Recorder.prototype.mp3}}
|
||||
,{url:RootFolder+"/src/engine/mp3-engine.js",check:function(){return !Recorder.lamejs}}
|
||||
,{url:RootFolder+"/src/extensions/dtmf.encode.js",check:function(){return !Recorder.DTMF_Encode}}
|
||||
,{url:RootFolder+"/src/extensions/dtmf.decode.js",check:function(){return !Recorder.DTMF_Decode}}
|
||||
|
||||
,{url:RootFolder+"/assets/runtime-codes/fragment.playbuffer.js",check:function(){return !window.DemoFragment||!DemoFragment.PlayBuffer}}//引入DemoFragment.PlayBuffer
|
||||
]);
|
||||
|
||||
//显示控制按钮
|
||||
Runtime.Ctrls([
|
||||
{name:"开始wav录音",click:"recStartWav"}
|
||||
,{name:"开始mp3录音",click:"recStartMp3"}
|
||||
,{name:"结束录音",click:"recStop"}
|
||||
,{name:"识别按键信号",click:"decodeDTMF"}
|
||||
,{html:'<hr>\
|
||||
<div>混合按键信号到录音中(小提示:手机拨号设置了拨号按键音,可边录音边拿手机按拨号音,效果差不多)</div>\
|
||||
<div>\
|
||||
<style>\
|
||||
.dtmfTab td{padding: 15px 25px;border: 3px solid #ddd;cursor: pointer;user-select: none;}\
|
||||
.dtmfTab td:hover{background:#f60;opacity:.2;color:#fff}\
|
||||
.dtmfTab td:active{opacity:1}\
|
||||
</style>\
|
||||
<table onclick="sendKeyClick(event)" class="dtmfTab" style="border-collapse: collapse;text-align: center;border: 3px #ccc solid;">\
|
||||
<tr><td>1</td><td>2</td><td>3</td><td>A</td></tr>\
|
||||
<tr><td>4</td><td>5</td><td>6</td><td>B</td></tr>\
|
||||
<tr><td>7</td><td>8</td><td>9</td><td>C</td></tr>\
|
||||
<tr><td>*</td><td>0</td><td>#</td><td>D</td></tr>\
|
||||
</table>\
|
||||
</div>\
|
||||
'}
|
||||
,{name:"发送*#1234567890#*",click:"sendKeysClick"}
|
||||
|
||||
,{choiceFile:{
|
||||
multiple:false
|
||||
,name:"带按键信号音的音频"
|
||||
,mime:"audio/*"
|
||||
,process:function(fileName,arrayBuffer,filesCount,fileIdx,endCall){
|
||||
Runtime.DecodeAudio(fileName,arrayBuffer,function(data){
|
||||
Runtime.LogAudio(data.srcBlob,data.duration,{set:data},"已解码"+fileName);
|
||||
|
||||
setPCM(data.data,data.sampleRate);
|
||||
decodeDTMF();
|
||||
|
||||
endCall();
|
||||
},function(msg){
|
||||
Runtime.Log(msg,1);
|
||||
endCall();
|
||||
});
|
||||
}
|
||||
}}
|
||||
]);
|
||||
|
||||
|
||||
//调用录音
|
||||
var rec;
|
||||
function recStartMp3(){
|
||||
recStart("mp3");
|
||||
};
|
||||
function recStartWav(){
|
||||
recStart("wav");
|
||||
};
|
||||
function recStart(type){
|
||||
rec=Recorder({
|
||||
type:type
|
||||
,sampleRate:16000
|
||||
,bitRate:16
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate,newBufferIdx,asyncEnd){
|
||||
//实时混合按键信号
|
||||
if(dtmfMix){
|
||||
var val=dtmfMix.mix(buffers, bufferSampleRate, newBufferIdx);
|
||||
if(val.newEncodes.length>0){
|
||||
rec.PlayBufferDisable=true;
|
||||
DemoFragment.PlayBuffer(rec,val.newEncodes[0].data,bufferSampleRate);
|
||||
};
|
||||
};
|
||||
|
||||
Runtime.Process.apply(null,arguments);
|
||||
}
|
||||
});
|
||||
var t=setTimeout(function(){
|
||||
Runtime.Log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)",1);
|
||||
},8000);
|
||||
|
||||
rec.open(function(){//打开麦克风授权获得相关资源
|
||||
clearTimeout(t);
|
||||
rec.start();//开始录音
|
||||
},function(msg,isUserNotAllow){//用户拒绝未授权或不支持
|
||||
clearTimeout(t);
|
||||
Runtime.Log((isUserNotAllow?"UserNotAllow,":"")+"无法录音:"+msg, 1);
|
||||
});
|
||||
};
|
||||
function recStop(){
|
||||
rec.stop(function(blob,duration){
|
||||
rec.close();//释放录音资源
|
||||
|
||||
Runtime.LogAudio(blob,duration,rec);
|
||||
rec=null;
|
||||
|
||||
Runtime.ReadBlob(blob,function(arr){
|
||||
Runtime.DecodeAudio("rec",arr,function(data){
|
||||
setPCM(data.data,data.sampleRate);
|
||||
decodeDTMF();
|
||||
},function(msg){
|
||||
Runtime.Log(msg,1);
|
||||
});
|
||||
});
|
||||
},function(msg){
|
||||
Runtime.Log("录音失败:"+msg, 1);
|
||||
});
|
||||
};
|
||||
226
WebRoot/node_modules/Recorder-master/assets/runtime-codes/teach.realtime.encode_transfer.js
generated
vendored
Normal file
@ -0,0 +1,226 @@
|
||||
/******************
|
||||
《【教程】实时转码并上传-通用版》
|
||||
作者:高坚果
|
||||
时间:2019-10-22 23:04:49
|
||||
|
||||
通过onProcess回调可实现录音的实时处理;mp3和wav格式拥有极速转码特性,能做到边录音边转码;涉及Recorder两个核心方法:mock、SampleData。
|
||||
|
||||
如果不需要获得最终结果,可实时清理缓冲数据,避免占用过多内存,想录多久就录多久。
|
||||
|
||||
【拼接小技巧】测试结束后,可执行mp3、wav合并的demo代码,把所有片段拼接到一个文件
|
||||
|
||||
【mp3拼接】mp3格式因为lamejs采用的CBR编码,因此后端接收到了mp3片段后,通过简单的二进制拼接就能得到完整的长mp3;前端、后端实现拼接都可以参考mp3合并的demo代码。
|
||||
|
||||
【wav拼接】本库wav格式音频是用44字节wav头+PCM数据来构成的,因此只需要将所有片段去掉44字节后,通过简单的二进制拼接就能得到完整的长pcm数据,最后在加上44字节wav头就能得到完整的wav音频文件;前端、后端实现拼接都可以参考wav合并的demo代码。
|
||||
|
||||
【引入杂音、停顿问题】除wav外其他格式编码结果可能会比实际的PCM结果音频时长略长或略短,如果涉及到实时解码应留意此问题,长了的时候可截断首尾使解码后的PCM长度和录音的PCM长度一致(可能会增加噪音);
|
||||
wav格式最终拼接出来的音频音质比mp3的要好很多,因为wav拼接出来的PCM数据和录音得到的PCM数据是相同的;
|
||||
但mp3拼接出来的就不一样了,因为每次mp3编码时都会引入首尾的静默数据,使音频时长略微变长,这部分静默数据听起来就像有杂音和停顿一样,在实时转码间隔很短的情况下尤其明显(比如50ms),但只要转码间隔比较大时(比如500ms),mp3的这种停顿就会感知不到,音质几乎可以达到和wav一样。
|
||||
|
||||
仅使用mp3格式时,请参考更优良的《【教程】实时转码并上传-MP3专版》采用的takeoffEncodeChunk实现,不会有停顿导致的杂音。
|
||||
******************/
|
||||
var testOutputWavLog=false;//本测试如果是输出mp3,就顺带打一份wav的log,录音后执行mp3、wav合并的demo代码可对比音质
|
||||
var testSampleRate=16000;
|
||||
var testBitRate=16;
|
||||
|
||||
var SendInterval=300;/******
|
||||
转码发送间隔(实际间隔比这个变量值偏大点,取决于BufferSize)。这个值可以设置很大,但不能设置很低,毕竟转码和传输还是要花费一定时间的,设备性能低下可能还处理不过来。
|
||||
|
||||
mp3格式下一般大于500ms就能保证能够正常转码处理,wav大于100ms,剩下的问题就是传输速度了。由于转码操作都是串行的,录制过程中转码生成出来mp3顺序都是能够得到保证,但结束时最后几段数据可能产生顺序问题,需要留意。由于传输通道不一定稳定,后端接收到的顺序可能错乱,因此可以携带编号进行传输,完成后进行一次排序以纠正顺序错乱的问题。
|
||||
|
||||
mp3格式在间隔太低的情况下中间的停顿会非常明显,可适当调大间隔以规避此影响,因为mp3编码时首尾出现了填充的静默数据(mp3.js编码器内已尽力消除了这些静默,但还是会有些许的静默停顿);wav格式没有此问题,测试时可以打开 testOutputWavLog + mp3、wav合并demo 来对比音质。
|
||||
|
||||
当出现性能问题时,可能音频编码不过来,将采取丢弃部分帧的策略。
|
||||
******/
|
||||
|
||||
//重置环境
|
||||
var RealTimeSendTryReset=function(type){
|
||||
realTimeSendTryType=type;
|
||||
realTimeSendTryTime=0;
|
||||
};
|
||||
|
||||
var realTimeSendTryType;
|
||||
var realTimeSendTryEncBusy;
|
||||
var realTimeSendTryTime=0;
|
||||
var realTimeSendTryNumber;
|
||||
var transferUploadNumberMax;
|
||||
var realTimeSendTryChunk;
|
||||
|
||||
//=====实时处理核心函数==========
|
||||
var RealTimeSendTry=function(rec,isClose){
|
||||
var t1=Date.now(),endT=0,recImpl=Recorder.prototype;
|
||||
if(realTimeSendTryTime==0){
|
||||
realTimeSendTryTime=t1;
|
||||
realTimeSendTryEncBusy=0;
|
||||
realTimeSendTryNumber=0;
|
||||
transferUploadNumberMax=0;
|
||||
realTimeSendTryChunk=null;
|
||||
};
|
||||
if(!isClose && t1-realTimeSendTryTime<SendInterval){
|
||||
return;//控制缓冲达到指定间隔才进行传输
|
||||
};
|
||||
realTimeSendTryTime=t1;
|
||||
var number=++realTimeSendTryNumber;
|
||||
|
||||
//借用SampleData函数进行数据的连续处理,采样率转换是顺带的
|
||||
var chunk=Recorder.SampleData(rec.buffers,rec.srcSampleRate,testSampleRate,realTimeSendTryChunk,{frameType:isClose?"":realTimeSendTryType});
|
||||
|
||||
//清理已处理完的缓冲数据,释放内存以支持长时间录音,最后完成录音时不能调用stop,因为数据已经被清掉了
|
||||
for(var i=realTimeSendTryChunk?realTimeSendTryChunk.index:0;i<chunk.index;i++){
|
||||
rec.buffers[i]=null;
|
||||
};
|
||||
realTimeSendTryChunk=chunk;
|
||||
|
||||
//没有新数据,或结束时的数据量太小,不能进行mock转码
|
||||
if(chunk.data.length==0 || isClose&&chunk.data.length<2000){
|
||||
TransferUpload(number,null,0,null,isClose);
|
||||
return;
|
||||
};
|
||||
|
||||
//实时编码队列阻塞处理
|
||||
if(!isClose){
|
||||
if(realTimeSendTryEncBusy>=2){
|
||||
Runtime.Log("编码队列阻塞,已丢弃一帧",1);
|
||||
return;
|
||||
};
|
||||
};
|
||||
realTimeSendTryEncBusy++;
|
||||
|
||||
//通过mock方法实时转码成mp3、wav
|
||||
var encStartTime=Date.now();
|
||||
var recMock=Recorder({
|
||||
type:realTimeSendTryType
|
||||
,sampleRate:testSampleRate //采样率
|
||||
,bitRate:testBitRate //比特率
|
||||
});
|
||||
recMock.mock(chunk.data,chunk.sampleRate);
|
||||
recMock.stop(function(blob,duration){
|
||||
realTimeSendTryEncBusy&&(realTimeSendTryEncBusy--);
|
||||
blob.encTime=Date.now()-encStartTime;
|
||||
|
||||
//转码好就推入传输
|
||||
TransferUpload(number,blob,duration,recMock,isClose);
|
||||
},function(msg){
|
||||
realTimeSendTryEncBusy&&(realTimeSendTryEncBusy--);
|
||||
|
||||
//转码错误?没想到什么时候会产生错误!
|
||||
Runtime.Log("不应该出现的错误:"+msg,1);
|
||||
});
|
||||
|
||||
if(testOutputWavLog&&realTimeSendTryType=="mp3"){
|
||||
//测试输出一份wav,方便对比数据
|
||||
var recMock2=Recorder({
|
||||
type:"wav"
|
||||
,sampleRate:testSampleRate
|
||||
,bitRate:16
|
||||
});
|
||||
recMock2.mock(chunk.data,chunk.sampleRate);
|
||||
recMock2.stop(function(blob,duration){
|
||||
var logMsg="No."+(number<100?("000"+number).substr(-3):number);
|
||||
Runtime.LogAudio(blob,duration,recMock2,logMsg);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
//=====数据传输函数==========
|
||||
var TransferUpload=function(number,blobOrNull,duration,blobRec,isClose){
|
||||
transferUploadNumberMax=Math.max(transferUploadNumberMax,number);
|
||||
if(blobOrNull){
|
||||
var blob=blobOrNull;
|
||||
var encTime=blob.encTime;
|
||||
|
||||
//*********Read As Base64***************
|
||||
var reader=new FileReader();
|
||||
reader.onloadend=function(){
|
||||
var base64=(/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result)||[])[1];
|
||||
|
||||
//可以实现
|
||||
//WebSocket send(base64) ...
|
||||
//WebRTC send(base64) ...
|
||||
//XMLHttpRequest send(base64) ...
|
||||
|
||||
//这里啥也不干
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
|
||||
//*********Blob***************
|
||||
//可以实现
|
||||
//WebSocket send(blob) ...
|
||||
//WebRTC send(blob) ...
|
||||
//XMLHttpRequest send(blob) ...
|
||||
|
||||
//这里仅 console send 意思意思
|
||||
var numberFail=number<transferUploadNumberMax?'<span style="color:red">顺序错乱的数据,如果要求不高可以直接丢弃,或者调大SendInterval试试</span>':"";
|
||||
var logMsg="No."+(number<100?("000"+number).substr(-3):number)+numberFail;
|
||||
|
||||
Runtime.LogAudio(blob,duration,blobRec,logMsg+"花"+("___"+encTime).substr(-3)+"ms");
|
||||
|
||||
if(true && number%100==0){//emmm....
|
||||
Runtime.LogClear();
|
||||
};
|
||||
};
|
||||
|
||||
if(isClose){
|
||||
Runtime.Log("No."+(number<100?("000"+number).substr(-3):number)+":已停止传输");
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
//=====以下代码无关紧要,音频数据源,采集原始音频用的==================
|
||||
//加载框架
|
||||
Runtime.Import([
|
||||
{url:RootFolder+"/src/recorder-core.js",check:function(){return !window.Recorder}}
|
||||
,{url:RootFolder+"/src/engine/mp3.js",check:function(){return !Recorder.prototype.mp3}}
|
||||
,{url:RootFolder+"/src/engine/mp3-engine.js",check:function(){return !Recorder.lamejs}}
|
||||
,{url:RootFolder+"/src/engine/wav.js",check:function(){return !Recorder.prototype.wav}}
|
||||
]);
|
||||
|
||||
//显示控制按钮
|
||||
Runtime.Ctrls([
|
||||
{name:"开始录音和传输mp3",click:"recStartMp3"}
|
||||
,{name:"开始录音和传输wav",click:"recStartWav"}
|
||||
,{name:"停止录音",click:"recStop"}
|
||||
]);
|
||||
|
||||
|
||||
//调用录音
|
||||
var rec;
|
||||
function recStartMp3(){
|
||||
recStart("mp3");
|
||||
};
|
||||
function recStartWav(){
|
||||
recStart("wav");
|
||||
};
|
||||
function recStart(type){
|
||||
if(rec){
|
||||
rec.close();
|
||||
};
|
||||
rec=Recorder({
|
||||
type:"unknown"
|
||||
,onProcess:function(buffers,powerLevel,bufferDuration,bufferSampleRate){
|
||||
Runtime.Process.apply(null,arguments);
|
||||
|
||||
RealTimeSendTry(rec,false);//推入实时处理,因为是unknown格式,这里简化函数调用,没有用到buffers和bufferSampleRate,因为这些数据和rec.buffers是完全相同的。
|
||||
}
|
||||
});
|
||||
|
||||
var t=setTimeout(function(){
|
||||
Runtime.Log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)",1);
|
||||
},8000);
|
||||
|
||||
rec.open(function(){//打开麦克风授权获得相关资源
|
||||
clearTimeout(t);
|
||||
rec.start();//开始录音
|
||||
|
||||
RealTimeSendTryReset(type);//重置
|
||||
},function(msg,isUserNotAllow){
|
||||
clearTimeout(t);
|
||||
Runtime.Log((isUserNotAllow?"UserNotAllow,":"")+"无法录音:"+msg, 1);
|
||||
});
|
||||
};
|
||||
function recStop(){
|
||||
rec.close();//直接close掉即可,这个例子不需要获得最终的音频文件
|
||||
|
||||
RealTimeSendTry(rec,true);//最后一次发送
|
||||
};
|
||||