mirror of
https://github.com/babysor/MockingBird.git
synced 2024-03-22 13:11:31 +08:00
Web server (#94)
* Init App * init server.py (#93) * init server.py * Update requirements.txt Add requirement Co-authored-by: auau <auau@test.com> Co-authored-by: babysor00 <babysor00@gmail.com> * Run web.py! Run web.py! Co-authored-by: balala <Ozgay@users.noreply.github.com> Co-authored-by: auau <auau@test.com>
This commit is contained in:
parent
4178416385
commit
ddd478c0ad
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
@ -8,7 +8,7 @@
|
|||
"name": "Python: Web",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/web/app.py",
|
||||
"program": "web.py",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
|
|
|
@ -14,4 +14,8 @@ PyQt5
|
|||
multiprocess
|
||||
numba
|
||||
webrtcvad; platform_system != "Windows"
|
||||
pypinyin
|
||||
pypinyin
|
||||
flask
|
||||
flask_wtf
|
||||
flask_cors
|
||||
gevent==21.8.0
|
||||
|
|
|
@ -49,12 +49,12 @@ hparams = HParams(
|
|||
# frame that has all values < -3.4
|
||||
|
||||
### Tacotron Training
|
||||
tts_schedule = [(2, 1e-3, 20_000, 12), # Progressive training schedule
|
||||
(2, 5e-4, 40_000, 12), # (r, lr, step, batch_size)
|
||||
(2, 2e-4, 80_000, 12), #
|
||||
(2, 1e-4, 160_000, 12), # r = reduction factor (# of mel frames
|
||||
(2, 3e-5, 320_000, 12), # synthesized for each decoder iteration)
|
||||
(2, 1e-5, 640_000, 12)], # lr = learning rate
|
||||
tts_schedule = [(2, 1e-3, 20_000, 24), # Progressive training schedule
|
||||
(2, 5e-4, 40_000, 24), # (r, lr, step, batch_size)
|
||||
(2, 2e-4, 80_000, 24), #
|
||||
(2, 1e-4, 160_000, 24), # r = reduction factor (# of mel frames
|
||||
(2, 3e-5, 320_000, 24), # synthesized for each decoder iteration)
|
||||
(2, 1e-5, 640_000, 24)], # lr = learning rate
|
||||
|
||||
tts_clip_grad_norm = 1.0, # clips the gradient norm to prevent explosion - set to None if not needed
|
||||
tts_eval_interval = 500, # Number of steps between model evaluation (sample generation)
|
||||
|
|
11
web.py
Normal file
11
web.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from web import webApp
|
||||
from gevent import pywsgi as wsgi
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = webApp()
|
||||
host = app.config.get("HOST")
|
||||
port = app.config.get("PORT")
|
||||
print(f"Web server: http://{host}:{port}")
|
||||
server = wsgi.WSGIServer((host, port), app)
|
||||
server.serve_forever()
|
10
web/DOCKERFILE
Normal file
10
web/DOCKERFILE
Normal file
|
@ -0,0 +1,10 @@
|
|||
|
||||
FROM python:3.7
|
||||
|
||||
RUN pip install gevent uwsgi flask
|
||||
|
||||
COPY app.py /app.py
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["uwsgi", "--http", ":3000", "--master", "--module", "app:app"]
|
167
web/__init__.py
Normal file
167
web/__init__.py
Normal file
|
@ -0,0 +1,167 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
from gevent import pywsgi as wsgi
|
||||
from flask import Flask, jsonify, Response, request, render_template
|
||||
from synthesizer.inference import Synthesizer
|
||||
from encoder import inference as encoder
|
||||
from vocoder.hifigan import inference as gan_vocoder
|
||||
from vocoder.wavernn import inference as rnn_vocoder
|
||||
import numpy as np
|
||||
import re
|
||||
from scipy.io.wavfile import write, read
|
||||
import io
|
||||
import base64
|
||||
from flask_cors import CORS
|
||||
from flask_wtf import CSRFProtect
|
||||
|
||||
def webApp():
|
||||
# Init and load config
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
||||
app.config.from_object("web.config.default")
|
||||
|
||||
CORS(app) #允许跨域,注释掉此行则禁止跨域请求
|
||||
csrf = CSRFProtect(app)
|
||||
csrf.init_app(app)
|
||||
# API For Non-Trainer
|
||||
# 1. list sample audio files
|
||||
# 2. record / upload / select audio files
|
||||
# 3. load melspetron of audio
|
||||
# 4. inference by audio + text + models(encoder, vocoder, synthesizer)
|
||||
# 5. export result
|
||||
audio_samples = []
|
||||
AUDIO_SAMPLES_DIR = app.config.get("AUDIO_SAMPLES_DIR")
|
||||
if os.path.isdir(AUDIO_SAMPLES_DIR):
|
||||
audio_samples = list(Path(AUDIO_SAMPLES_DIR).glob("*.wav"))
|
||||
print("Loaded samples: " + str(len(audio_samples)))
|
||||
# enc_models_dir = "encoder/saved_models"
|
||||
# voc_models_di = "vocoder/saved_models"
|
||||
# encoders = list(Path(enc_models_dir).glob("*.pt"))
|
||||
# vocoders = list(Path(voc_models_di).glob("**/*.pt"))
|
||||
syn_models_dirt = "synthesizer/saved_models"
|
||||
synthesizers = list(Path(syn_models_dirt).glob("**/*.pt"))
|
||||
# print("Loaded encoder models: " + str(len(encoders)))
|
||||
# print("Loaded vocoder models: " + str(len(vocoders)))
|
||||
print("Loaded synthesizer models: " + str(len(synthesizers)))
|
||||
synthesizers_cache = {}
|
||||
encoder.load_model(Path("encoder/saved_models/pretrained.pt"))
|
||||
gan_vocoder.load_model(Path("vocoder/saved_models/pretrained/g_hifigan.pt"))
|
||||
|
||||
# TODO: move to utils
|
||||
def generate(wav_path):
|
||||
with open(wav_path, "rb") as fwav:
|
||||
data = fwav.read(1024)
|
||||
while data:
|
||||
yield data
|
||||
data = fwav.read(1024)
|
||||
|
||||
@app.route("/api/audios", methods=["GET"])
|
||||
def audios():
|
||||
return jsonify(
|
||||
{"data": list(a.name for a in audio_samples), "total": len(audio_samples)}
|
||||
)
|
||||
|
||||
@app.route("/api/audios/<name>", methods=["GET"])
|
||||
def audio_play(name):
|
||||
return Response(generate(AUDIO_SAMPLES_DIR + name), mimetype="audio/x-wav")
|
||||
|
||||
@app.route("/api/models", methods=["GET"])
|
||||
def models():
|
||||
return jsonify(
|
||||
{
|
||||
# "encoder": list(e.name for e in encoders),
|
||||
# "vocoder": list(e.name for e in vocoders),
|
||||
"synthesizers":
|
||||
list({"name": e.name, "path": str(e)} for e in synthesizers),
|
||||
}
|
||||
)
|
||||
|
||||
def pcm2float(sig, dtype='float32'):
|
||||
"""Convert PCM signal to floating point with a range from -1 to 1.
|
||||
Use dtype='float32' for single precision.
|
||||
Parameters
|
||||
----------
|
||||
sig : array_like
|
||||
Input array, must have integral type.
|
||||
dtype : data type, optional
|
||||
Desired (floating point) data type.
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Normalized floating point data.
|
||||
See Also
|
||||
--------
|
||||
float2pcm, dtype
|
||||
"""
|
||||
sig = np.asarray(sig)
|
||||
if sig.dtype.kind not in 'iu':
|
||||
raise TypeError("'sig' must be an array of integers")
|
||||
dtype = np.dtype(dtype)
|
||||
if dtype.kind != 'f':
|
||||
raise TypeError("'dtype' must be a floating point type")
|
||||
|
||||
i = np.iinfo(sig.dtype)
|
||||
abs_max = 2 ** (i.bits - 1)
|
||||
offset = i.min + abs_max
|
||||
return (sig.astype(dtype) - offset) / abs_max
|
||||
|
||||
# Cache for synthesizer
|
||||
@csrf.exempt
|
||||
@app.route("/api/synthesize", methods=["POST"])
|
||||
def synthesize():
|
||||
# TODO Implementation with json to support more platform
|
||||
|
||||
# Load synthesizer
|
||||
if "synt_path" in request.form:
|
||||
synt_path = request.form["synt_path"]
|
||||
else:
|
||||
synt_path = synthesizers[0]
|
||||
print("NO synthsizer is specified, try default first one.")
|
||||
if synthesizers_cache.get(synt_path) is None:
|
||||
current_synt = Synthesizer(Path(synt_path))
|
||||
synthesizers_cache[synt_path] = current_synt
|
||||
else:
|
||||
current_synt = synthesizers_cache[synt_path]
|
||||
print("using synthesizer model: " + str(synt_path))
|
||||
# Load input wav
|
||||
wav_base64 = request.form["upfile_b64"]
|
||||
wav = base64.b64decode(bytes(wav_base64, 'utf-8'))
|
||||
wav = pcm2float(np.frombuffer(wav, dtype=np.int16), dtype=np.float32)
|
||||
encoder_wav = encoder.preprocess_wav(wav, 16000)
|
||||
embed, _, _ = encoder.embed_utterance(encoder_wav, return_partials=True)
|
||||
|
||||
# Load input text
|
||||
texts = request.form["text"].split("\n")
|
||||
punctuation = '!,。、,' # punctuate and split/clean text
|
||||
processed_texts = []
|
||||
for text in texts:
|
||||
for processed_text in re.sub(r'[{}]+'.format(punctuation), '\n', text).split('\n'):
|
||||
if processed_text:
|
||||
processed_texts.append(processed_text.strip())
|
||||
texts = processed_texts
|
||||
|
||||
# synthesize and vocode
|
||||
embeds = [embed] * len(texts)
|
||||
specs = current_synt.synthesize_spectrograms(texts, embeds)
|
||||
spec = np.concatenate(specs, axis=1)
|
||||
wav = gan_vocoder.infer_waveform(spec)
|
||||
|
||||
# Return cooked wav
|
||||
out = io.BytesIO()
|
||||
write(out, Synthesizer.sample_rate, wav)
|
||||
return Response(out, mimetype="audio/wav")
|
||||
|
||||
@app.route('/', methods=['GET'])
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
host = app.config.get("HOST")
|
||||
port = app.config.get("PORT")
|
||||
print(f"Web server: http://{host}:{port}")
|
||||
server = wsgi.WSGIServer((host, port), app)
|
||||
server.serve_forever()
|
||||
|
||||
return app
|
||||
|
||||
if __name__ == "__main__":
|
||||
webApp()
|
0
web/config/__init__.py
Normal file
0
web/config/__init__.py
Normal file
7
web/config/default.py
Normal file
7
web/config/default.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
AUDIO_SAMPLES_DIR = 'samples\\'
|
||||
DEVICE = '0'
|
||||
HOST = 'localhost'
|
||||
PORT = 8080
|
||||
MAX_CONTENT_PATH =1024 * 1024 * 4 # mp3文件大小限定不能超过4M
|
||||
SECRET_KEY = "mockingbird_key"
|
||||
WTF_CSRF_SECRET_KEY = "mockingbird_key"
|
2
web/static/js/eruda.min.js
vendored
Normal file
2
web/static/js/eruda.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
338
web/static/js/frequency.histogram.view.js
Normal file
338
web/static/js/frequency.histogram.view.js
Normal file
|
@ -0,0 +1,338 @@
|
|||
/*
|
||||
录音 Recorder扩展,频率直方图显示
|
||||
使用本扩展需要引入lib.fft.js支持,直方图特意优化主要显示0-5khz语音部分,其他高频显示区域较小,不适合用来展示音乐频谱
|
||||
|
||||
https://github.com/xiangyuecn/Recorder
|
||||
|
||||
本扩展核心算法主要参考了Java开源库jmp123 版本0.3 的代码:
|
||||
https://www.iteye.com/topic/851459
|
||||
https://sourceforge.net/projects/jmp123/files/
|
||||
*/
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
var FrequencyHistogramView=function(set){
|
||||
return new fn(set);
|
||||
};
|
||||
var fn=function(set){
|
||||
var This=this;
|
||||
var o={
|
||||
/*
|
||||
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){}
|
||||
};
|
||||
for(var k in set){
|
||||
o[k]=set[k];
|
||||
};
|
||||
This.set=set=o;
|
||||
|
||||
var elem=set.elem;
|
||||
if(elem){
|
||||
if(typeof(elem)=="string"){
|
||||
elem=document.querySelector(elem);
|
||||
}else if(elem.length){
|
||||
elem=elem[0];
|
||||
};
|
||||
};
|
||||
if(elem){
|
||||
set.width=elem.offsetWidth;
|
||||
set.height=elem.offsetHeight;
|
||||
};
|
||||
|
||||
var scale=set.scale;
|
||||
var width=set.width*scale;
|
||||
var height=set.height*scale;
|
||||
|
||||
var thisElem=This.elem=document.createElement("div");
|
||||
var lowerCss=["","transform-origin:0 0;","transform:scale("+(1/scale)+");"];
|
||||
thisElem.innerHTML='<div style="width:'+set.width+'px;height:'+set.height+'px;overflow:hidden"><div style="width:'+width+'px;height:'+height+'px;'+lowerCss.join("-webkit-")+lowerCss.join("-ms-")+lowerCss.join("-moz-")+lowerCss.join("")+'"><canvas/></div></div>';
|
||||
|
||||
var canvas=This.canvas=thisElem.querySelector("canvas");
|
||||
var ctx=This.ctx=canvas.getContext("2d");
|
||||
canvas.width=width;
|
||||
canvas.height=height;
|
||||
|
||||
if(elem){
|
||||
elem.innerHTML="";
|
||||
elem.appendChild(thisElem);
|
||||
};
|
||||
|
||||
if(!Recorder.LibFFT){
|
||||
throw new Error("需要lib.fft.js支持");
|
||||
};
|
||||
This.fft=Recorder.LibFFT(1024);
|
||||
|
||||
//柱子所在高度
|
||||
This.lastH=[];
|
||||
//峰值小横条所在高度
|
||||
This.stripesH=[];
|
||||
};
|
||||
fn.prototype=FrequencyHistogramView.prototype={
|
||||
genLinear:function(ctx,colors,from,to){
|
||||
var rtv=ctx.createLinearGradient(0,from,0,to);
|
||||
for(var i=0;i<colors.length;){
|
||||
rtv.addColorStop(colors[i++],colors[i++]);
|
||||
};
|
||||
return rtv;
|
||||
}
|
||||
,input:function(pcmData,powerLevel,sampleRate){
|
||||
var This=this;
|
||||
This.sampleRate=sampleRate;
|
||||
This.pcmData=pcmData;
|
||||
This.pcmPos=0;
|
||||
|
||||
This.inputTime=Date.now();
|
||||
This.schedule();
|
||||
}
|
||||
,schedule:function(){
|
||||
var This=this,set=This.set;
|
||||
var interval=Math.floor(1000/set.fps);
|
||||
if(!This.timer){
|
||||
This.timer=setInterval(function(){
|
||||
This.schedule();
|
||||
},interval);
|
||||
};
|
||||
|
||||
var now=Date.now();
|
||||
var drawTime=This.drawTime||0;
|
||||
if(now-This.inputTime>set.stripeFallDuration*1.3){
|
||||
//超时没有输入,顶部横条已全部落下,干掉定时器
|
||||
clearInterval(This.timer);
|
||||
This.timer=0;
|
||||
return;
|
||||
};
|
||||
if(now-drawTime<interval){
|
||||
//没到间隔时间,不绘制
|
||||
return;
|
||||
};
|
||||
This.drawTime=now;
|
||||
|
||||
//调用FFT计算频率数据
|
||||
var bufferSize=This.fft.bufferSize;
|
||||
var pcm=This.pcmData;
|
||||
var pos=This.pcmPos;
|
||||
var arr=new Int16Array(bufferSize);
|
||||
for(var i=0;i<bufferSize&&pos<pcm.length;i++,pos++){
|
||||
arr[i]=pcm[pos];
|
||||
};
|
||||
This.pcmPos=pos;
|
||||
|
||||
var frequencyData=This.fft.transform(arr);
|
||||
|
||||
//推入绘制
|
||||
This.draw(frequencyData,This.sampleRate);
|
||||
}
|
||||
,draw:function(frequencyData,sampleRate){
|
||||
var This=this,set=This.set;
|
||||
var ctx=This.ctx;
|
||||
var scale=set.scale;
|
||||
var width=set.width*scale;
|
||||
var height=set.height*scale;
|
||||
var lineCount=set.lineCount;
|
||||
var bufferSize=This.fft.bufferSize;
|
||||
|
||||
|
||||
//计算高度位置
|
||||
var position=set.position;
|
||||
var posAbs=Math.abs(set.position);
|
||||
var originY=position==1?0:height;//y轴原点
|
||||
var heightY=height;//最高的一边高度
|
||||
if(posAbs<1){
|
||||
heightY=heightY/2;
|
||||
originY=heightY;
|
||||
heightY=Math.floor(heightY*(1+posAbs));
|
||||
originY=Math.floor(position>0?originY*(1-posAbs):originY*(1+posAbs));
|
||||
};
|
||||
|
||||
var lastH=This.lastH;
|
||||
var stripesH=This.stripesH;
|
||||
var speed=Math.ceil(heightY/(set.fallDuration/(1000/set.fps)));
|
||||
var stripeSpeed=Math.ceil(heightY/(set.stripeFallDuration/(1000/set.fps)));
|
||||
var stripeMargin=set.stripeMargin*scale;
|
||||
|
||||
var Y0=1 << (Math.round(Math.log(bufferSize)/Math.log(2) + 3) << 1);
|
||||
var logY0 = Math.log(Y0)/Math.log(10);
|
||||
var dBmax=20*Math.log(0x7fff)/Math.log(10);
|
||||
|
||||
var fftSize=bufferSize/2;
|
||||
var fftSize5k=Math.min(fftSize,Math.floor(fftSize*5000/(sampleRate/2)));//5khz所在位置,8000采样率及以下最高只有4khz
|
||||
var fftSize5kIsAll=fftSize5k==fftSize;
|
||||
var line80=fftSize5kIsAll?lineCount:Math.round(lineCount*0.8);//80%的柱子位置
|
||||
var fftSizeStep1=fftSize5k/line80;
|
||||
var fftSizeStep2=fftSize5kIsAll?0:(fftSize-fftSize5k)/(lineCount-line80);
|
||||
var fftIdx=0;
|
||||
for(var i=0;i<lineCount;i++){
|
||||
//不采用jmp123的非线性划分频段,录音语音并不适用于音乐的频率,应当弱化高频部分
|
||||
//80%关注0-5khz主要人声部分 20%关注剩下的高频,这样不管什么采样率都能做到大部分频率显示一致。
|
||||
var start=Math.ceil(fftIdx);
|
||||
if(i<line80){
|
||||
//5khz以下
|
||||
fftIdx+=fftSizeStep1;
|
||||
}else{
|
||||
//5khz以上
|
||||
fftIdx+=fftSizeStep2;
|
||||
};
|
||||
var end=Math.min(Math.ceil(fftIdx),fftSize);
|
||||
|
||||
|
||||
//参考AudioGUI.java .drawHistogram方法
|
||||
|
||||
//查找当前频段的最大"幅值"
|
||||
var maxAmp=0;
|
||||
for (var j=start; j<end; j++) {
|
||||
maxAmp=Math.max(maxAmp,Math.abs(frequencyData[j]));
|
||||
};
|
||||
|
||||
//计算音量
|
||||
var dB= (maxAmp > Y0) ? Math.floor((Math.log(maxAmp)/Math.log(10) - logY0) * 17) : 0;
|
||||
var h=heightY*Math.min(dB/dBmax,1);
|
||||
|
||||
//使柱子匀速下降
|
||||
lastH[i]=(lastH[i]||0)-speed;
|
||||
if(h<lastH[i]){h=lastH[i];};
|
||||
if(h<0){h=0;};
|
||||
lastH[i]=h;
|
||||
|
||||
var shi=stripesH[i]||0;
|
||||
if(h&&h+stripeMargin>shi) {
|
||||
stripesH[i]=h+stripeMargin;
|
||||
}else{
|
||||
//使峰值小横条匀速度下落
|
||||
var sh =shi-stripeSpeed;
|
||||
if(sh < 0){sh = 0;};
|
||||
stripesH[i] = sh;
|
||||
};
|
||||
};
|
||||
|
||||
//开始绘制图形
|
||||
ctx.clearRect(0,0,width,height);
|
||||
|
||||
var linear1=This.genLinear(ctx,set.linear,originY,originY-heightY);//上半部分的填充
|
||||
var stripeLinear1=set.stripeLinear&&This.genLinear(ctx,set.stripeLinear,originY,originY-heightY)||linear1;//上半部分的峰值小横条填充
|
||||
|
||||
var linear2=This.genLinear(ctx,set.linear,originY,originY+heightY);//下半部分的填充
|
||||
var stripeLinear2=set.stripeLinear&&This.genLinear(ctx,set.stripeLinear,originY,originY+heightY)||linear2;//上半部分的峰值小横条填充
|
||||
|
||||
//计算柱子间距
|
||||
ctx.shadowBlur=set.shadowBlur*scale;
|
||||
ctx.shadowColor=set.shadowColor;
|
||||
var mirrorEnable=set.mirrorEnable;
|
||||
var mirrorCount=mirrorEnable?lineCount*2-1:lineCount;//镜像柱子数量翻一倍-1根
|
||||
|
||||
var widthRatio=set.widthRatio;
|
||||
var spaceWidth=set.spaceWidth*scale;
|
||||
if(spaceWidth!=0){
|
||||
widthRatio=(width-spaceWidth*(mirrorCount+1))/width;
|
||||
};
|
||||
|
||||
var lineWidth=Math.max(1*scale,Math.floor((width*widthRatio)/mirrorCount));//柱子宽度至少1个单位
|
||||
var spaceFloat=(width-mirrorCount*lineWidth)/(mirrorCount+1);//均匀间隔,首尾都留空,可能为负数,柱子将发生重叠
|
||||
|
||||
//绘制柱子
|
||||
var minHeight=set.minHeight*scale;
|
||||
var mirrorSubX=spaceFloat+lineWidth/2;
|
||||
var XFloat=mirrorEnable?width/2-mirrorSubX:0;//镜像时,中间柱子位于正中心
|
||||
for(var i=0,xFloat=XFloat,x,y,h;i<lineCount;i++){
|
||||
xFloat+=spaceFloat;
|
||||
x=Math.floor(xFloat);
|
||||
h=Math.max(lastH[i],minHeight);
|
||||
|
||||
//绘制上半部分
|
||||
if(originY!=0){
|
||||
y=originY-h;
|
||||
ctx.fillStyle=linear1;
|
||||
ctx.fillRect(x, y, lineWidth, h);
|
||||
};
|
||||
//绘制下半部分
|
||||
if(originY!=height){
|
||||
ctx.fillStyle=linear2;
|
||||
ctx.fillRect(x, originY, lineWidth, h);
|
||||
};
|
||||
|
||||
xFloat+=lineWidth;
|
||||
};
|
||||
|
||||
//绘制柱子顶上峰值小横条
|
||||
if(set.stripeEnable){
|
||||
var stripeShadowBlur=set.stripeShadowBlur;
|
||||
ctx.shadowBlur=(stripeShadowBlur==-1?set.shadowBlur:stripeShadowBlur)*scale;
|
||||
ctx.shadowColor=set.stripeShadowColor||set.shadowColor;
|
||||
var stripeHeight=set.stripeHeight*scale;
|
||||
for(var i=0,xFloat=XFloat,x,y,h;i<lineCount;i++){
|
||||
xFloat+=spaceFloat;
|
||||
x=Math.floor(xFloat);
|
||||
h=stripesH[i];
|
||||
|
||||
//绘制上半部分
|
||||
if(originY!=0){
|
||||
y=originY-h-stripeHeight;
|
||||
if(y<0){y=0;};
|
||||
ctx.fillStyle=stripeLinear1;
|
||||
ctx.fillRect(x, y, lineWidth, stripeHeight);
|
||||
};
|
||||
//绘制下半部分
|
||||
if(originY!=height){
|
||||
y=originY+h;
|
||||
if(y+stripeHeight>height){
|
||||
y=height-stripeHeight;
|
||||
};
|
||||
ctx.fillStyle=stripeLinear2;
|
||||
ctx.fillRect(x, y, lineWidth, stripeHeight);
|
||||
};
|
||||
|
||||
xFloat+=lineWidth;
|
||||
};
|
||||
};
|
||||
|
||||
//镜像,从中间直接镜像即可
|
||||
if(mirrorEnable){
|
||||
var srcW=Math.floor(width/2);
|
||||
ctx.save();
|
||||
ctx.scale(-1,1);
|
||||
ctx.drawImage(This.canvas,Math.ceil(width/2),0,srcW,height,-srcW,0,srcW,height);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
set.onDraw(frequencyData,sampleRate);
|
||||
}
|
||||
};
|
||||
Recorder.FrequencyHistogramView=FrequencyHistogramView;
|
||||
|
||||
|
||||
})();
|
10881
web/static/js/jquery.js
vendored
Normal file
10881
web/static/js/jquery.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
111
web/static/js/lib.fft.js
Normal file
111
web/static/js/lib.fft.js
Normal file
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
时域转频域,快速傅里叶变换(FFT)
|
||||
https://github.com/xiangyuecn/Recorder
|
||||
|
||||
var fft=Recorder.LibFFT(bufferSize)
|
||||
bufferSize取值2的n次方
|
||||
|
||||
fft.bufferSize 实际采用的bufferSize
|
||||
fft.transform(inBuffer)
|
||||
inBuffer:[Int16,...] 数组长度必须是bufferSize
|
||||
返回[Float64(Long),...],长度为bufferSize/2
|
||||
*/
|
||||
|
||||
/*
|
||||
从FFT.java 移植,Java开源库:jmp123 版本0.3
|
||||
https://www.iteye.com/topic/851459
|
||||
https://sourceforge.net/projects/jmp123/files/
|
||||
*/
|
||||
Recorder.LibFFT=function(bufferSize){
|
||||
"use strict";
|
||||
|
||||
var FFT_N_LOG,FFT_N,MINY;
|
||||
var real, imag, sintable, costable;
|
||||
var bitReverse;
|
||||
|
||||
var FFT_Fn=function(bufferSize) {//bufferSize只能取值2的n次方
|
||||
FFT_N_LOG=Math.round(Math.log(bufferSize)/Math.log(2));
|
||||
FFT_N = 1 << FFT_N_LOG;
|
||||
MINY = ((FFT_N << 2) * Math.sqrt(2));
|
||||
|
||||
real = [];
|
||||
imag = [];
|
||||
sintable = [0];
|
||||
costable = [0];
|
||||
bitReverse = [];
|
||||
|
||||
var i, j, k, reve;
|
||||
for (i = 0; i < FFT_N; i++) {
|
||||
k = i;
|
||||
for (j = 0, reve = 0; j != FFT_N_LOG; j++) {
|
||||
reve <<= 1;
|
||||
reve |= (k & 1);
|
||||
k >>>= 1;
|
||||
}
|
||||
bitReverse[i] = reve;
|
||||
}
|
||||
|
||||
var theta, dt = 2 * Math.PI / FFT_N;
|
||||
for (i = (FFT_N >> 1) - 1; i > 0; i--) {
|
||||
theta = i * dt;
|
||||
costable[i] = Math.cos(theta);
|
||||
sintable[i] = Math.sin(theta);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
用于频谱显示的快速傅里叶变换
|
||||
inBuffer 输入FFT_N个实数,返回 FFT_N/2个输出值(复数模的平方)。
|
||||
*/
|
||||
var getModulus=function(inBuffer) {
|
||||
var i, j, k, ir, j0 = 1, idx = FFT_N_LOG - 1;
|
||||
var cosv, sinv, tmpr, tmpi;
|
||||
for (i = 0; i != FFT_N; i++) {
|
||||
real[i] = inBuffer[bitReverse[i]];
|
||||
imag[i] = 0;
|
||||
}
|
||||
|
||||
for (i = FFT_N_LOG; i != 0; i--) {
|
||||
for (j = 0; j != j0; j++) {
|
||||
cosv = costable[j << idx];
|
||||
sinv = sintable[j << idx];
|
||||
for (k = j; k < FFT_N; k += j0 << 1) {
|
||||
ir = k + j0;
|
||||
tmpr = cosv * real[ir] - sinv * imag[ir];
|
||||
tmpi = cosv * imag[ir] + sinv * real[ir];
|
||||
real[ir] = real[k] - tmpr;
|
||||
imag[ir] = imag[k] - tmpi;
|
||||
real[k] += tmpr;
|
||||
imag[k] += tmpi;
|
||||
}
|
||||
}
|
||||
j0 <<= 1;
|
||||
idx--;
|
||||
}
|
||||
|
||||
j = FFT_N >> 1;
|
||||
var outBuffer=new Float64Array(j);
|
||||
/*
|
||||
* 输出模的平方:
|
||||
* for(i = 1; i <= j; i++)
|
||||
* inBuffer[i-1] = real[i] * real[i] + imag[i] * imag[i];
|
||||
*
|
||||
* 如果FFT只用于频谱显示,可以"淘汰"幅值较小的而减少浮点乘法运算. MINY的值
|
||||
* 和Spectrum.Y0,Spectrum.logY0对应.
|
||||
*/
|
||||
sinv = MINY;
|
||||
cosv = -MINY;
|
||||
for (i = j; i != 0; i--) {
|
||||
tmpr = real[i];
|
||||
tmpi = imag[i];
|
||||
if (tmpr > cosv && tmpr < sinv && tmpi > cosv && tmpi < sinv)
|
||||
outBuffer[i - 1] = 0;
|
||||
else
|
||||
outBuffer[i - 1] = Math.round(tmpr * tmpr + tmpi * tmpi);
|
||||
}
|
||||
return outBuffer;
|
||||
}
|
||||
|
||||
FFT_Fn(bufferSize);
|
||||
return {transform:getModulus,bufferSize:FFT_N};
|
||||
};
|
14173
web/static/js/mp3-engine.js
Normal file
14173
web/static/js/mp3-engine.js
Normal file
File diff suppressed because it is too large
Load Diff
424
web/static/js/mp3.js
Normal file
424
web/static/js/mp3.js
Normal file
|
@ -0,0 +1,424 @@
|
|||
/*
|
||||
mp3编码器,需带上mp3-engine.js引擎使用
|
||||
https://github.com/xiangyuecn/Recorder
|
||||
|
||||
当然最佳推荐使用mp3、wav格式,代码也是优先照顾这两种格式
|
||||
浏览器支持情况
|
||||
https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats
|
||||
*/
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
Recorder.prototype.enc_mp3={
|
||||
stable:true
|
||||
,testmsg:"采样率范围48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000"
|
||||
};
|
||||
|
||||
|
||||
|
||||
//*******标准UI线程转码支持函数************
|
||||
|
||||
Recorder.prototype.mp3=function(res,True,False){
|
||||
var This=this,set=This.set,size=res.length;
|
||||
|
||||
//优先采用worker编码,太低版本下面用老方法提供兼容
|
||||
var ctx=This.mp3_start(set);
|
||||
if(ctx){
|
||||
This.mp3_encode(ctx,res);
|
||||
This.mp3_complete(ctx,True,False,1);
|
||||
return;
|
||||
};
|
||||
|
||||
//https://github.com/wangpengfei15975/recorder.js
|
||||
//https://github.com/zhuker/lamejs bug:采样率必须和源一致,不然8k时没有声音,有问题fix:https://github.com/zhuker/lamejs/pull/11
|
||||
var mp3=new Recorder.lamejs.Mp3Encoder(1,set.sampleRate,set.bitRate);
|
||||
|
||||
var blockSize=57600;
|
||||
var data=[];
|
||||
|
||||
var idx=0,mp3Size=0;
|
||||
var run=function(){
|
||||
if(idx<size){
|
||||
var buf=mp3.encodeBuffer(res.subarray(idx,idx+blockSize));
|
||||
if(buf.length>0){
|
||||
mp3Size+=buf.buffer.byteLength;
|
||||
data.push(buf.buffer);
|
||||
};
|
||||
idx+=blockSize;
|
||||
setTimeout(run);//尽量避免卡ui
|
||||
}else{
|
||||
var buf=mp3.flush();
|
||||
if(buf.length>0){
|
||||
mp3Size+=buf.buffer.byteLength;
|
||||
data.push(buf.buffer);
|
||||
};
|
||||
|
||||
//去掉开头的标记信息帧
|
||||
var meta=mp3TrimFix.fn(data,mp3Size,size,set.sampleRate);
|
||||
mp3TrimFixSetMeta(meta,set);
|
||||
|
||||
True(new Blob(data,{type:"audio/mp3"}));
|
||||
};
|
||||
};
|
||||
run();
|
||||
}
|
||||
|
||||
|
||||
//********边录边转码(Worker)支持函数,如果提供就代表可能支持,否则只支持标准转码*********
|
||||
|
||||
//全局共享一个Worker,后台串行执行。如果每次都开一个新的,编码速度可能会慢很多,可能是浏览器运行缓存的因素,并且可能瞬间产生多个并行操作占用大量cpu
|
||||
var mp3Worker;
|
||||
Recorder.BindDestroy("mp3Worker",function(){
|
||||
console.log("mp3Worker Destroy");
|
||||
mp3Worker&&mp3Worker.terminate();
|
||||
mp3Worker=null;
|
||||
});
|
||||
|
||||
|
||||
Recorder.prototype.mp3_envCheck=function(envInfo,set){//检查环境下配置是否可用
|
||||
var errMsg="";
|
||||
//需要实时编码返回数据,此时需要检查环境是否有实时特性、和是否可实时编码
|
||||
if(set.takeoffEncodeChunk){
|
||||
if(!envInfo.canProcess){
|
||||
errMsg=envInfo.envName+"环境不支持实时处理";
|
||||
}else if(!newContext()){//浏览器不能创建实时编码环境
|
||||
errMsg="当前浏览器版本太低,无法实时处理";
|
||||
};
|
||||
};
|
||||
return errMsg;
|
||||
};
|
||||
Recorder.prototype.mp3_start=function(set){//如果返回null代表不支持
|
||||
return newContext(set);
|
||||
};
|
||||
var openList={id:0};
|
||||
var newContext=function(setOrNull){
|
||||
var worker=mp3Worker;
|
||||
try{
|
||||
if(!worker){
|
||||
var onmsg=function(e){
|
||||
var ed=e.data;
|
||||
var cur=wk_ctxs[ed.id];
|
||||
if(ed.action=="init"){
|
||||
wk_ctxs[ed.id]={
|
||||
sampleRate:ed.sampleRate
|
||||
,bitRate:ed.bitRate
|
||||
,takeoff:ed.takeoff
|
||||
|
||||
,mp3Size:0
|
||||
,pcmSize:0
|
||||
,encArr:[]
|
||||
,encObj:new wk_lame.Mp3Encoder(1,ed.sampleRate,ed.bitRate)
|
||||
};
|
||||
}else if(!cur){
|
||||
return;
|
||||
};
|
||||
|
||||
switch(ed.action){
|
||||
case "stop":
|
||||
cur.encObj=null;
|
||||
delete wk_ctxs[ed.id];
|
||||
break;
|
||||
case "encode":
|
||||
cur.pcmSize+=ed.pcm.length;
|
||||
var buf=cur.encObj.encodeBuffer(ed.pcm);
|
||||
if(buf.length>0){
|
||||
if(cur.takeoff){
|
||||
self.postMessage({action:"takeoff",id:ed.id,chunk:buf});
|
||||
}else{
|
||||
cur.mp3Size+=buf.buffer.byteLength;
|
||||
cur.encArr.push(buf.buffer);
|
||||
};
|
||||
};
|
||||
break;
|
||||
case "complete":
|
||||
var buf=cur.encObj.flush();
|
||||
if(buf.length>0){
|
||||
if(cur.takeoff){
|
||||
self.postMessage({action:"takeoff",id:ed.id,chunk:buf});
|
||||
}else{
|
||||
cur.mp3Size+=buf.buffer.byteLength;
|
||||
cur.encArr.push(buf.buffer);
|
||||
};
|
||||
};
|
||||
|
||||
//去掉开头的标记信息帧
|
||||
var meta=wk_mp3TrimFix.fn(cur.encArr,cur.mp3Size,cur.pcmSize,cur.sampleRate);
|
||||
|
||||
self.postMessage({
|
||||
action:ed.action
|
||||
,id:ed.id
|
||||
,blob:new Blob(cur.encArr,{type:"audio/mp3"})
|
||||
,meta:meta
|
||||
});
|
||||
break;
|
||||
};
|
||||
};
|
||||
|
||||
//创建一个新Worker
|
||||
var jsCode=");wk_lame();var wk_ctxs={};self.onmessage="+onmsg;
|
||||
jsCode+=";var wk_mp3TrimFix={rm:"+mp3TrimFix.rm+",fn:"+mp3TrimFix.fn+"}";
|
||||
|
||||
var lamejsCode=Recorder.lamejs.toString();
|
||||
var url=(window.URL||webkitURL).createObjectURL(new Blob(["var wk_lame=(",lamejsCode,jsCode], {type:"text/javascript"}));
|
||||
|
||||
worker=new Worker(url);
|
||||
setTimeout(function(){
|
||||
(window.URL||webkitURL).revokeObjectURL(url);//必须要释放,不然每次调用内存都明显泄露内存
|
||||
},10000);//chrome 83 file协议下如果直接释放,将会使WebWorker无法启动
|
||||
|
||||
worker.onmessage=function(e){
|
||||
var data=e.data;
|
||||
var ctx=openList[data.id];
|
||||
if(ctx){
|
||||
if(data.action=="takeoff"){
|
||||
//取走实时生成的mp3数据
|
||||
ctx.set.takeoffEncodeChunk(new Uint8Array(data.chunk.buffer));
|
||||
}else{
|
||||
//complete
|
||||
ctx.call&&ctx.call(data);
|
||||
ctx.call=null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
var ctx={worker:worker,set:setOrNull,takeoffQueue:[]};
|
||||
if(setOrNull){
|
||||
ctx.id=++openList.id;
|
||||
openList[ctx.id]=ctx;
|
||||
|
||||
worker.postMessage({
|
||||
action:"init"
|
||||
,id:ctx.id
|
||||
,sampleRate:setOrNull.sampleRate
|
||||
,bitRate:setOrNull.bitRate
|
||||
,takeoff:!!setOrNull.takeoffEncodeChunk
|
||||
|
||||
,x:new Int16Array(5)//低版本浏览器不支持序列化TypedArray
|
||||
});
|
||||
}else{
|
||||
worker.postMessage({
|
||||
x:new Int16Array(5)//低版本浏览器不支持序列化TypedArray
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
mp3Worker=worker;
|
||||
return ctx;
|
||||
}catch(e){//出错了就不要提供了
|
||||
worker&&worker.terminate();
|
||||
|
||||
console.error(e);
|
||||
return null;
|
||||
};
|
||||
};
|
||||
Recorder.prototype.mp3_stop=function(startCtx){
|
||||
if(startCtx&&startCtx.worker){
|
||||
startCtx.worker.postMessage({
|
||||
action:"stop"
|
||||
,id:startCtx.id
|
||||
});
|
||||
startCtx.worker=null;
|
||||
delete openList[startCtx.id];
|
||||
|
||||
//疑似泄露检测 排除id
|
||||
var opens=-1;
|
||||
for(var k in openList){
|
||||
opens++;
|
||||
};
|
||||
if(opens){
|
||||
console.warn("mp3 worker剩"+opens+"个在串行等待");
|
||||
};
|
||||
};
|
||||
};
|
||||
Recorder.prototype.mp3_encode=function(startCtx,pcm){
|
||||
if(startCtx&&startCtx.worker){
|
||||
startCtx.worker.postMessage({
|
||||
action:"encode"
|
||||
,id:startCtx.id
|
||||
,pcm:pcm
|
||||
});
|
||||
};
|
||||
};
|
||||
Recorder.prototype.mp3_complete=function(startCtx,True,False,autoStop){
|
||||
var This=this;
|
||||
if(startCtx&&startCtx.worker){
|
||||
startCtx.call=function(data){
|
||||
mp3TrimFixSetMeta(data.meta,startCtx.set);
|
||||
True(data.blob);
|
||||
|
||||
if(autoStop){
|
||||
This.mp3_stop(startCtx);
|
||||
};
|
||||
};
|
||||
startCtx.worker.postMessage({
|
||||
action:"complete"
|
||||
,id:startCtx.id
|
||||
});
|
||||
}else{
|
||||
False("mp3编码器未打开");
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//*******辅助函数************
|
||||
|
||||
/*读取lamejs编码出来的mp3信息,只能读特定格式,如果读取失败返回null
|
||||
mp3Buffers=[ArrayBuffer,...]
|
||||
length=mp3Buffers的数据二进制总长度
|
||||
*/
|
||||
Recorder.mp3ReadMeta=function(mp3Buffers,length){
|
||||
//kill babel-polyfill ES6 Number.parseInt 不然放到Worker里面找不到方法
|
||||
var parseInt_ES3=typeof(window)=="object"?window.parseInt:self.parseInt;
|
||||
|
||||
var u8arr0=new Uint8Array(mp3Buffers[0]||[]);
|
||||
if(u8arr0.length<4){
|
||||
return null;
|
||||
};
|
||||
var byteAt=function(idx,u8){
|
||||
return ("0000000"+((u8||u8arr0)[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_ES3(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_ES3(b4.substr(0,4),2)];
|
||||
|
||||
if(!version || !layer || !bitRate || !sampleRate){
|
||||
return null;
|
||||
};
|
||||
|
||||
var duration=Math.round(length*8/bitRate);
|
||||
var frame=layer==1?384:layer==2?1152:version==1?1152:576;
|
||||
var frameDurationFloat=frame/sampleRate*1000;
|
||||
var frameSize=Math.floor((frame*bitRate)/8/sampleRate*1000);
|
||||
|
||||
//检测是否存在Layer3帧填充1字节。这里只获取第二帧的填充信息,首帧永远没有填充。其他帧可能隔一帧出现一个填充,或者隔很多帧出现一个填充;目测是取决于frameSize未舍入时的小数部分,因为有些采样率的frameSize会出现小数(11025、22050、44100 典型的除不尽),然后字节数无法表示这种小数,就通过一定步长来填充弥补小数部分丢失
|
||||
var hasPadding=0,seek=0;
|
||||
for(var i=0;i<mp3Buffers.length;i++){
|
||||
//寻找第二帧
|
||||
var buf=mp3Buffers[i];
|
||||
seek+=buf.byteLength;
|
||||
if(seek>=frameSize+3){
|
||||
var buf8=new Uint8Array(buf);
|
||||
var idx=buf.byteLength-(seek-(frameSize+3)+1);
|
||||
var ib4=byteAt(idx,buf8);
|
||||
hasPadding=ib4.charAt(6)=="1";
|
||||
break;
|
||||
};
|
||||
};
|
||||
if(hasPadding){
|
||||
frameSize++;
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
version:version //1 2 2.5 -> MPEG1 MPEG2 MPEG2.5
|
||||
,layer:layer//3 -> Layer3
|
||||
,sampleRate:sampleRate //采样率 hz
|
||||
,bitRate:bitRate //比特率 kbps
|
||||
|
||||
,duration:duration //音频时长 ms
|
||||
,size:length //总长度 byte
|
||||
,hasPadding:hasPadding //是否存在1字节填充,首帧永远没有,这个值其实代表的第二帧是否有填充,并不代表其他帧的
|
||||
,frameSize:frameSize //每帧最大长度,含可能存在的1字节padding byte
|
||||
,frameDurationFloat:frameDurationFloat //每帧时长,含小数 ms
|
||||
};
|
||||
};
|
||||
|
||||
//去掉lamejs开头的标记信息帧,免得mp3解码出来的时长比pcm的长太多
|
||||
var mp3TrimFix={//minfiy keep name
|
||||
rm:Recorder.mp3ReadMeta
|
||||
,fn:function(mp3Buffers,length,pcmLength,pcmSampleRate){
|
||||
var meta=this.rm(mp3Buffers,length);
|
||||
if(!meta){
|
||||
return {err:"mp3非预定格式"};
|
||||
};
|
||||
var pcmDuration=Math.round(pcmLength/pcmSampleRate*1000);
|
||||
|
||||
//开头多出这么多帧,移除掉;正常情况下最多为2帧
|
||||
var num=Math.floor((meta.duration-pcmDuration)/meta.frameDurationFloat);
|
||||
if(num>0){
|
||||
var size=num*meta.frameSize-(meta.hasPadding?1:0);//首帧没有填充,第二帧可能有填充,这里假设最多为2帧(测试并未出现3帧以上情况),其他帧不管,就算出现了并且导致了错误后面自动容错
|
||||
length-=size;
|
||||
var arr0=0,arrs=[];
|
||||
for(var i=0;i<mp3Buffers.length;i++){
|
||||
var arr=mp3Buffers[i];
|
||||
if(size<=0){
|
||||
break;
|
||||
};
|
||||
if(size>=arr.byteLength){
|
||||
size-=arr.byteLength;
|
||||
arrs.push(arr);
|
||||
mp3Buffers.splice(i,1);
|
||||
i--;
|
||||
}else{
|
||||
mp3Buffers[i]=arr.slice(size);
|
||||
arr0=arr;
|
||||
size=0;
|
||||
};
|
||||
};
|
||||
var checkMeta=this.rm(mp3Buffers,length);
|
||||
if(!checkMeta){
|
||||
//还原变更,应该不太可能会出现
|
||||
arr0&&(mp3Buffers[0]=arr0);
|
||||
for(var i=0;i<arrs.length;i++){
|
||||
mp3Buffers.splice(i,0,arrs[i]);
|
||||
};
|
||||
meta.err="fix后数据错误,已还原,错误原因不明";
|
||||
};
|
||||
|
||||
var fix=meta.trimFix={};
|
||||
fix.remove=num;
|
||||
fix.removeDuration=Math.round(num*meta.frameDurationFloat);
|
||||
fix.duration=Math.round(length*8/meta.bitRate);
|
||||
};
|
||||
return meta;
|
||||
}
|
||||
};
|
||||
var mp3TrimFixSetMeta=function(meta,set){
|
||||
var tag="MP3信息 ";
|
||||
if(meta.sampleRate&&meta.sampleRate!=set.sampleRate || meta.bitRate&&meta.bitRate!=set.bitRate){
|
||||
console.warn(tag+"和设置的不匹配set:"+set.bitRate+"kbps "+set.sampleRate+"hz,已更新set:"+meta.bitRate+"kbps "+meta.sampleRate+"hz",set);
|
||||
set.sampleRate=meta.sampleRate;
|
||||
set.bitRate=meta.bitRate;
|
||||
};
|
||||
|
||||
var trimFix=meta.trimFix;
|
||||
if(trimFix){
|
||||
tag+="Fix移除"+trimFix.remove+"帧"+trimFix.removeDuration+"ms -> "+trimFix.duration+"ms";
|
||||
if(trimFix.remove>2){
|
||||
meta.err=(meta.err?meta.err+", ":"")+"移除帧数过多";
|
||||
};
|
||||
}else{
|
||||
tag+=(meta.duration||"-")+"ms";
|
||||
};
|
||||
|
||||
if(meta.err){
|
||||
console.error(tag,meta.err,meta);
|
||||
}else{
|
||||
console.log(tag,meta);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
})();
|
950
web/static/js/recorder-core.js
Normal file
950
web/static/js/recorder-core.js
Normal file
|
@ -0,0 +1,950 @@
|
|||
/*
|
||||
录音
|
||||
https://github.com/xiangyuecn/Recorder
|
||||
*/
|
||||
(function(factory){
|
||||
factory(window);
|
||||
//umd returnExports.js
|
||||
if(typeof(define)=='function' && define.amd){
|
||||
define(function(){
|
||||
return Recorder;
|
||||
});
|
||||
};
|
||||
if(typeof(module)=='object' && module.exports){
|
||||
module.exports=Recorder;
|
||||
};
|
||||
}(function(window){
|
||||
"use strict";
|
||||
|
||||
//兼容环境
|
||||
var LM="2021-08-03 20:01:03";
|
||||
var NOOP=function(){};
|
||||
//end 兼容环境 ****从以下开始copy源码*****
|
||||
|
||||
var Recorder=function(set){
|
||||
return new initFn(set);
|
||||
};
|
||||
//是否已经打开了全局的麦克风录音,所有工作都已经准备好了,就等接收音频数据了
|
||||
Recorder.IsOpen=function(){
|
||||
var stream=Recorder.Stream;
|
||||
if(stream){
|
||||
var tracks=stream.getTracks&&stream.getTracks()||stream.audioTracks||[];
|
||||
var track=tracks[0];
|
||||
if(track){
|
||||
var state=track.readyState;
|
||||
return state=="live"||state==track.LIVE;
|
||||
};
|
||||
};
|
||||
return false;
|
||||
};
|
||||
/*H5录音时的AudioContext缓冲大小。会影响H5录音时的onProcess调用速率,相对于AudioContext.sampleRate=48000时,4096接近12帧/s,调节此参数可生成比较流畅的回调动画。
|
||||
取值256, 512, 1024, 2048, 4096, 8192, or 16384
|
||||
注意,取值不能过低,2048开始不同浏览器可能回调速率跟不上造成音质问题。
|
||||
一般无需调整,调整后需要先close掉已打开的录音,再open时才会生效。
|
||||
*/
|
||||
Recorder.BufferSize=4096;
|
||||
//销毁已持有的所有全局资源,当要彻底移除Recorder时需要显式的调用此方法
|
||||
Recorder.Destroy=function(){
|
||||
CLog("Recorder Destroy");
|
||||
Disconnect();//断开可能存在的全局Stream、资源
|
||||
|
||||
for(var k in DestroyList){
|
||||
DestroyList[k]();
|
||||
};
|
||||
};
|
||||
var DestroyList={};
|
||||
//登记一个需要销毁全局资源的处理方法
|
||||
Recorder.BindDestroy=function(key,call){
|
||||
DestroyList[key]=call;
|
||||
};
|
||||
//判断浏览器是否支持录音,随时可以调用。注意:仅仅是检测浏览器支持情况,不会判断和调起用户授权,不会判断是否支持特定格式录音。
|
||||
Recorder.Support=function(){
|
||||
var AC=window.AudioContext;
|
||||
if(!AC){
|
||||
AC=window.webkitAudioContext;
|
||||
};
|
||||
if(!AC){
|
||||
return false;
|
||||
};
|
||||
var scope=navigator.mediaDevices||{};
|
||||
if(!scope.getUserMedia){
|
||||
scope=navigator;
|
||||
scope.getUserMedia||(scope.getUserMedia=scope.webkitGetUserMedia||scope.mozGetUserMedia||scope.msGetUserMedia);
|
||||
};
|
||||
if(!scope.getUserMedia){
|
||||
return false;
|
||||
};
|
||||
|
||||
Recorder.Scope=scope;
|
||||
if(!Recorder.Ctx||Recorder.Ctx.state=="closed"){
|
||||
//不能反复构造,低版本number of hardware contexts reached maximum (6)
|
||||
Recorder.Ctx=new AC();
|
||||
|
||||
Recorder.BindDestroy("Ctx",function(){
|
||||
var ctx=Recorder.Ctx;
|
||||
if(ctx&&ctx.close){//能关掉就关掉,关不掉就保留着
|
||||
ctx.close();
|
||||
Recorder.Ctx=0;
|
||||
};
|
||||
});
|
||||
};
|
||||
return true;
|
||||
};
|
||||
/*初始化H5音频采集连接。如果自行提供了sourceStream将只进行一次简单的连接处理。如果是普通麦克风录音,此时的Stream是全局的,Safari上断开后就无法再次进行连接使用,表现为静音,因此使用全部使用全局处理避免调用到disconnect;全局处理也有利于屏蔽底层细节,start时无需再调用底层接口,提升兼容、可靠性。*/
|
||||
var Connect=function(streamStore){
|
||||
streamStore=streamStore||Recorder;
|
||||
var bufferSize=streamStore.BufferSize||Recorder.BufferSize;
|
||||
|
||||
var ctx=Recorder.Ctx,stream=streamStore.Stream;
|
||||
var media=stream._m=ctx.createMediaStreamSource(stream);
|
||||
var process=stream._p=(ctx.createScriptProcessor||ctx.createJavaScriptNode).call(ctx,bufferSize,1,1);//单声道,省的数据处理复杂
|
||||
|
||||
media.connect(process);
|
||||
process.connect(ctx.destination);
|
||||
|
||||
var calls=stream._call;
|
||||
process.onaudioprocess=function(e){
|
||||
for(var k0 in calls){//has item
|
||||
var o=e.inputBuffer.getChannelData(0);//块是共享的,必须复制出来
|
||||
var size=o.length;
|
||||
|
||||
var pcm=new Int16Array(size);
|
||||
var sum=0;
|
||||
for(var j=0;j<size;j++){//floatTo16BitPCM
|
||||
var s=Math.max(-1,Math.min(1,o[j]));
|
||||
s=s<0?s*0x8000:s*0x7FFF;
|
||||
pcm[j]=s;
|
||||
sum+=Math.abs(s);
|
||||
};
|
||||
|
||||
for(var k in calls){
|
||||
calls[k](pcm,sum);
|
||||
};
|
||||
|
||||
return;
|
||||
};
|
||||
};
|
||||
};
|
||||
var Disconnect=function(streamStore){
|
||||
streamStore=streamStore||Recorder;
|
||||
var isGlobal=streamStore==Recorder;
|
||||
|
||||
var stream=streamStore.Stream;
|
||||
if(stream){
|
||||
if(stream._m){
|
||||
stream._m.disconnect();
|
||||
stream._p.disconnect();
|
||||
stream._p.onaudioprocess=stream._p=stream._m=null;
|
||||
};
|
||||
|
||||
if(isGlobal){//全局的时候,要把流关掉(麦克风),直接提供的流不处理
|
||||
var tracks=stream.getTracks&&stream.getTracks()||stream.audioTracks||[];
|
||||
for(var i=0;i<tracks.length;i++){
|
||||
var track=tracks[i];
|
||||
track.stop&&track.stop();
|
||||
};
|
||||
stream.stop&&stream.stop();
|
||||
};
|
||||
};
|
||||
streamStore.Stream=0;
|
||||
};
|
||||
|
||||
/*对pcm数据的采样率进行转换
|
||||
pcmDatas: [[Int16,...]] pcm片段列表
|
||||
pcmSampleRate:48000 pcm数据的采样率
|
||||
newSampleRate:16000 需要转换成的采样率,newSampleRate>=pcmSampleRate时不会进行任何处理,小于时会进行重新采样
|
||||
prevChunkInfo:{} 可选,上次调用时的返回值,用于连续转换,本次调用将从上次结束位置开始进行处理。或可自行定义一个ChunkInfo从pcmDatas指定的位置开始进行转换
|
||||
option:{ 可选,配置项
|
||||
frameSize:123456 帧大小,每帧的PCM Int16的数量,采样率转换后的pcm长度为frameSize的整数倍,用于连续转换。目前仅在mp3格式时才有用,frameSize取值为1152,这样编码出来的mp3时长和pcm的时长完全一致,否则会因为mp3最后一帧录音不够填满时添加填充数据导致mp3的时长变长。
|
||||
frameType:"" 帧类型,一般为rec.set.type,提供此参数时无需提供frameSize,会自动使用最佳的值给frameSize赋值,目前仅支持mp3=1152(MPEG1 Layer3的每帧采采样数),其他类型=1。
|
||||
以上两个参数用于连续转换时使用,最多使用一个,不提供时不进行帧的特殊处理,提供时必须同时提供prevChunkInfo才有作用。最后一段数据处理时无需提供帧大小以便输出最后一丁点残留数据。
|
||||
}
|
||||
|
||||
返回ChunkInfo:{
|
||||
//可定义,从指定位置开始转换到结尾
|
||||
index:0 pcmDatas已处理到的索引
|
||||
offset:0.0 已处理到的index对应的pcm中的偏移的下一个位置
|
||||
|
||||
//仅作为返回值
|
||||
frameNext:null||[Int16,...] 下一帧的部分数据,frameSize设置了的时候才可能会有
|
||||
sampleRate:16000 结果的采样率,<=newSampleRate
|
||||
data:[Int16,...] 转换后的PCM结果;如果是连续转换,并且pcmDatas中并没有新数据时,data的长度可能为0
|
||||
}
|
||||
*/
|
||||
Recorder.SampleData=function(pcmDatas,pcmSampleRate,newSampleRate,prevChunkInfo,option){
|
||||
prevChunkInfo||(prevChunkInfo={});
|
||||
var index=prevChunkInfo.index||0;
|
||||
var offset=prevChunkInfo.offset||0;
|
||||
|
||||
var frameNext=prevChunkInfo.frameNext||[];
|
||||
option||(option={});
|
||||
var frameSize=option.frameSize||1;
|
||||
if(option.frameType){
|
||||
frameSize=option.frameType=="mp3"?1152:1;
|
||||
};
|
||||
|
||||
var size=0;
|
||||
for(var i=index;i<pcmDatas.length;i++){
|
||||
size+=pcmDatas[i].length;
|
||||
};
|
||||
size=Math.max(0,size-Math.floor(offset));
|
||||
|
||||
//采样 https://www.cnblogs.com/blqw/p/3782420.html
|
||||
var step=pcmSampleRate/newSampleRate;
|
||||
if(step>1){//新采样低于录音采样,进行抽样
|
||||
size=Math.floor(size/step);
|
||||
}else{//新采样高于录音采样不处理,省去了插值处理
|
||||
step=1;
|
||||
newSampleRate=pcmSampleRate;
|
||||
};
|
||||
|
||||
size+=frameNext.length;
|
||||
var res=new Int16Array(size);
|
||||
var idx=0;
|
||||
//添加上一次不够一帧的剩余数据
|
||||
for(var i=0;i<frameNext.length;i++){
|
||||
res[idx]=frameNext[i];
|
||||
idx++;
|
||||
};
|
||||
//处理数据
|
||||
for (var nl=pcmDatas.length;index<nl;index++) {
|
||||
var o=pcmDatas[index];
|
||||
var i=offset,il=o.length;
|
||||
while(i<il){
|
||||
//res[idx]=o[Math.round(i)]; 直接简单抽样
|
||||
|
||||
//https://www.cnblogs.com/xiaoqi/p/6993912.html
|
||||
//当前点=当前点+到后面一个点之间的增量,音质比直接简单抽样好些
|
||||
var before = Math.floor(i);
|
||||
var after = Math.ceil(i);
|
||||
var atPoint = i - before;
|
||||
|
||||
var beforeVal=o[before];
|
||||
var afterVal=after<il ? o[after]
|
||||
: (//后个点越界了,查找下一个数组
|
||||
(pcmDatas[index+1]||[beforeVal])[0]||0
|
||||
);
|
||||
res[idx]=beforeVal+(afterVal-beforeVal)*atPoint;
|
||||
|
||||
idx++;
|
||||
i+=step;//抽样
|
||||
};
|
||||
offset=i-il;
|
||||
};
|
||||
//帧处理
|
||||
frameNext=null;
|
||||
var frameNextSize=res.length%frameSize;
|
||||
if(frameNextSize>0){
|
||||
var u8Pos=(res.length-frameNextSize)*2;
|
||||
frameNext=new Int16Array(res.buffer.slice(u8Pos));
|
||||
res=new Int16Array(res.buffer.slice(0,u8Pos));
|
||||
};
|
||||
|
||||
return {
|
||||
index:index
|
||||
,offset:offset
|
||||
|
||||
,frameNext:frameNext
|
||||
,sampleRate:newSampleRate
|
||||
,data:res
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/*计算音量百分比的一个方法
|
||||
pcmAbsSum: pcm Int16所有采样的绝对值的和
|
||||
pcmLength: pcm长度
|
||||
返回值:0-100,主要当做百分比用
|
||||
注意:这个不是分贝,因此没用volume当做名称*/
|
||||
Recorder.PowerLevel=function(pcmAbsSum,pcmLength){
|
||||
/*计算音量 https://blog.csdn.net/jody1989/article/details/73480259
|
||||
更高灵敏度算法:
|
||||
限定最大感应值10000
|
||||
线性曲线:低音量不友好
|
||||
power/10000*100
|
||||
对数曲线:低音量友好,但需限定最低感应值
|
||||
(1+Math.log10(power/10000))*100
|
||||
*/
|
||||
var power=(pcmAbsSum/pcmLength) || 0;//NaN
|
||||
var level;
|
||||
if(power<1251){//1250的结果10%,更小的音量采用线性取值
|
||||
level=Math.round(power/1250*10);
|
||||
}else{
|
||||
level=Math.round(Math.min(100,Math.max(0,(1+Math.log(power/10000)/Math.log(10))*100)));
|
||||
};
|
||||
return level;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
//带时间的日志输出,CLog(msg,errOrLogMsg, logMsg...) err为数字时代表日志类型1:error 2:log默认 3:warn,否则当做内容输出,第一个参数不能是对象因为要拼接时间,后面可以接无数个输出参数
|
||||
var CLog=function(msg,err){
|
||||
var now=new Date();
|
||||
var t=("0"+now.getMinutes()).substr(-2)
|
||||
+":"+("0"+now.getSeconds()).substr(-2)
|
||||
+"."+("00"+now.getMilliseconds()).substr(-3);
|
||||
var arr=["["+t+" Recorder]"+msg];
|
||||
var a=arguments;
|
||||
var i=2,fn=console.log;
|
||||
if(typeof(err)=="number"){
|
||||
fn=err==1?console.error:err==3?console.warn:fn;
|
||||
}else{
|
||||
i=1;
|
||||
};
|
||||
for(;i<a.length;i++){
|
||||
arr.push(a[i]);
|
||||
};
|
||||
fn.apply(console,arr);
|
||||
};
|
||||
Recorder.CLog=CLog;
|
||||
|
||||
|
||||
|
||||
|
||||
var ID=0;
|
||||
function initFn(set){
|
||||
this.id=++ID;
|
||||
|
||||
//如果开启了流量统计,这里将发送一个图片请求
|
||||
Recorder.Traffic&&Recorder.Traffic();
|
||||
|
||||
|
||||
var o={
|
||||
type:"mp3" //输出类型:mp3,wav,wav输出文件尺寸超大不推荐使用,但mp3编码支持会导致js文件超大,如果不需支持mp3可以使js文件大幅减小
|
||||
,bitRate:16 //比特率 wav:16或8位,MP3:8kbps 1k/s,8kbps 2k/s 录音文件很小
|
||||
|
||||
,sampleRate:16000 //采样率,wav格式大小=sampleRate*时间;mp3此项对低比特率有影响,高比特率几乎无影响。
|
||||
//wav任意值,mp3取值范围:48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000
|
||||
//采样率参考https://www.cnblogs.com/devin87/p/mp3-recorder.html
|
||||
|
||||
,onProcess:NOOP //fn(buffers,powerLevel,bufferDuration,bufferSampleRate,newBufferIdx,asyncEnd) buffers=[[Int16,...],...]:缓冲的PCM数据,为从开始录音到现在的所有pcm片段;powerLevel:当前缓冲的音量级别0-100,bufferDuration:已缓冲时长,bufferSampleRate:缓冲使用的采样率(当type支持边录边转码(Worker)时,此采样率和设置的采样率相同,否则不一定相同);newBufferIdx:本次回调新增的buffer起始索引;asyncEnd:fn() 如果onProcess是异步的(返回值为true时),处理完成时需要调用此回调,如果不是异步的请忽略此参数,此方法回调时必须是真异步(不能真异步时需用setTimeout包裹)。onProcess返回值:如果返回true代表开启异步模式,在某些大量运算的场合异步是必须的,必须在异步处理完成时调用asyncEnd(不能真异步时需用setTimeout包裹),在onProcess执行后新增的buffer会全部替换成空数组,因此本回调开头应立即将newBufferIdx到本次回调结尾位置的buffer全部保存到另外一个数组内,处理完成后写回buffers中本次回调的结尾位置。
|
||||
|
||||
//*******高级设置******
|
||||
//,sourceStream:MediaStream Object
|
||||
//可选直接提供一个媒体流,从这个流中录制、实时处理音频数据(当前Recorder实例独享此流);不提供时为普通的麦克风录音,由getUserMedia提供音频流(所有Recorder实例共享同一个流)
|
||||
//比如:audio、video标签dom节点的captureStream方法(实验特性,不同浏览器支持程度不高)返回的流;WebRTC中的remote流;自己创建的流等
|
||||
//注意:流内必须至少存在一条音轨(Audio Track),比如audio标签必须等待到可以开始播放后才会有音轨,否则open会失败
|
||||
|
||||
//,audioTrackSet:{ deviceId:"",groupId:"", autoGainControl:true, echoCancellation:true, noiseSuppression:true }
|
||||
//普通麦克风录音时getUserMedia方法的audio配置参数,比如指定设备id,回声消除、降噪开关;注意:提供的任何配置值都不一定会生效
|
||||
//由于麦克风是全局共享的,所以新配置后需要close掉以前的再重新open
|
||||
//更多参考: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints
|
||||
|
||||
//,disableEnvInFix:false 内部参数,禁用设备卡顿时音频输入丢失补偿功能
|
||||
|
||||
//,takeoffEncodeChunk:NOOP //fn(chunkBytes) chunkBytes=[Uint8,...]:实时编码环境下接管编码器输出,当编码器实时编码出一块有效的二进制音频数据时实时回调此方法;参数为二进制的Uint8Array,就是编码出来的音频数据片段,所有的chunkBytes拼接在一起即为完整音频。本实现的想法最初由QQ2543775048提出
|
||||
//当提供此回调方法时,将接管编码器的数据输出,编码器内部将放弃存储生成的音频数据;环境要求比较苛刻:如果当前环境不支持实时编码处理,将在open时直接走fail逻辑
|
||||
//因此提供此回调后调用stop方法将无法获得有效的音频数据,因为编码器内没有音频数据,因此stop时返回的blob将是一个字节长度为0的blob
|
||||
//目前只有mp3格式实现了实时编码,在支持实时处理的环境中将会实时的将编码出来的mp3片段通过此方法回调,所有的chunkBytes拼接到一起即为完整的mp3,此种拼接的结果比mock方法实时生成的音质更加,因为天然避免了首尾的静默
|
||||
//目前除mp3外其他格式不可以提供此回调,提供了将在open时直接走fail逻辑
|
||||
};
|
||||
|
||||
for(var k in set){
|
||||
o[k]=set[k];
|
||||
};
|
||||
this.set=o;
|
||||
|
||||
this._S=9;//stop同步锁,stop可以阻止open过程中还未运行的start
|
||||
this.Sync={O:9,C:9};//和Recorder.Sync一致,只不过这个是非全局的,仅用来简化代码逻辑,无实际作用
|
||||
};
|
||||
//同步锁,控制对Stream的竞争;用于close时中断异步的open;一个对象open如果变化了都要阻止close,Stream的控制权交个新的对象
|
||||
Recorder.Sync={/*open*/O:9,/*close*/C:9};
|
||||
|
||||
Recorder.prototype=initFn.prototype={
|
||||
//流相关的数据存储在哪个对象里面;如果提供了sourceStream,数据直接存储在当前对象中,否则存储在全局
|
||||
_streamStore:function(){
|
||||
if(this.set.sourceStream){
|
||||
return this;
|
||||
}else{
|
||||
return Recorder;
|
||||
}
|
||||
}
|
||||
|
||||
//打开录音资源True(),False(msg,isUserNotAllow),需要调用close。注意:此方法是异步的;一般使用时打开,用完立即关闭;可重复调用,可用来测试是否能录音
|
||||
,open:function(True,False){
|
||||
var This=this,streamStore=This._streamStore();
|
||||
True=True||NOOP;
|
||||
var failCall=function(errMsg,isUserNotAllow){
|
||||
isUserNotAllow=!!isUserNotAllow;
|
||||
CLog("录音open失败:"+errMsg+",isUserNotAllow:"+isUserNotAllow,1);
|
||||
False&&False(errMsg,isUserNotAllow);
|
||||
};
|
||||
|
||||
var ok=function(){
|
||||
CLog("open成功");
|
||||
True();
|
||||
|
||||
This._SO=0;//解除stop对open中的start调用的阻止
|
||||
};
|
||||
|
||||
|
||||
//同步锁
|
||||
var Lock=streamStore.Sync;
|
||||
var lockOpen=++Lock.O,lockClose=Lock.C;
|
||||
This._O=This._O_=lockOpen;//记住当前的open,如果变化了要阻止close,这里假定了新对象已取代当前对象并且不再使用
|
||||
This._SO=This._S;//记住open过程中的stop,中途任何stop调用后都不能继续open中的start
|
||||
var lockFail=function(){
|
||||
//允许多次open,但不允许任何一次close,或者自身已经调用了关闭
|
||||
if(lockClose!=Lock.C || !This._O){
|
||||
var err="open被取消";
|
||||
if(lockOpen==Lock.O){
|
||||
//无新的open,已经调用了close进行取消,此处应让上次的close明确生效
|
||||
This.close();
|
||||
}else{
|
||||
err="open被中断";
|
||||
};
|
||||
failCall(err);
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
//环境配置检查
|
||||
var checkMsg=This.envCheck({envName:"H5",canProcess:true});
|
||||
if(checkMsg){
|
||||
failCall("不能录音:"+checkMsg);
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
//***********已直接提供了音频流************
|
||||
if(This.set.sourceStream){
|
||||
if(!Recorder.Support()){
|
||||
failCall("不支持此浏览器从流中获取录音");
|
||||
return;
|
||||
};
|
||||
|
||||
Disconnect(streamStore);//可能已open过,直接先尝试断开
|
||||
This.Stream=This.set.sourceStream;
|
||||
This.Stream._call={};
|
||||
|
||||
try{
|
||||
Connect(streamStore);
|
||||
}catch(e){
|
||||
failCall("从流中打开录音失败:"+e.message);
|
||||
return;
|
||||
}
|
||||
ok();
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
//***********打开麦克风得到全局的音频流************
|
||||
var codeFail=function(code,msg){
|
||||
try{//跨域的优先检测一下
|
||||
window.top.a;
|
||||
}catch(e){
|
||||
failCall('无权录音(跨域,请尝试给iframe添加麦克风访问策略,如allow="camera;microphone")');
|
||||
return;
|
||||
};
|
||||
|
||||
if(/Permission|Allow/i.test(code)){
|
||||
failCall("用户拒绝了录音权限",true);
|
||||
}else if(window.isSecureContext===false){
|
||||
failCall("无权录音(需https)");
|
||||
}else if(/Found/i.test(code)){//可能是非安全环境导致的没有设备
|
||||
failCall(msg+",无可用麦克风");
|
||||
}else{
|
||||
failCall(msg);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
//如果已打开并且有效就不要再打开了
|
||||
if(Recorder.IsOpen()){
|
||||
ok();
|
||||
return;
|
||||
};
|
||||
if(!Recorder.Support()){
|
||||
codeFail("","此浏览器不支持录音");
|
||||
return;
|
||||
};
|
||||
|
||||
//请求权限,如果从未授权,一般浏览器会弹出权限请求弹框
|
||||
var f1=function(stream){
|
||||
Recorder.Stream=stream;
|
||||
stream._call={};//此时is open,但并未connect,是允许绑定接收数据的
|
||||
if(lockFail())return;
|
||||
|
||||
//https://github.com/xiangyuecn/Recorder/issues/14 获取到的track.readyState!="live",刚刚回调时可能是正常的,但过一下可能就被关掉了,原因不明。延迟一下保证真异步。对正常浏览器不影响
|
||||
setTimeout(function(){
|
||||
if(lockFail())return;
|
||||
|
||||
if(Recorder.IsOpen()){
|
||||
Connect();
|
||||
ok();
|
||||
}else{
|
||||
failCall("录音功能无效:无音频流");
|
||||
};
|
||||
},100);
|
||||
};
|
||||
var f2=function(e){
|
||||
var code=e.name||e.message||e.code+":"+e;
|
||||
CLog("请求录音权限错误",1,e);
|
||||
|
||||
codeFail(code,"无法录音:"+code);
|
||||
};
|
||||
var pro=Recorder.Scope.getUserMedia({audio:This.set.audioTrackSet||true},f1,f2);
|
||||
if(pro&&pro.then){
|
||||
pro.then(f1)[True&&"catch"](f2); //fix 关键字,保证catch压缩时保持字符串形式
|
||||
};
|
||||
}
|
||||
//关闭释放录音资源
|
||||
,close:function(call){
|
||||
call=call||NOOP;
|
||||
|
||||
var This=this,streamStore=This._streamStore();
|
||||
This._stop();
|
||||
|
||||
var Lock=streamStore.Sync;
|
||||
This._O=0;
|
||||
if(This._O_!=Lock.O){
|
||||
//唯一资源Stream的控制权已交给新对象,这里不能关闭。此处在每次都弹权限的浏览器内可能存在泄漏,新对象被拒绝权限可能不会调用close,忽略这种不处理
|
||||
CLog("close被忽略",3);
|
||||
call();
|
||||
return;
|
||||
};
|
||||
Lock.C++;//获得控制权
|
||||
|
||||
Disconnect(streamStore);
|
||||
|
||||
CLog("close");
|
||||
call();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*模拟一段录音数据,后面可以调用stop进行编码,需提供pcm数据[1,2,3...],pcm的采样率*/
|
||||
,mock:function(pcmData,pcmSampleRate){
|
||||
var This=this;
|
||||
This._stop();//清理掉已有的资源
|
||||
|
||||
This.isMock=1;
|
||||
This.mockEnvInfo=null;
|
||||
This.buffers=[pcmData];
|
||||
This.recSize=pcmData.length;
|
||||
This.srcSampleRate=pcmSampleRate;
|
||||
return This;
|
||||
}
|
||||
,envCheck:function(envInfo){//平台环境下的可用性检查,任何时候都可以调用检查,返回errMsg:""正常,"失败原因"
|
||||
//envInfo={envName:"H5",canProcess:true}
|
||||
var errMsg,This=this,set=This.set;
|
||||
|
||||
//编码器检查环境下配置是否可用
|
||||
if(!errMsg){
|
||||
if(This[set.type+"_envCheck"]){//编码器已实现环境检查
|
||||
errMsg=This[set.type+"_envCheck"](envInfo,set);
|
||||
}else{//未实现检查的手动检查配置是否有效
|
||||
if(set.takeoffEncodeChunk){
|
||||
errMsg=set.type+"类型不支持设置takeoffEncodeChunk";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
return errMsg||"";
|
||||
}
|
||||
,envStart:function(mockEnvInfo,sampleRate){//平台环境相关的start调用
|
||||
var This=this,set=This.set;
|
||||
This.isMock=mockEnvInfo?1:0;//非H5环境需要启用mock,并提供envCheck需要的环境信息
|
||||
This.mockEnvInfo=mockEnvInfo;
|
||||
This.buffers=[];//数据缓冲
|
||||
This.recSize=0;//数据大小
|
||||
|
||||
This.envInLast=0;//envIn接收到最后录音内容的时间
|
||||
This.envInFirst=0;//envIn接收到的首个录音内容的录制时间
|
||||
This.envInFix=0;//补偿的总时间
|
||||
This.envInFixTs=[];//补偿计数列表
|
||||
|
||||
set.sampleRate=Math.min(sampleRate,set.sampleRate);//engineCtx需要提前确定最终的采样率
|
||||
This.srcSampleRate=sampleRate;
|
||||
|
||||
This.engineCtx=0;
|
||||
//此类型有边录边转码(Worker)支持
|
||||
if(This[set.type+"_start"]){
|
||||
var engineCtx=This.engineCtx=This[set.type+"_start"](set);
|
||||
if(engineCtx){
|
||||
engineCtx.pcmDatas=[];
|
||||
engineCtx.pcmSize=0;
|
||||
};
|
||||
};
|
||||
}
|
||||
,envResume:function(){//和平台环境无关的恢复录音
|
||||
//重新开始计数
|
||||
this.envInFixTs=[];
|
||||
}
|
||||
,envIn:function(pcm,sum){//和平台环境无关的pcm[Int16]输入
|
||||
var This=this,set=This.set,engineCtx=This.engineCtx;
|
||||
var bufferSampleRate=This.srcSampleRate;
|
||||
var size=pcm.length;
|
||||
var powerLevel=Recorder.PowerLevel(sum,size);
|
||||
|
||||
var buffers=This.buffers;
|
||||
var bufferFirstIdx=buffers.length;//之前的buffer都是经过onProcess处理好的,不允许再修改
|
||||
buffers.push(pcm);
|
||||
|
||||
//有engineCtx时会被覆盖,这里保存一份
|
||||
var buffersThis=buffers;
|
||||
var bufferFirstIdxThis=bufferFirstIdx;
|
||||
|
||||
//卡顿丢失补偿:因为设备很卡的时候导致H5接收到的数据量不够造成播放时候变速,结果比实际的时长要短,此处保证了不会变短,但不能修复丢失的音频数据造成音质变差。当前算法采用输入时间侦测下一帧是否需要添加补偿帧,需要(6次输入||超过1秒)以上才会开始侦测,如果滑动窗口内丢失超过1/3就会进行补偿
|
||||
var now=Date.now();
|
||||
var pcmTime=Math.round(size/bufferSampleRate*1000);
|
||||
This.envInLast=now;
|
||||
if(This.buffers.length==1){//记下首个录音数据的录制时间
|
||||
This.envInFirst=now-pcmTime;
|
||||
};
|
||||
var envInFixTs=This.envInFixTs;
|
||||
envInFixTs.splice(0,0,{t:now,d:pcmTime});
|
||||
//保留3秒的计数滑动窗口,另外超过3秒的停顿不补偿
|
||||
var tsInStart=now,tsPcm=0;
|
||||
for(var i=0;i<envInFixTs.length;i++){
|
||||
var o=envInFixTs[i];
|
||||
if(now-o.t>3000){
|
||||
envInFixTs.length=i;
|
||||
break;
|
||||
};
|
||||
tsInStart=o.t;
|
||||
tsPcm+=o.d;
|
||||
};
|
||||
//达到需要的数据量,开始侦测是否需要补偿
|
||||
var tsInPrev=envInFixTs[1];
|
||||
var tsIn=now-tsInStart;
|
||||
var lost=tsIn-tsPcm;
|
||||
if( lost>tsIn/3 && (tsInPrev&&tsIn>1000 || envInFixTs.length>=6) ){
|
||||
//丢失过多,开始执行补偿
|
||||
var addTime=now-tsInPrev.t-pcmTime;//距离上次输入丢失这么多ms
|
||||
if(addTime>pcmTime/5){//丢失超过本帧的1/5
|
||||
var fixOpen=!set.disableEnvInFix;
|
||||
CLog("["+now+"]"+(fixOpen?"":"未")+"补偿"+addTime+"ms",3);
|
||||
This.envInFix+=addTime;
|
||||
|
||||
//用静默进行补偿
|
||||
if(fixOpen){
|
||||
var addPcm=new Int16Array(addTime*bufferSampleRate/1000);
|
||||
size+=addPcm.length;
|
||||
buffers.push(addPcm);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
var sizeOld=This.recSize,addSize=size;
|
||||
var bufferSize=sizeOld+addSize;
|
||||
This.recSize=bufferSize;//此值在onProcess后需要修正,可能新数据被修改
|
||||
|
||||
|
||||
//此类型有边录边转码(Worker)支持,开启实时转码
|
||||
if(engineCtx){
|
||||
//转换成set的采样率
|
||||
var chunkInfo=Recorder.SampleData(buffers,bufferSampleRate,set.sampleRate,engineCtx.chunkInfo);
|
||||
engineCtx.chunkInfo=chunkInfo;
|
||||
|
||||
sizeOld=engineCtx.pcmSize;
|
||||
addSize=chunkInfo.data.length;
|
||||
bufferSize=sizeOld+addSize;
|
||||
engineCtx.pcmSize=bufferSize;//此值在onProcess后需要修正,可能新数据被修改
|
||||
|
||||
buffers=engineCtx.pcmDatas;
|
||||
bufferFirstIdx=buffers.length;
|
||||
buffers.push(chunkInfo.data);
|
||||
bufferSampleRate=chunkInfo.sampleRate;
|
||||
};
|
||||
|
||||
var duration=Math.round(bufferSize/bufferSampleRate*1000);
|
||||
var bufferNextIdx=buffers.length;
|
||||
var bufferNextIdxThis=buffersThis.length;
|
||||
|
||||
//允许异步处理buffer数据
|
||||
var asyncEnd=function(){
|
||||
//重新计算size,异步的早已减去添加的,同步的需去掉本次添加的然后重新计算
|
||||
var num=asyncBegin?0:-addSize;
|
||||
var hasClear=buffers[0]==null;
|
||||
for(var i=bufferFirstIdx;i<bufferNextIdx;i++){
|
||||
var buffer=buffers[i];
|
||||
if(buffer==null){//已被主动释放内存,比如长时间实时传输录音时
|
||||
hasClear=1;
|
||||
}else{
|
||||
num+=buffer.length;
|
||||
|
||||
//推入后台边录边转码
|
||||
if(engineCtx&&buffer.length){
|
||||
This[set.type+"_encode"](engineCtx,buffer);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
//同步清理This.buffers,不管buffers到底清了多少个,buffersThis是使用不到的进行全清
|
||||
if(hasClear && engineCtx){
|
||||
var i=bufferFirstIdxThis;
|
||||
if(buffersThis[0]){
|
||||
i=0;
|
||||
};
|
||||
for(;i<bufferNextIdxThis;i++){
|
||||
buffersThis[i]=null;
|
||||
};
|
||||
};
|
||||
|
||||
//统计修改后的size,如果异步发生clear要原样加回来,同步的无需操作
|
||||
if(hasClear){
|
||||
num=asyncBegin?addSize:0;
|
||||
|
||||
buffers[0]=null;//彻底被清理
|
||||
};
|
||||
if(engineCtx){
|
||||
engineCtx.pcmSize+=num;
|
||||
}else{
|
||||
This.recSize+=num;
|
||||
};
|
||||
};
|
||||
//实时回调处理数据,允许修改或替换上次回调以来新增的数据 ,但是不允许修改已处理过的,不允许增删第一维数组 ,允许将第二维数组任意修改替换成空数组也可以
|
||||
var asyncBegin=set.onProcess(buffers,powerLevel,duration,bufferSampleRate,bufferFirstIdx,asyncEnd);
|
||||
|
||||
if(asyncBegin===true){
|
||||
//开启了异步模式,onProcess已接管buffers新数据,立即清空,避免出现未处理的数据
|
||||
var hasClear=0;
|
||||
for(var i=bufferFirstIdx;i<bufferNextIdx;i++){
|
||||
if(buffers[i]==null){//已被主动释放内存,比如长时间实时传输录音时 ,但又要开启异步模式,此种情况是非法的
|
||||
hasClear=1;
|
||||
}else{
|
||||
buffers[i]=new Int16Array(0);
|
||||
};
|
||||
};
|
||||
|
||||
if(hasClear){
|
||||
CLog("未进入异步前不能清除buffers",3);
|
||||
}else{
|
||||
//还原size,异步结束后再统计仅修改后的size,如果发生clear要原样加回来
|
||||
if(engineCtx){
|
||||
engineCtx.pcmSize-=addSize;
|
||||
}else{
|
||||
This.recSize-=addSize;
|
||||
};
|
||||
};
|
||||
}else{
|
||||
asyncEnd();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
//开始录音,需先调用open;只要open成功时,调用此方法是安全的,如果未open强行调用导致的内部错误将不会有任何提示,stop时自然能得到错误
|
||||
,start:function(){
|
||||
var This=this,ctx=Recorder.Ctx;
|
||||
|
||||
var isOpen=1;
|
||||
if(This.set.sourceStream){//直接提供了流,仅判断是否调用了open
|
||||
if(!This.Stream){
|
||||
isOpen=0;
|
||||
}
|
||||
}else if(!Recorder.IsOpen()){//监测全局麦克风是否打开并且有效
|
||||
isOpen=0;
|
||||
};
|
||||
if(!isOpen){
|
||||
CLog("未open",1);
|
||||
return;
|
||||
};
|
||||
CLog("开始录音");
|
||||
|
||||
This._stop();
|
||||
This.state=0;
|
||||
This.envStart(null,ctx.sampleRate);
|
||||
|
||||
//检查open过程中stop是否已经调用过
|
||||
if(This._SO&&This._SO+1!=This._S){//上面调用过一次 _stop
|
||||
//open未完成就调用了stop,此种情况终止start。也应尽量避免出现此情况
|
||||
CLog("start被中断",3);
|
||||
return;
|
||||
};
|
||||
This._SO=0;
|
||||
|
||||
var end=function(){
|
||||
This.state=1;
|
||||
This.resume();
|
||||
};
|
||||
if(ctx.state=="suspended"){
|
||||
ctx.resume().then(function(){
|
||||
CLog("ctx resume");
|
||||
end();
|
||||
});
|
||||
}else{
|
||||
end();
|
||||
};
|
||||
}
|
||||
/*暂停录音*/
|
||||
,pause:function(){
|
||||
var This=this;
|
||||
if(This.state){
|
||||
This.state=2;
|
||||
CLog("pause");
|
||||
delete This._streamStore().Stream._call[This.id];
|
||||
};
|
||||
}
|
||||
/*恢复录音*/
|
||||
,resume:function(){
|
||||
var This=this;
|
||||
if(This.state){
|
||||
This.state=1;
|
||||
CLog("resume");
|
||||
This.envResume();
|
||||
|
||||
This._streamStore().Stream._call[This.id]=function(pcm,sum){
|
||||
if(This.state==1){
|
||||
This.envIn(pcm,sum);
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
,_stop:function(keepEngine){
|
||||
var This=this,set=This.set;
|
||||
if(!This.isMock){
|
||||
This._S++;
|
||||
};
|
||||
if(This.state){
|
||||
This.pause();
|
||||
This.state=0;
|
||||
};
|
||||
if(!keepEngine && This[set.type+"_stop"]){
|
||||
This[set.type+"_stop"](This.engineCtx);
|
||||
This.engineCtx=0;
|
||||
};
|
||||
}
|
||||
/*
|
||||
结束录音并返回录音数据blob对象
|
||||
True(blob,duration) blob:录音数据audio/mp3|wav格式
|
||||
duration:录音时长,单位毫秒
|
||||
False(msg)
|
||||
autoClose:false 可选,是否自动调用close,默认为false
|
||||
*/
|
||||
,stop:function(True,False,autoClose){
|
||||
var This=this,set=This.set,t1;
|
||||
CLog("Stop "+(This.envInLast?This.envInLast-This.envInFirst+"ms 补"+This.envInFix+"ms":"-"));
|
||||
|
||||
var end=function(){
|
||||
This._stop();//彻底关掉engineCtx
|
||||
if(autoClose){
|
||||
This.close();
|
||||
};
|
||||
};
|
||||
var err=function(msg){
|
||||
CLog("结束录音失败:"+msg,1);
|
||||
False&&False(msg);
|
||||
end();
|
||||
};
|
||||
var ok=function(blob,duration){
|
||||
CLog("结束录音 编码"+(Date.now()-t1)+"ms 音频"+duration+"ms/"+blob.size+"b");
|
||||
if(set.takeoffEncodeChunk){//接管了输出,此时blob长度为0
|
||||
CLog("启用takeoffEncodeChunk后stop返回的blob长度为0不提供音频数据",3);
|
||||
}else if(blob.size<Math.max(100,duration/2)){//1秒小于0.5k?
|
||||
err("生成的"+set.type+"无效");
|
||||
return;
|
||||
};
|
||||
True&&True(blob,duration);
|
||||
end();
|
||||
};
|
||||
if(!This.isMock){
|
||||
if(!This.state){
|
||||
err("未开始录音");
|
||||
return;
|
||||
};
|
||||
This._stop(true);
|
||||
};
|
||||
var size=This.recSize;
|
||||
if(!size){
|
||||
err("未采集到录音");
|
||||
return;
|
||||
};
|
||||
if(!This.buffers[0]){
|
||||
err("音频被释放");
|
||||
return;
|
||||
};
|
||||
if(!This[set.type]){
|
||||
err("未加载"+set.type+"编码器");
|
||||
return;
|
||||
};
|
||||
|
||||
//环境配置检查,此处仅针对mock调用,因为open已经检查过了
|
||||
if(This.isMock){
|
||||
var checkMsg=This.envCheck(This.mockEnvInfo||{envName:"mock",canProcess:false});//没有提供环境信息的mock时没有onProcess回调
|
||||
if(checkMsg){
|
||||
err("录音错误:"+checkMsg);
|
||||
return;
|
||||
};
|
||||
};
|
||||
|
||||
//此类型有边录边转码(Worker)支持
|
||||
var engineCtx=This.engineCtx;
|
||||
if(This[set.type+"_complete"]&&engineCtx){
|
||||
var duration=Math.round(engineCtx.pcmSize/set.sampleRate*1000);//采用后的数据长度和buffers的长度可能微小的不一致,是采样率连续转换的精度问题
|
||||
|
||||
t1=Date.now();
|
||||
This[set.type+"_complete"](engineCtx,function(blob){
|
||||
ok(blob,duration);
|
||||
},err);
|
||||
return;
|
||||
};
|
||||
|
||||
//标准UI线程转码,调整采样率
|
||||
t1=Date.now();
|
||||
var chunk=Recorder.SampleData(This.buffers,This.srcSampleRate,set.sampleRate);
|
||||
|
||||
set.sampleRate=chunk.sampleRate;
|
||||
var res=chunk.data;
|
||||
var duration=Math.round(res.length/set.sampleRate*1000);
|
||||
|
||||
CLog("采样"+size+"->"+res.length+" 花:"+(Date.now()-t1)+"ms");
|
||||
|
||||
setTimeout(function(){
|
||||
t1=Date.now();
|
||||
This[set.type](res,function(blob){
|
||||
ok(blob,duration);
|
||||
},function(msg){
|
||||
err(msg);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
if(window.Recorder){
|
||||
window.Recorder.Destroy();
|
||||
};
|
||||
window.Recorder=Recorder;
|
||||
|
||||
//end ****copy源码结束*****
|
||||
Recorder.LM=LM;
|
||||
|
||||
//流量统计用1像素图片地址,设置为空将不参与统计
|
||||
Recorder.TrafficImgUrl="//ia.51.la/go1?id=20469973&pvFlag=1";
|
||||
Recorder.Traffic=function(){
|
||||
var imgUrl=Recorder.TrafficImgUrl;
|
||||
if(imgUrl){
|
||||
var data=Recorder.Traffic;
|
||||
var idf=location.href.replace(/#.*/,"");
|
||||
|
||||
if(imgUrl.indexOf("//")==0){
|
||||
//给url加上http前缀,如果是file协议下,不加前缀没法用
|
||||
if(/^https:/i.test(idf)){
|
||||
imgUrl="https:"+imgUrl;
|
||||
}else{
|
||||
imgUrl="http:"+imgUrl;
|
||||
};
|
||||
};
|
||||
|
||||
if(!data[idf]){
|
||||
data[idf]=1;
|
||||
|
||||
var img=new Image();
|
||||
img.src=imgUrl;
|
||||
CLog("Traffic Analysis Image: Recorder.TrafficImgUrl="+Recorder.TrafficImgUrl);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
}));
|
86
web/static/js/wav.js
Normal file
86
web/static/js/wav.js
Normal file
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
wav编码器+编码引擎
|
||||
https://github.com/xiangyuecn/Recorder
|
||||
|
||||
当然最佳推荐使用mp3、wav格式,代码也是优先照顾这两种格式
|
||||
浏览器支持情况
|
||||
https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats
|
||||
|
||||
编码原理:给pcm数据加上一个44直接的wav头即成wav文件;pcm数据就是Recorder中的buffers原始数据(重新采样),16位时为LE小端模式(Little Endian),实质上是未经过任何编码处理
|
||||
*/
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
Recorder.prototype.enc_wav={
|
||||
stable:true
|
||||
,testmsg:"支持位数8位、16位(填在比特率里面),采样率取值无限制"
|
||||
};
|
||||
Recorder.prototype.wav=function(res,True,False){
|
||||
var This=this,set=This.set
|
||||
,size=res.length
|
||||
,sampleRate=set.sampleRate
|
||||
,bitRate=set.bitRate==8?8:16;
|
||||
|
||||
//编码数据 https://github.com/mattdiamond/Recorderjs https://www.cnblogs.com/blqw/p/3782420.html https://www.cnblogs.com/xiaoqi/p/6993912.html
|
||||
var dataLength=size*(bitRate/8);
|
||||
var buffer=new ArrayBuffer(44+dataLength);
|
||||
var data=new DataView(buffer);
|
||||
|
||||
var offset=0;
|
||||
var writeString=function(str){
|
||||
for (var i=0;i<str.length;i++,offset++) {
|
||||
data.setUint8(offset,str.charCodeAt(i));
|
||||
};
|
||||
};
|
||||
var write16=function(v){
|
||||
data.setUint16(offset,v,true);
|
||||
offset+=2;
|
||||
};
|
||||
var write32=function(v){
|
||||
data.setUint32(offset,v,true);
|
||||
offset+=4;
|
||||
};
|
||||
|
||||
/* RIFF identifier */
|
||||
writeString('RIFF');
|
||||
/* RIFF chunk length */
|
||||
write32(36+dataLength);
|
||||
/* RIFF type */
|
||||
writeString('WAVE');
|
||||
/* format chunk identifier */
|
||||
writeString('fmt ');
|
||||
/* format chunk length */
|
||||
write32(16);
|
||||
/* sample format (raw) */
|
||||
write16(1);
|
||||
/* channel count */
|
||||
write16(1);
|
||||
/* sample rate */
|
||||
write32(sampleRate);
|
||||
/* byte rate (sample rate * block align) */
|
||||
write32(sampleRate*(bitRate/8));
|
||||
/* block align (channel count * bytes per sample) */
|
||||
write16(bitRate/8);
|
||||
/* bits per sample */
|
||||
write16(bitRate);
|
||||
/* data chunk identifier */
|
||||
writeString('data');
|
||||
/* data chunk length */
|
||||
write32(dataLength);
|
||||
// 写入采样数据
|
||||
if(bitRate==8) {
|
||||
for(var i=0;i<size;i++,offset++) {
|
||||
//16转8据说是雷霄骅的 https://blog.csdn.net/sevennight1989/article/details/85376149 细节比blqw的按比例的算法清晰点,虽然都有明显杂音
|
||||
var val=(res[i]>>8)+128;
|
||||
data.setInt8(offset,val,true);
|
||||
};
|
||||
}else{
|
||||
for (var i=0;i<size;i++,offset+=2){
|
||||
data.setInt16(offset,res[i],true);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
True(new Blob([data.buffer],{type:"audio/wav"}));
|
||||
}
|
||||
})();
|
374
web/templates/index.html
Normal file
374
web/templates/index.html
Normal file
|
@ -0,0 +1,374 @@
|
|||
<!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>MockingBird Web Server</title>
|
||||
|
||||
<script src="{{ url_for('static',filename='js/recorder-core.js') }}"></script>
|
||||
<script src="{{ url_for('static',filename='js/mp3.js') }}"></script>
|
||||
<script src="{{ url_for('static',filename='js/wav.js') }}"></script>
|
||||
<script src="{{ url_for('static',filename='js/mp3-engine.js') }}"></script>
|
||||
<script src="{{ url_for('static',filename='js/frequency.histogram.view.js') }}"></script>
|
||||
<script src="{{ url_for('static',filename='js/lib.fft.js') }}"></script>
|
||||
|
||||
<script src="{{ url_for('static',filename='js/jquery.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<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()" style="margin-left:100px">录制</button>
|
||||
<button onclick="recStop()" style="margin-left:100px">停止</button>
|
||||
<button onclick="recPlay()" style="margin-left:100px">播放</button>
|
||||
</div>
|
||||
|
||||
<!-- 波形绘制区域 -->
|
||||
<div class="pd recpower">
|
||||
<div style="height:40px;width:100%;background:#fff;position:relative;">
|
||||
<div class="recpowerx" style="height:40px;background:#ff3295;position:absolute;"></div>
|
||||
<div class="recpowert" style="padding-left:50px; line-height:40px; position: relative;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pd waveBox" style="height:100px;">
|
||||
<div style="border:1px solid #ccc;display:inline-block; width: 100%; height: 100px;">
|
||||
<div style="height:100px; width: 100%; background-color: #FE76B8; position: relative;left: 0px;top: 0px;z-index: 10;"
|
||||
class="recwave"></div>
|
||||
<div
|
||||
style="background-color: transparent;position: relative;top: -80px;left: 30%;z-index: 20;font-size: 48px;color: #fff;">
|
||||
音频预览</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>请输入文本:</div>
|
||||
<input type="text" id="user_input_text"
|
||||
style="border:1px solid #ccc; width: 100%; height: 20px; font-size: 18px;" />
|
||||
</div>
|
||||
<div class="pd btns">
|
||||
<button onclick="recUpload()" style="margin-left: 300px; margin-top: 15px;">上传</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志输出区域 -->
|
||||
<div class="mainBox">
|
||||
<div class="reclog"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
var rec, wave, recBlob;
|
||||
/**调用open打开录音请求好录音权限**/
|
||||
var recOpen = function () {//一般在显示出录音按钮或相关的录音界面时进行此方法调用,后面用户点击开始录音时就能畅通无阻了
|
||||
rec = null;
|
||||
wave = null;
|
||||
recBlob = null;
|
||||
var newRec = Recorder({
|
||||
type: "wav", 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);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
recOpen();
|
||||
};
|
||||
};
|
||||
|
||||
function recStop() {
|
||||
rec.stop(function (blob, duration) {
|
||||
rec.close();//释放录音资源
|
||||
console.log(blob, (window.URL || webkitURL).createObjectURL(blob), "时长:" + duration + "ms");
|
||||
recBlob = blob;
|
||||
reclog("已录制wav:" + 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 playResult(resultBlob) {
|
||||
if (!resultBlob) {
|
||||
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(resultBlob);
|
||||
audio.play();
|
||||
|
||||
setTimeout(function () {
|
||||
(window.URL || webkitURL).revokeObjectURL(audio.src);
|
||||
}, 12000);
|
||||
};
|
||||
|
||||
/**上传**/
|
||||
function recUpload() {
|
||||
var blob = recBlob;
|
||||
if (!blob) {
|
||||
reclog("请先录音,然后停止后再上传", 1);
|
||||
return;
|
||||
};
|
||||
|
||||
//本例子假设使用原始XMLHttpRequest请求方式,实际使用中自行调整为自己的请求方式
|
||||
//录音结束时拿到了blob文件对象,可以用FileReader读取出内容,或者用FormData上传
|
||||
var api = "http://127.0.0.1:8080/api/synthesize";
|
||||
|
||||
reclog("开始上传到" + api + ",请求稍后...");
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onloadend = function () {
|
||||
var csrftoken = "{{ csrf_token() }}";
|
||||
var user_input_text = document.getElementById("user_input_text");
|
||||
var input_text = user_input_text.value;
|
||||
var postData = "";
|
||||
postData += "mime=" + encodeURIComponent(blob.type);//告诉后端,这个录音是什么格式的,可能前后端都固定的mp3可以不用写
|
||||
postData += "&upfile_b64=" + encodeURIComponent((/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result) || [])[1]) //录音文件内容,后端进行base64解码成二进制
|
||||
postData += "&text=" + encodeURIComponent(input_text);
|
||||
|
||||
fetch(api, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"X-CSRFToken": csrftoken
|
||||
},
|
||||
body: postData
|
||||
}).then(function (res) {
|
||||
if (!res.ok) throw Error(res.statusText);
|
||||
return res.blob();
|
||||
}).then(function (blob) {
|
||||
playResult(blob)
|
||||
}).catch(function (err) {
|
||||
console.log('Error: ' + err.message);
|
||||
})
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
};
|
||||
|
||||
//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 ? "#FE76B8" : color) + '">[' + t + ']' + s + '</div>';
|
||||
};
|
||||
window.onerror = function (message, url, lineNo, columnNo, error) {
|
||||
reclog('<span style="color:red">【Uncaught Error】' + message + '<pre>' + "at:" + lineNo + ":" + columnNo + " url:" + url + "\n" + (error && error.stack || "不能获得错误堆栈") + '</pre></span>');
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
if (/mobile/i.test(navigator.userAgent)) {
|
||||
//移动端加载控制台组件
|
||||
var elem = document.createElement("script");
|
||||
elem.setAttribute("type", "text/javascript");
|
||||
|
||||
elem.setAttribute("src", "{{ url_for('static',filename='js/eruda.min.js') }}");
|
||||
document.body.appendChild(elem);
|
||||
elem.onload = function () {
|
||||
eruda.init();
|
||||
};
|
||||
};
|
||||
</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: #FE76B8;
|
||||
}
|
||||
|
||||
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: #FE76B8;
|
||||
color: #fff;
|
||||
padding: 0 15px;
|
||||
margin: 3px 20px 3px 0;
|
||||
line-height: 36px;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.btns button:active {
|
||||
background: #fd54a6
|
||||
}
|
||||
|
||||
.pd {
|
||||
padding: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.lb {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
background: #ff3d9b;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
</style>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user