mirror of
https://github.com/kuaifan/dootask.git
synced 2026-04-20 11:28:07 +00:00
feat: 支持发送录音
This commit is contained in:
parent
4dd8658aff
commit
44ff21ffcd
@ -263,6 +263,50 @@ class DialogController extends AbstractController
|
||||
return Base::retSuccess('发送成功', $list);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/dialog/msg/sendrecord 07. 发送语音
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__sendrecord
|
||||
*
|
||||
* @apiParam {Number} dialog_id 对话ID
|
||||
* @apiParam {String} base64 语音base64
|
||||
* @apiParam {Number} duration 语音时长(毫秒)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__sendrecord()
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$dialog_id = Base::getPostInt('dialog_id');
|
||||
//
|
||||
WebSocketDialog::checkDialog($dialog_id);
|
||||
//
|
||||
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
|
||||
$base64 = Base::getPostValue('base64');
|
||||
$duration = Base::getPostInt('duration');
|
||||
if ($duration < 600) {
|
||||
return Base::retError('说话时间太短');
|
||||
}
|
||||
$data = Base::record64save([
|
||||
"base64" => $base64,
|
||||
"path" => $path,
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
return Base::retError($data['msg']);
|
||||
} else {
|
||||
$recordData = $data['data'];
|
||||
$recordData['size'] *= 1024;
|
||||
$recordData['duration'] = $duration;
|
||||
return WebSocketDialogMsg::sendMsg($dialog_id, 'record', $recordData, $user->userid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/dialog/msg/sendfile 07. 文件上传
|
||||
*
|
||||
|
||||
@ -36,6 +36,9 @@ class VerifyCsrfToken extends Middleware
|
||||
// 聊天发文本
|
||||
'api/dialog/msg/sendtext/',
|
||||
|
||||
// 聊天发语音
|
||||
'api/dialog/msg/sendrecord/',
|
||||
|
||||
// 聊天发文件
|
||||
'api/dialog/msg/sendfile/',
|
||||
|
||||
|
||||
@ -92,6 +92,8 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
$value['type'] = in_array($value['ext'], ['jpg', 'jpeg', 'png', 'gif']) ? 'img' : 'file';
|
||||
$value['path'] = Base::fillUrl($value['path']);
|
||||
$value['thumb'] = Base::fillUrl($value['thumb'] ?: Base::extIcon($value['ext']));
|
||||
} else if ($this->type === 'record') {
|
||||
$value['path'] = Base::fillUrl($value['path']);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
@ -210,6 +212,8 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
switch ($this->type) {
|
||||
case 'text':
|
||||
return $this->previewTextMsg($this->msg['text'], $preserveHtml);
|
||||
case 'record':
|
||||
return "[语音]";
|
||||
case 'file':
|
||||
if ($this->msg['type'] == 'img') {
|
||||
return "[图片]";
|
||||
|
||||
@ -2174,6 +2174,39 @@ class Base
|
||||
return Min(Max(Base::nullShow(Request::input($inputName), $default), 1), $max);
|
||||
}
|
||||
|
||||
/**
|
||||
* base64语音保存
|
||||
* @param array $param [ base64=带前缀的base64, path=>文件路径 ]
|
||||
* @return array [name=>文件名, size=>文件大小(单位KB),file=>绝对地址, path=>相对地址, url=>全路径地址, ext=>文件后缀名]
|
||||
*/
|
||||
public static function record64save($param)
|
||||
{
|
||||
$base64 = $param['base64'];
|
||||
if (preg_match('/^(data:\s*audio\/(\w+);base64,)/', $base64, $res)) {
|
||||
$extension = $res[2];
|
||||
if (!in_array($extension, ['mp3', 'wav'])) {
|
||||
return Base::retError('语音格式错误');
|
||||
}
|
||||
$fileName = 'record_' . md5($base64) . '.' . $extension;
|
||||
$fileDir = $param['path'];
|
||||
$filePath = public_path($fileDir);
|
||||
Base::makeDir($filePath);
|
||||
if (file_put_contents($filePath . $fileName, base64_decode(str_replace($res[1], '', $base64)))) {
|
||||
$fileSize = filesize($filePath . $fileName);
|
||||
$array = [
|
||||
"name" => $fileName, //原文件名
|
||||
"size" => Base::twoFloat($fileSize / 1024, true), //大小KB
|
||||
"file" => $filePath . $fileName, //文件的完整路径 "D:\www....KzZ.jpg"
|
||||
"path" => $fileDir . $fileName, //相对路径 "uploads/pic....KzZ.jpg"
|
||||
"url" => Base::fillUrl($fileDir . $fileName), //完整的URL "https://.....hhsKzZ.jpg"
|
||||
"ext" => $extension, //文件后缀名
|
||||
];
|
||||
return Base::retSuccess('success', $array);
|
||||
}
|
||||
}
|
||||
return Base::retError('语音保存失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* image64图片保存
|
||||
* @param array $param [ image64=带前缀的base64, path=>文件路径, fileName=>文件名称, scale=>[压缩原图宽,高, 压缩方式] ]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
6
public/js/recorder.mp3.min.js
vendored
Normal file
6
public/js/recorder.mp3.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
338
public/js/recorder/frequency.histogram.view.js
vendored
Normal file
338
public/js/recorder/frequency.histogram.view.js
vendored
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;
|
||||
|
||||
|
||||
})();
|
||||
111
public/js/recorder/lib.fft.js
vendored
Normal file
111
public/js/recorder/lib.fft.js
vendored
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};
|
||||
};
|
||||
6
public/js/recorder/recorder.mp3.min.js
vendored
Normal file
6
public/js/recorder/recorder.mp3.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -9,6 +9,7 @@
|
||||
<RightBottom/>
|
||||
<NetworkException/>
|
||||
<PreviewImageState/>
|
||||
<AudioManager/>
|
||||
<iframe v-if="manifestUrl" v-show="false" :src="manifestUrl"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
@ -19,9 +20,10 @@ import RightBottom from "./components/RightBottom";
|
||||
import PreviewImageState from "./components/PreviewImage/state";
|
||||
import {mapState} from "vuex";
|
||||
import NetworkException from "./components/NetworkException";
|
||||
import AudioManager from "./components/AudioManager";
|
||||
|
||||
export default {
|
||||
components: {NetworkException, PreviewImageState, RightBottom, Spinner},
|
||||
components: {AudioManager, NetworkException, PreviewImageState, RightBottom, Spinner},
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
||||
93
resources/assets/js/components/AudioManager.vue
Normal file
93
resources/assets/js/components/AudioManager.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<audio
|
||||
ref="audio"
|
||||
class="common-audio"
|
||||
@ended="overAudio"
|
||||
@pause="onPause"
|
||||
@play="onPlay"></audio>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.common-audio {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
import {Store} from "le5le-store";
|
||||
|
||||
export default {
|
||||
name: 'AudioManager',
|
||||
data() {
|
||||
return {
|
||||
audioSubscribe: null,
|
||||
audioPlay: false,
|
||||
audioSrc: null,
|
||||
callback: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.audioSubscribe = Store.subscribe('audioSubscribe', this.setAudioPlay);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.audioSubscribe) {
|
||||
this.audioSubscribe.unsubscribe();
|
||||
this.audioSubscribe = null;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audioPlay(play) {
|
||||
if (typeof this.callback === "function") {
|
||||
this.callback(play)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setAudioPlay(info) {
|
||||
const audio = this.$refs.audio;
|
||||
const ended = audio.ended || audio.paused;
|
||||
audio.controls = false;
|
||||
audio.loop = false;
|
||||
if (info === false) {
|
||||
if (!ended) {
|
||||
audio.pause()
|
||||
}
|
||||
return
|
||||
}
|
||||
const {src, callback} = info
|
||||
this.callback = callback || null;
|
||||
if (src === this.audioSrc) {
|
||||
if (ended) {
|
||||
audio.play()
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
} else {
|
||||
this.audioSrc = src;
|
||||
if (!ended) {
|
||||
audio.pause()
|
||||
}
|
||||
audio.src = src
|
||||
audio.play()
|
||||
}
|
||||
},
|
||||
|
||||
overAudio() {
|
||||
this.audioPlay = false;
|
||||
},
|
||||
|
||||
onPause() {
|
||||
this.audioPlay = false;
|
||||
},
|
||||
|
||||
onPlay() {
|
||||
this.audioPlay = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
46
resources/assets/js/directives/longpress.js
vendored
46
resources/assets/js/directives/longpress.js
vendored
@ -1,46 +0,0 @@
|
||||
const isSupportTouch = "ontouchend" in document;
|
||||
export default {
|
||||
bind (el, binding) {
|
||||
if (!isSupportTouch) {
|
||||
return
|
||||
}
|
||||
let timer = 0;
|
||||
el.__touchLongpressDown__ = e => {
|
||||
timer = setTimeout(_ => {
|
||||
timer = 0
|
||||
if (binding.expression) {
|
||||
binding.value(e, el)
|
||||
}
|
||||
}, 600)
|
||||
};
|
||||
el.__touchLongpressMove__ = _ => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = 0
|
||||
}
|
||||
};
|
||||
el.__touchLongpressUp__ = _ => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = 0
|
||||
}
|
||||
};
|
||||
el.addEventListener('touchstart', el.__touchLongpressDown__);
|
||||
el.addEventListener('touchmove', el.__touchLongpressMove__);
|
||||
el.addEventListener('touchend', el.__touchLongpressUp__);
|
||||
},
|
||||
update () {
|
||||
|
||||
},
|
||||
unbind (el) {
|
||||
if (!isSupportTouch) {
|
||||
return
|
||||
}
|
||||
el.removeEventListener('touchstart', el.__touchLongpressDown__);
|
||||
el.removeEventListener('touchmove', el.__touchLongpressMove__);
|
||||
el.removeEventListener('touchend', el.__touchLongpressUp__);
|
||||
delete el.__touchLongpressDown__;
|
||||
delete el.__touchLongpressMove__;
|
||||
delete el.__touchLongpressUp__;
|
||||
}
|
||||
};
|
||||
29
resources/assets/js/directives/touchmouse.js
vendored
29
resources/assets/js/directives/touchmouse.js
vendored
@ -1,33 +1,34 @@
|
||||
const isSupportTouch = "ontouchend" in document;
|
||||
export default {
|
||||
bind (el, binding) {
|
||||
let isMove = false;
|
||||
let isTouch = false;
|
||||
el.__touchMouseDown__ = e => {
|
||||
isMove = false;
|
||||
e.preventDefault();
|
||||
isTouch = true;
|
||||
binding.value("down", e);
|
||||
};
|
||||
el.__touchMouseMove__ = _ => {
|
||||
isMove = true;
|
||||
};
|
||||
el.__touchMouseUp__ = e => {
|
||||
if (isMove) {
|
||||
return;
|
||||
el.__touchMouseMove__ = e => {
|
||||
if (isTouch) {
|
||||
binding.value("move", e);
|
||||
}
|
||||
if (binding.expression) {
|
||||
binding.value(e);
|
||||
};
|
||||
el.__touchMouseUp__ = _ => {
|
||||
if (isTouch) {
|
||||
isTouch = false;
|
||||
binding.value("up");
|
||||
}
|
||||
};
|
||||
el.addEventListener(isSupportTouch ? 'touchstart' : 'mousedown', el.__touchMouseDown__);
|
||||
el.addEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
|
||||
el.addEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
|
||||
document.addEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
|
||||
document.addEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
|
||||
},
|
||||
update () {
|
||||
|
||||
},
|
||||
unbind (el) {
|
||||
el.removeEventListener(isSupportTouch ? 'touchstart' : 'mousedown', el.__touchMouseDown__);
|
||||
el.removeEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
|
||||
el.removeEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
|
||||
document.removeEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
|
||||
document.removeEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
|
||||
delete el.__touchMouseDown__;
|
||||
delete el.__touchMouseMove__;
|
||||
delete el.__touchMouseUp__;
|
||||
|
||||
@ -1,63 +1,104 @@
|
||||
<template>
|
||||
<div class="chat-input-box">
|
||||
<div class="chat-input-wrapper" :class="modeClass" @click.stop="focus">
|
||||
<div ref="editor" class="no-dark-content" :style="editorStyle" @click.stop="" @paste="handlePaste"></div>
|
||||
<div class="chat-input-toolbar" @click.stop="">
|
||||
<slot name="toolbarBefore"/>
|
||||
<div class="chat-input-box" :class="boxClass">
|
||||
<div class="chat-input-wrapper" @click.stop="focus">
|
||||
<!-- 输入框 -->
|
||||
<div
|
||||
ref="editor"
|
||||
class="no-dark-content"
|
||||
:style="editorStyle"
|
||||
@click.stop=""
|
||||
@paste="handlePaste"></div>
|
||||
|
||||
<EPopover
|
||||
v-if="!emojiBottom"
|
||||
v-model="showEmoji"
|
||||
:visibleArrow="false"
|
||||
placement="top"
|
||||
popperClass="chat-input-emoji-popover">
|
||||
<ETooltip slot="reference" ref="emojiTip" :disabled="!$isDesktop || showEmoji" placement="top" :content="$L('表情')">
|
||||
<i class="taskfont"></i>
|
||||
<!-- 工具栏 -->
|
||||
<ul class="chat-toolbar" @click.stop="">
|
||||
<!-- 桌面端表情(漂浮) -->
|
||||
<li>
|
||||
<EPopover
|
||||
v-if="!emojiBottom"
|
||||
v-model="showEmoji"
|
||||
:visibleArrow="false"
|
||||
placement="top"
|
||||
popperClass="chat-input-emoji-popover">
|
||||
<ETooltip slot="reference" ref="emojiTip" :disabled="!$isDesktop || showEmoji" placement="top" :content="$L('表情')">
|
||||
<i class="taskfont"></i>
|
||||
</ETooltip>
|
||||
<ChatEmoji @on-select="onSelectEmoji"/>
|
||||
</EPopover>
|
||||
<ETooltip v-else ref="emojiTip" :disabled="!$isDesktop || showEmoji" placement="top" :content="$L('表情')">
|
||||
<i class="taskfont" @click="showEmoji=!showEmoji"></i>
|
||||
</ETooltip>
|
||||
<ChatEmoji @on-select="onSelectEmoji"/>
|
||||
</EPopover>
|
||||
<ETooltip v-else ref="emojiTip" :disabled="!$isDesktop || showEmoji" placement="top" :content="$L('表情')">
|
||||
<i class="taskfont" @click="showEmoji=!showEmoji"></i>
|
||||
</ETooltip>
|
||||
</li>
|
||||
|
||||
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择会员')">
|
||||
<i class="taskfont" @click="onToolbar('user')"></i>
|
||||
</ETooltip>
|
||||
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择任务')">
|
||||
<i class="taskfont" @click="onToolbar('task')"></i>
|
||||
</ETooltip>
|
||||
|
||||
<EPopover
|
||||
v-model="showMore"
|
||||
:visibleArrow="false"
|
||||
placement="top"
|
||||
popperClass="chat-input-more-popover">
|
||||
<ETooltip slot="reference" ref="moreTip" :disabled="!$isDesktop || showMore" placement="top" :content="$L('展开')">
|
||||
<i class="taskfont"></i>
|
||||
<!-- @ # -->
|
||||
<li>
|
||||
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择会员')">
|
||||
<i class="taskfont" @click="onToolbar('user')"></i>
|
||||
</ETooltip>
|
||||
<div class="chat-input-popover-item" @click="onToolbar('image')">
|
||||
<i class="taskfont"></i>
|
||||
{{$L('图片')}}
|
||||
</div>
|
||||
<div class="chat-input-popover-item" @click="onToolbar('file')">
|
||||
<i class="taskfont"></i>
|
||||
{{$L('文件')}}
|
||||
</div>
|
||||
</EPopover>
|
||||
|
||||
<div class="chat-send" :class="[value ? '' : 'disabled']" v-touchmouse="send">
|
||||
<Loading v-if="loading"/>
|
||||
<ETooltip v-else placement="top" :disabled="!$isDesktop" :content="$L('发送')">
|
||||
<Icon type="md-send"/>
|
||||
</li>
|
||||
<li>
|
||||
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择任务')">
|
||||
<i class="taskfont" @click="onToolbar('task')"></i>
|
||||
</ETooltip>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<slot name="toolbarAfter"/>
|
||||
</div>
|
||||
<!-- 图片文件 -->
|
||||
<li>
|
||||
<EPopover
|
||||
v-model="showMore"
|
||||
:visibleArrow="false"
|
||||
placement="top"
|
||||
popperClass="chat-input-more-popover">
|
||||
<ETooltip slot="reference" ref="moreTip" :disabled="!$isDesktop || showMore" placement="top" :content="$L('展开')">
|
||||
<i class="taskfont"></i>
|
||||
</ETooltip>
|
||||
<div class="chat-input-popover-item" @click="onToolbar('image')">
|
||||
<i class="taskfont"></i>
|
||||
{{$L('图片')}}
|
||||
</div>
|
||||
<div class="chat-input-popover-item" @click="onToolbar('file')">
|
||||
<i class="taskfont"></i>
|
||||
{{$L('文件')}}
|
||||
</div>
|
||||
</EPopover>
|
||||
</li>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<li class="chat-send" :class="sendClass" v-touchmouse="clickSend">
|
||||
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('发送')">
|
||||
<div>
|
||||
<transition name="mobile-send">
|
||||
<i v-if="sendClass === 'recorder'" class="taskfont"></i>
|
||||
</transition>
|
||||
<transition name="mobile-send">
|
||||
<i v-if="sendClass !== 'recorder'" class="taskfont"></i>
|
||||
</transition>
|
||||
</div>
|
||||
</ETooltip>
|
||||
</li>
|
||||
|
||||
<!-- 录音效果 -->
|
||||
<li v-if="recordReady" class="chat-record-recwave">
|
||||
<div ref="recwave"></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<template v-if="emojiBottom">
|
||||
<ChatEmoji v-if="showEmoji" @on-select="onSelectEmoji"/>
|
||||
</template>
|
||||
|
||||
<!-- 移动端表情(底部) -->
|
||||
<ChatEmoji v-if="emojiBottom && showEmoji" @on-select="onSelectEmoji"/>
|
||||
|
||||
<!-- 录音取消 -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="recordState === 'ing'"
|
||||
v-transfer-dom
|
||||
:data-transfer="true"
|
||||
class="chat-input-record-transfer"
|
||||
:class="{cancel: touchLimitY}"
|
||||
@click="stopRecord">
|
||||
<div class="record-duration">{{recordFormatDuration}}</div>
|
||||
<div class="record-cancel" @click.stop="stopRecord(true)">{{$L(touchLimitY ? '松开取消' : '向上滑动取消')}}</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -67,11 +108,12 @@ import Quill from 'quill';
|
||||
import "quill-mention";
|
||||
import ChatEmoji from "./emoji";
|
||||
import touchmouse from "../../../../directives/touchmouse";
|
||||
import TransferDom from "../../../../directives/transfer-dom";
|
||||
|
||||
export default {
|
||||
name: 'ChatInput',
|
||||
components: {ChatEmoji},
|
||||
directives: {touchmouse},
|
||||
directives: {touchmouse, TransferDom},
|
||||
props: {
|
||||
dialogId: {
|
||||
type: Number,
|
||||
@ -126,7 +168,7 @@ export default {
|
||||
_content: '',
|
||||
_options: {},
|
||||
|
||||
modeClass: '',
|
||||
mentionMode: '',
|
||||
|
||||
userList: null,
|
||||
taskList: null,
|
||||
@ -138,6 +180,17 @@ export default {
|
||||
wrapperWidth: 0,
|
||||
editorHeight: 0,
|
||||
|
||||
recordReady: false,
|
||||
recordRec: null,
|
||||
recordState: "stop",
|
||||
recordBlob: null,
|
||||
recordWave: null,
|
||||
recordDuration: 0,
|
||||
|
||||
touchStart: {},
|
||||
touchLimitX: false,
|
||||
touchLimitY: false,
|
||||
|
||||
isSpecVersion: this.checkIOSVersion(),
|
||||
};
|
||||
},
|
||||
@ -179,6 +232,37 @@ export default {
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
boxClass() {
|
||||
const array = [];
|
||||
if (this.recordState === 'ing') {
|
||||
array.push('record-ing');
|
||||
}
|
||||
if (this.mentionMode) {
|
||||
array.push(this.mentionMode);
|
||||
}
|
||||
return array
|
||||
},
|
||||
|
||||
sendClass() {
|
||||
if (this.value) {
|
||||
return 'sender';
|
||||
}
|
||||
if (this.recordReady) {
|
||||
return 'recorder'
|
||||
}
|
||||
return ''
|
||||
},
|
||||
|
||||
recordFormatDuration() {
|
||||
const {recordDuration} = this;
|
||||
let minute = Math.floor(recordDuration / 60000),
|
||||
seconds = Math.floor(recordDuration / 1000) % 60,
|
||||
millisecond = ("00" + recordDuration % 1000).substr(-2)
|
||||
if (minute < 10) minute = `0${minute}`
|
||||
if (seconds < 10) seconds = `0${seconds}`
|
||||
return `${minute}:${seconds}″${millisecond}`
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -257,7 +341,7 @@ export default {
|
||||
shortKey: true,
|
||||
handler: _ => {
|
||||
if (!this.enterSend) {
|
||||
this.send();
|
||||
this.onSend();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@ -268,7 +352,7 @@ export default {
|
||||
shiftKey: false,
|
||||
handler: _ => {
|
||||
if (this.enterSend) {
|
||||
this.send();
|
||||
this.onSend();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@ -390,6 +474,27 @@ export default {
|
||||
return delta
|
||||
})
|
||||
|
||||
// Load recorder
|
||||
$A.loadScriptS([
|
||||
'js/recorder/recorder.mp3.min.js',
|
||||
'js/recorder/lib.fft.js',
|
||||
'js/recorder/frequency.histogram.view.js',
|
||||
], (e) => {
|
||||
if (e !== null || typeof window.Recorder !== 'function') {
|
||||
return;
|
||||
}
|
||||
this.recordRec = window.Recorder({
|
||||
type: "mp3",
|
||||
bitRate: 16,
|
||||
sampleRate: 16000,
|
||||
onProcess: (buffers, powerLevel, duration, sampleRate, newBufferIdx, asyncEnd) => {
|
||||
this.recordWave.input(buffers[buffers.length - 1], powerLevel, sampleRate);
|
||||
this.recordDuration = duration;
|
||||
}
|
||||
})
|
||||
this.recordReady = true;
|
||||
});
|
||||
|
||||
// Ready event
|
||||
this.$emit('on-ready', this.quill)
|
||||
},
|
||||
@ -439,18 +544,122 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
send() {
|
||||
clickSend(action, event) {
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
switch (action) {
|
||||
case 'down':
|
||||
this.touchLimitX = false;
|
||||
this.touchLimitY = false;
|
||||
this.touchStart = event.type === "touchstart" ? event.touches[0] : event;
|
||||
if (this.startRecord()) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'move':
|
||||
const touchMove = event.type === "touchmove" ? event.touches[0] : event;
|
||||
this.touchLimitX = (this.touchStart.clientX - touchMove.clientX) / window.innerWidth > 0.1
|
||||
this.touchLimitY = (this.touchStart.clientY - touchMove.clientY) / window.innerHeight > 0.1
|
||||
break;
|
||||
|
||||
case 'up':
|
||||
if (this.stopRecord(this.touchLimitY)) {
|
||||
return;
|
||||
}
|
||||
if (this.touchLimitY || this.touchLimitX) {
|
||||
return; // 移动了 X、Y 轴
|
||||
}
|
||||
this.onSend()
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
onSend() {
|
||||
this.rangeIndex = 0
|
||||
this.$emit('on-send')
|
||||
},
|
||||
|
||||
startRecord() {
|
||||
if (this.sendClass === 'recorder') {
|
||||
this.recordState = "ready";
|
||||
this.recordRec.open(_ => {
|
||||
if (this.recordState === "ready") {
|
||||
this.recordState = "ing"
|
||||
this.recordBlob = null
|
||||
this.$nextTick(_ => {
|
||||
this.$refs.recwave.innerHTML = "";
|
||||
this.recordWave = window.Recorder.FrequencyHistogramView({
|
||||
elem: this.$refs.recwave,
|
||||
lineCount: 90,
|
||||
position: 0,
|
||||
minHeight: 1,
|
||||
stripeEnable: false
|
||||
})
|
||||
this.recordRec.start()
|
||||
})
|
||||
} else {
|
||||
this.recordRec.close();
|
||||
}
|
||||
}, (msg) => {
|
||||
$A.modalError(msg || '打开录音失败')
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
stopRecord(isCancel) {
|
||||
if (this.recordState === "ing") {
|
||||
this.recordState = "stop";
|
||||
this.recordRec.stop((blob, duration) => {
|
||||
this.recordRec.close();
|
||||
if (isCancel === true) {
|
||||
return;
|
||||
}
|
||||
if (duration < 600) {
|
||||
// 小于 600ms 不发送
|
||||
$A.messageWarning("说话时间太短")
|
||||
} else {
|
||||
this.recordBlob = blob;
|
||||
this.uploadRecord(duration);
|
||||
}
|
||||
}, (msg) => {
|
||||
this.recordRec.close();
|
||||
$A.modalError("录音失败: " + msg);
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
this.recordState = "stop";
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
hidePopover() {
|
||||
this.showEmoji = false;
|
||||
this.showMore = false;
|
||||
},
|
||||
|
||||
uploadRecord(duration) {
|
||||
if (this.recordBlob === null) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
this.$emit('on-send', {
|
||||
type: 'record',
|
||||
data: {
|
||||
type: this.recordBlob.type,
|
||||
base64: reader.result,
|
||||
duration,
|
||||
}
|
||||
})
|
||||
};
|
||||
reader.readAsDataURL(this.recordBlob);
|
||||
},
|
||||
|
||||
onSelectEmoji(item) {
|
||||
if (!this.quill) {
|
||||
return;
|
||||
@ -533,7 +742,7 @@ export default {
|
||||
return new Promise(resolve => {
|
||||
switch (mentionChar) {
|
||||
case "@": // @成员
|
||||
this.modeClass = "user-mention";
|
||||
this.mentionMode = "user-mention";
|
||||
if (this.userList !== null) {
|
||||
resolve(this.userList)
|
||||
return;
|
||||
@ -603,7 +812,7 @@ export default {
|
||||
break;
|
||||
|
||||
case "#": // #任务
|
||||
this.modeClass = "task-mention";
|
||||
this.mentionMode = "task-mention";
|
||||
if (this.taskList !== null) {
|
||||
resolve(this.taskList)
|
||||
return;
|
||||
|
||||
@ -25,6 +25,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--录音-->
|
||||
<div v-else-if="msgData.type === 'record'" class="content-record no-dark-content">
|
||||
<div class="dialog-record" :class="{playing: recordPlay}" :style="recordStyle(msgData.msg)" @click="playRecord">
|
||||
<div class="record-time">{{recordDuration(msgData.msg.duration)}}</div>
|
||||
<div class="record-icon taskfont"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!--等待-->
|
||||
<div v-else-if="msgData.type === 'loading'" class="content-loading">
|
||||
<Loading/>
|
||||
@ -88,6 +95,7 @@
|
||||
<script>
|
||||
import WCircle from "../../../components/WCircle";
|
||||
import {mapState} from "vuex";
|
||||
import {Store} from "le5le-store";
|
||||
|
||||
export default {
|
||||
name: "DialogView",
|
||||
@ -114,6 +122,7 @@ export default {
|
||||
popperLoad: 0,
|
||||
popperShow: false,
|
||||
timeShow: false,
|
||||
recordPlay: false,
|
||||
allList: [],
|
||||
}
|
||||
},
|
||||
@ -122,6 +131,12 @@ export default {
|
||||
this.msgRead()
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
if (this.recordPlay) {
|
||||
Store.set('audioSubscribe', false);
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['userToken', 'userId', 'dialogMsgs']),
|
||||
|
||||
@ -246,6 +261,23 @@ export default {
|
||||
return text;
|
||||
},
|
||||
|
||||
recordStyle(info) {
|
||||
const {duration} = info;
|
||||
let width = 50 + Math.min(180, Math.floor(duration / 150));
|
||||
return {
|
||||
width: width + 'px',
|
||||
};
|
||||
},
|
||||
|
||||
recordDuration(duration) {
|
||||
let minute = Math.floor(duration / 60000),
|
||||
seconds = Math.floor(duration / 1000) % 60;
|
||||
if (minute > 0) {
|
||||
return `${minute}:${seconds}″`
|
||||
}
|
||||
return `${Math.max(1, seconds)}″`
|
||||
},
|
||||
|
||||
imageStyle(info) {
|
||||
const {width, height} = info;
|
||||
if (width && height) {
|
||||
@ -270,6 +302,15 @@ export default {
|
||||
return {};
|
||||
},
|
||||
|
||||
playRecord() {
|
||||
Store.set('audioSubscribe', {
|
||||
src: this.msgData.msg.path,
|
||||
callback: (play) => {
|
||||
this.recordPlay = play;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
withdraw() {
|
||||
$A.modalConfirm({
|
||||
content: `确定撤回此信息吗?`,
|
||||
|
||||
@ -96,24 +96,8 @@
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
<div :class="['dialog-footer', msgNew > 0 && allMsgs.length > 0 ? 'newmsg' : '']" @click="onActive">
|
||||
<div class="dialog-footer" :class="{newmsg: msgNew > 0 && allMsgs.length > 0}" @click="onActive">
|
||||
<div class="dialog-newmsg" @click="onToBottom">{{$L('有' + msgNew + '条新消息')}}</div>
|
||||
<div class="dialog-input">
|
||||
<slot name="inputBefore"/>
|
||||
<ChatInput
|
||||
ref="input"
|
||||
v-model="msgText"
|
||||
:dialog-id="dialogId"
|
||||
:emoji-bottom="!$isDesktop"
|
||||
:maxlength="20000"
|
||||
@on-focus="onEventFocus"
|
||||
@on-blur="onEventBlur"
|
||||
@on-more="onEventMore"
|
||||
@on-file="sendFileMsg"
|
||||
@on-send="sendMsg"
|
||||
:placeholder="$L('输入消息...')"/>
|
||||
<slot name="inputAfter"/>
|
||||
</div>
|
||||
<DialogUpload
|
||||
ref="chatUpload"
|
||||
class="chat-upload"
|
||||
@ -121,6 +105,18 @@
|
||||
@on-progress="chatFile('progress', $event)"
|
||||
@on-success="chatFile('success', $event)"
|
||||
@on-error="chatFile('error', $event)"/>
|
||||
<ChatInput
|
||||
ref="input"
|
||||
v-model="msgText"
|
||||
:dialog-id="dialogId"
|
||||
:emoji-bottom="!$isDesktop"
|
||||
:maxlength="20000"
|
||||
@on-focus="onEventFocus"
|
||||
@on-blur="onEventBlur"
|
||||
@on-more="onEventMore"
|
||||
@on-file="sendFileMsg"
|
||||
@on-send="sendMsg"
|
||||
:placeholder="$L('输入消息...')"/>
|
||||
</div>
|
||||
<div v-if="dialogDrag" class="drag-over" @click="dialogDrag=false">
|
||||
<div class="drag-text">{{$L('拖动到这里发送')}}</div>
|
||||
@ -386,7 +382,18 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* 发送消息
|
||||
* @param text
|
||||
*/
|
||||
sendMsg(text) {
|
||||
if ($A.isJson(text)) {
|
||||
if (text.type === 'record') {
|
||||
// 发送录音 text.record
|
||||
this.sendRecordMsg(text.data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let msgText;
|
||||
if (typeof text === "string" && text) {
|
||||
msgText = text;
|
||||
@ -430,6 +437,42 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送录音
|
||||
* @param msg {base64, duration}
|
||||
*/
|
||||
sendRecordMsg(msg) {
|
||||
this.onToBottom();
|
||||
this.onActive();
|
||||
//
|
||||
let tempId = $A.randomString(16);
|
||||
this.tempMsgs.push({
|
||||
id: tempId,
|
||||
dialog_id: this.dialogData.id,
|
||||
type: 'loading',
|
||||
userid: this.userId,
|
||||
msg,
|
||||
});
|
||||
//
|
||||
this.$store.dispatch("call", {
|
||||
url: 'dialog/msg/sendrecord',
|
||||
data: Object.assign(msg, {
|
||||
dialog_id: this.dialogId,
|
||||
}),
|
||||
method: 'post'
|
||||
}).then(({data}) => {
|
||||
this.tempMsgs = this.tempMsgs.filter(({id}) => id != tempId)
|
||||
this.sendSuccess(data);
|
||||
}).catch(({msg}) => {
|
||||
$A.modalError(msg);
|
||||
this.tempMsgs = this.tempMsgs.filter(({id}) => id != tempId)
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送文件
|
||||
* @param row
|
||||
*/
|
||||
sendFileMsg(row) {
|
||||
const files = $A.isArray(row) ? row : [row];
|
||||
if (files.length > 0) {
|
||||
|
||||
@ -409,9 +409,8 @@
|
||||
:placeholder="$L('输入消息...')"
|
||||
@on-more="onEventMore"
|
||||
@on-file="onSelectFile"
|
||||
@on-send="onSend">
|
||||
<Badge slot="toolbarAfter" :count="taskDetail.msg_num"/>
|
||||
</ChatInput>
|
||||
@on-send="onSend"/>
|
||||
<Badge class="input-badge" :count="taskDetail.msg_num"/>
|
||||
</div>
|
||||
<div v-if="dialogDrag" class="drag-over" @click="dialogDrag=false">
|
||||
<div class="drag-text">{{$L('拖动到这里发送')}}</div>
|
||||
|
||||
@ -478,6 +478,8 @@ export default {
|
||||
switch (data.type) {
|
||||
case 'text':
|
||||
return $A.getMsgTextPreview(data.msg.text)
|
||||
case 'record':
|
||||
return `[${this.$L('语音')}]`
|
||||
case 'file':
|
||||
if (data.msg.type == 'img') {
|
||||
return `[${this.$L('图片')}]`
|
||||
|
||||
15
resources/assets/sass/components/mobile.scss
vendored
15
resources/assets/sass/components/mobile.scss
vendored
@ -240,6 +240,21 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// 录音发送(缩放)
|
||||
.mobile-send-enter-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-send-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-send-enter,
|
||||
.mobile-send-leave-to {
|
||||
transform: translate(-50%, -50%) scale(0) !important;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-tabbar {
|
||||
display: flex;
|
||||
|
||||
@ -2,15 +2,28 @@
|
||||
@import "~quill-mention/dist/quill.mention.min.css";
|
||||
|
||||
.chat-input-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
|
||||
.chat-input-wrapper {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
&.record-ing {
|
||||
.chat-input-wrapper {
|
||||
.chat-toolbar {
|
||||
> li {
|
||||
&.chat-send {
|
||||
&:before {
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
}
|
||||
}
|
||||
&.chat-record-recwave {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.task-mention {
|
||||
&.task-mention {
|
||||
.chat-input-wrapper {
|
||||
.ql-container {
|
||||
.ql-mention-list-container {
|
||||
.ql-mention-list {
|
||||
@ -29,6 +42,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
|
||||
.ql-container {
|
||||
display: block;
|
||||
@ -43,6 +63,7 @@
|
||||
max-height: 200px;
|
||||
padding: 0;
|
||||
margin: 4px 7px;
|
||||
line-height: 22px;
|
||||
|
||||
img {
|
||||
max-width: 150px;
|
||||
@ -87,6 +108,7 @@
|
||||
min-width: 220px;
|
||||
max-width: 280px;
|
||||
max-height: 360px;
|
||||
//noinspection CssInvalidPropertyValue
|
||||
overflow-y: overlay;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
@ -218,101 +240,76 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-toolbar {
|
||||
.chat-toolbar {
|
||||
float: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
float: right;
|
||||
width: 170px;
|
||||
height: 28px;
|
||||
padding: 0 2px;
|
||||
> i {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
padding: 0 5px;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
.chat-send {
|
||||
> li {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
padding-right: 16px;
|
||||
margin: 0 -16px 0 7px;
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
position: relative;
|
||||
.common-loading {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
margin: 0 1px;
|
||||
}
|
||||
> i {
|
||||
position: static;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
line-height: 28px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
&:after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background-color: #cccccc;
|
||||
transition: all 0.3s ease;
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
left: 2px;
|
||||
z-index: 1;
|
||||
border-radius: 50%;
|
||||
background-color: $primary-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
transition: all 0.3s ease;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
&.disabled {
|
||||
> i {
|
||||
color: rgba($primary-text-color, 0.5);
|
||||
.taskfont {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:after {
|
||||
transform: scale(1);
|
||||
}
|
||||
&.chat-send {
|
||||
width: 48px;
|
||||
position: relative;
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
border-radius: 50%;
|
||||
background-color: $primary-color;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
&:before {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
.el-tooltip {
|
||||
height: 30px;
|
||||
}
|
||||
.taskfont {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 3;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
&.chat-record-recwave {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
> div {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.el-tooltip.taskfont {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
padding: 0 5px;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-emoji-wrapper {
|
||||
.chat-emoji-box {
|
||||
width: auto;
|
||||
@ -364,34 +361,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.chat-input-emoji-popover {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-input-more-popover {
|
||||
min-width: 100px;
|
||||
padding: 8px;
|
||||
.chat-input-popover-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
line-height: 36px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
> i {
|
||||
font-size: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-emoji-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -474,18 +443,77 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-emoji-popover {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-input-more-popover {
|
||||
min-width: 100px;
|
||||
padding: 8px;
|
||||
.chat-input-popover-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
line-height: 36px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
.taskfont {
|
||||
font-size: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-record-transfer {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 9999;
|
||||
padding: 14px 26px;
|
||||
color: $primary-title-color;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 12px 0 #c8c8c8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
.record-duration {
|
||||
font-size: 20px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.record-cancel {
|
||||
opacity: 0.6;
|
||||
}
|
||||
&.cancel {
|
||||
background-color: #ff6565;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-input-box {
|
||||
.chat-input-wrapper {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
background-color: #ffffff;
|
||||
.ql-container {
|
||||
.ql-editor {
|
||||
font-size: 16px;
|
||||
color: $primary-title-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
.chat-emoji-wrapper {
|
||||
margin-top: 8px;
|
||||
margin-left: -10px;
|
||||
margin-bottom: -8px;
|
||||
width: calc(100% + 20px);
|
||||
background-color: #ffffff;
|
||||
.chat-emoji-box {
|
||||
height: 246px;
|
||||
|
||||
@ -443,6 +443,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
.content-record {
|
||||
display: flex;
|
||||
|
||||
.dialog-record {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-end;
|
||||
align-content: center;
|
||||
color: #ffffff;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
|
||||
.record-time {
|
||||
padding: 0 0 0 4px;
|
||||
}
|
||||
|
||||
.record-icon {
|
||||
transform: rotate(180deg) scale(0.9);
|
||||
|
||||
&:before {
|
||||
content: "\E793";
|
||||
}
|
||||
}
|
||||
|
||||
&.playing {
|
||||
.record-icon {
|
||||
&:before {
|
||||
animation: record-playing 1s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes record-playing {
|
||||
0% {
|
||||
content: "\E793";
|
||||
}
|
||||
33% {
|
||||
content: "\E791";
|
||||
}
|
||||
66% {
|
||||
content: "\E792";
|
||||
}
|
||||
100% {
|
||||
content: "\E793";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-loading {
|
||||
display: flex;
|
||||
|
||||
@ -637,6 +686,21 @@
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.content-record {
|
||||
|
||||
.dialog-record {
|
||||
flex-direction: row;
|
||||
|
||||
.record-time {
|
||||
padding: 0 4px 0 0;
|
||||
}
|
||||
|
||||
.record-icon {
|
||||
transform: rotate(0) scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-menu {
|
||||
@ -660,15 +724,14 @@
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
padding: 0 28px;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
padding: 0 24px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.dialog-newmsg {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: -38px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
color: #ffffff;
|
||||
@ -679,25 +742,7 @@
|
||||
margin-right: 10px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
z-index: 2;;
|
||||
}
|
||||
|
||||
.dialog-input {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.chat-input-box {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.chat-input-wrapper {
|
||||
background-color: #F4F5F7;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.chat-upload {
|
||||
@ -707,9 +752,26 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.newmsg {
|
||||
margin-top: -50px;
|
||||
.chat-input-box {
|
||||
.chat-input-wrapper {
|
||||
background-color: #F4F5F7;
|
||||
padding: 8px 2px;
|
||||
border-radius: 10px;
|
||||
.ql-container {
|
||||
.ql-editor {
|
||||
margin: 4px 12px;
|
||||
&.ql-blank {
|
||||
&::before {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.newmsg {
|
||||
.dialog-newmsg {
|
||||
display: block;
|
||||
}
|
||||
@ -921,14 +983,24 @@
|
||||
background-color: #f8f8f8;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 0;
|
||||
.dialog-input {
|
||||
.chat-input-box {
|
||||
.chat-input-wrapper {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
background-color: #ffffff;
|
||||
.chat-input-box {
|
||||
.chat-input-wrapper {
|
||||
background-color: #ffffff;
|
||||
.chat-toolbar {
|
||||
> li {
|
||||
&.chat-record-recwave {
|
||||
margin-left: -10px;
|
||||
width: calc(100% + 20px);
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chat-emoji-wrapper {
|
||||
margin-left: -10px;
|
||||
margin-bottom: -8px;
|
||||
width: calc(100% + 20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -568,28 +568,31 @@
|
||||
margin-left: 36px;
|
||||
}
|
||||
.no-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
margin: 22px 0 0 36px;
|
||||
background-color: #F4F5F7;
|
||||
padding: 10px 11px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 10px;
|
||||
.chat-input-box {
|
||||
.chat-input-wrapper {
|
||||
padding: 0;
|
||||
background-color: #F4F5F7;
|
||||
.chat-toolbar {
|
||||
> li {
|
||||
&.chat-record-recwave {
|
||||
background-color: #F4F5F7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chat-input-toolbar {
|
||||
position: relative;
|
||||
.ivu-badge {
|
||||
position: absolute;
|
||||
transform: scale(0.6) translateX(100%);
|
||||
transform-origin: right center;
|
||||
top: -8px;
|
||||
right: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
.input-badge {
|
||||
position: absolute;
|
||||
transform: scale(0.6) translateX(100%);
|
||||
transform-origin: right center;
|
||||
top: 2px;
|
||||
right: 25px;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
.drag-over {
|
||||
@ -651,7 +654,7 @@
|
||||
padding-left: 32px;
|
||||
}
|
||||
.dialog-footer {
|
||||
padding: 0 14px 0 28px;
|
||||
padding: 0 0 0 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
338
resources/assets/statics/public/js/recorder/frequency.histogram.view.js
vendored
Normal file
338
resources/assets/statics/public/js/recorder/frequency.histogram.view.js
vendored
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;
|
||||
|
||||
|
||||
})();
|
||||
111
resources/assets/statics/public/js/recorder/lib.fft.js
vendored
Normal file
111
resources/assets/statics/public/js/recorder/lib.fft.js
vendored
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};
|
||||
};
|
||||
6
resources/assets/statics/public/js/recorder/recorder.mp3.min.js
vendored
Normal file
6
resources/assets/statics/public/js/recorder/recorder.mp3.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user