first commit

This commit is contained in:
2026-01-16 14:13:44 +08:00
commit 903ff8d495
34603 changed files with 8585054 additions and 0 deletions

21
WebRoot/node_modules/Recorder-master/LICENSE generated vendored Normal file
View 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
View 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 AppIOS上支持微信网页和小程序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
View 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 headermultipart/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异步加载jsiframe里面使用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.X13.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:方法文档
![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/use_caller.png)
### 【构造】rec=Recorder(set)
构造函数,拿到`Recorder`的实例,然后可以进行请求获取麦克风权限和录音。
`set`参数为配置对象,默认配置值如下:
``` javascript
set={
type:"mp3" //输出类型mp3,wav等使用一个类型前需要先引入对应的编码引擎
,bitRate:16 //比特率,必须是数字 wav(位):16、8MP3(单位kbps)8kbps时文件大小1k/s16kbps 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起始索引。
//asyncEndfn() 如果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操作可能需要花费比较长时间如果中途调用了stopopen完成时同步的任何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.28000hz)音质和同比配置的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)
![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/use_wave.gif)
## `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,...],...]二维数组sampleRatepcm的采样率indexpcms第一维开始索引将从这个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,16khzMP3开语音15分钟大概3M的流量wav 15分钟要37M多流量。
另外除wav外MP3等格式编码出来的音频的播放时间比PCM原始数据要长一些或短一些如果涉及到解码或拼接时这个地方需要注意如果类型支持实时处理时使用`takeoffEncodeChunk`选项可完全避免此问题)。
![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/use_webrtc.png)
# :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中因此简单可靠额外提供了一套源码作者的身份认证机制。
我们不传输、不存储数据,我们只是代码的可靠搬运工。看图:
![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/use_runtime.gif)
# :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`;
![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/use_pcm_tool.png)
# :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 一下。
您也可以使用支付宝或微信打赏作者:
![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/donate-alipay.png) ![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/donate-weixin.png)

View 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 //code0正常其他错误
,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 AppIOS上支持微信网页和小程序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>

View 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) 详细的原生实现、权限配置等请阅读这个目录内的READMEclone后用`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:方法文档
![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/use_caller.png)
## 【静态方法】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 一下。
您也可以使用支付宝或微信打赏作者:
![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/donate-alipay.png) ![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/donate-weixin.png)

View 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。
## 【截图】
![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/use_native_android.gif)
## 【限制】
- 虽然兼容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基本上别人的服务器几乎不太可能识别出我们的AppAndroid神一样的把包名添加到了X-Requested-With请求头中还能不能讲理了。

View 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'])
}

View 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>

View 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();
}
}
}

View 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基本上别人的服务器几乎不太可能识别出我们的AppAndroid神一样的把包名添加到了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();
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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>

View 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>

View 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>

View 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
}

View 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

View 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

View 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" "$@"

View 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

View File

@ -0,0 +1 @@
include ':app'

View 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天才能被使用嫌弃苹果公司工程师水准**
## 【截图】
![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/use_native_ios.gif)
## 【限制】
- 未做古董版本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基本上别人的服务器几乎不太可能识别出我们的AppAndroid神一样的把包名添加到了X-Requested-With请求头中还能不能讲理了。

View 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 */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:recorder.xcodeproj">
</FileRef>
</Workspace>

View File

@ -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>

View 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);
}
//alertconfirm
}
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 ?? ""));
}
}
}

View File

@ -0,0 +1,678 @@
import WebKit
import AVFoundation
/* Hybrid App https://github.com/xiangyuecn/Recorder
使
1. copy使
2. plistNSMicrophoneUsageDescription
3. WKWebViewconfigurationLogprint
4. WKWebViewpromptacceptPrompt
UserAgentApp
WebViewUAH5AppUA便H5AppWebViewIOSUAAppAndroidX-Requested-With
*/
public class RecordAppJsBridge {
//js
static private let JsBridgeName="RecordAppJsBridge";
static private let JsRequestName="AppJsBridgeRequest";
//appcache
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;
//apppromptpromptacceptPrompt
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;
}
/*promptjson*/
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!;
/**
* valuestatussuccess
*/
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();//Destroylog
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;//12Int162
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;
}
//AVAudioRecorderAudioQueueAudioUnitOC 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);
}

View 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:.
}
}

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View 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>

View 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>

View 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>

View 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 AppIOS上支持微信网页和小程序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>

View 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 //code0正常其他错误
,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需要自己重写或修改后才能使用");
};

View 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){
//异步接口录音权限检测返回值int1支持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需要自己重写或修改后才能使用");
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View 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查看效果了

View 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>

File diff suppressed because one or more lines are too long

View 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>

View File

@ -0,0 +1,66 @@
//加载必须要的coredemo简化起见采用的直接加载类库实际使用时应当采用异步按需加载
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);

View 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"
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
WebRoot/node_modules/Recorder-master/assets/icon.png generated vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1,136 @@
# Recorderrecorder-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)
![](https://gitee.com/xiangyuecn/Recorder/raw/master/assets/use_wave.gif)
## 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. 运行根目录/srcnpm 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@@

View 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"
}
]

View 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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,53 @@
/******************
《【Demo库】PCM Buffer播放》
作者:高坚果
时间2020-1-10 19:51:18
文档:
DemoFragment.PlayBuffer(store,buffer,sampleRate)
store: {} 随便定义一个空对象即可,每次调用的时候传入,用于存储上下文数据
buffer[Int16,...] pcm数据一维数组
sampleRatebuffer的采样率
调用本方法前应当确保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;
};

View 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) 合并成功回调
fileBytesUint8Array 为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);
});
};

View 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) 合并成功回调
fileBytesUint8Array 为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);
});
};

View File

@ -0,0 +1,184 @@
/******************
《【Demo库】PCM采样率提升》
作者:高坚果
时间2020-1-9 20:48:39
文档:
Recorder.SampleRaise(pcmDatas,pcmSampleRate,newSampleRate)
pcmDatas: [[Int16,...]] pcm片段列表二维数组
pcmSampleRatepcm的采样率
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("结束录音转换格式以最后点击的哪个为准");

View File

@ -0,0 +1,135 @@
/******************
《【Demo库】【格式转换】-amr格式转成其他格式》
作者:高坚果
时间2020-6-24 22:54:41
文档:
Recorder.AMR2Other(newSet,amrBlob,True,False)
newSetRecorder的set参数用来生成新格式注意要先加载好新格式的编码引擎
amrBlobamr二进制数据注意只支持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);
});
};

View File

@ -0,0 +1,155 @@
/******************
《【Demo库】【格式转换】-mp3格式转成其他格式》
作者:高坚果
时间2019-10-22 15:20:57
文档:
Recorder.Mp32Other(newSet,mp3Blob,True,False)
newSetRecorder的set参数用来生成新格式注意要先加载好新格式的编码引擎
mp3Blobmp3二进制数据
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);
});
};

View File

@ -0,0 +1,174 @@
/******************
《【Demo库】【格式转换】-wav格式转成其他格式》
作者:高坚果
时间2019-10-22 13:48:35
文档:
Recorder.Wav2Other(newSet,wavBlob,True,False)
newSetRecorder的set参数用来生成新格式注意要先加载好新格式的编码引擎
wavBlobwav二进制数据
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);
});
};

View 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);
});
};

View 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但只要转码间隔比较大时比如500msmp3的这种停顿就会感知不到音质几乎可以达到和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);//最后一次发送
};

Some files were not shown because too many files have changed in this diff Show More