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);
|
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. 文件上传
|
* @api {post} api/dialog/msg/sendfile 07. 文件上传
|
||||||
*
|
*
|
||||||
|
|||||||
@ -36,6 +36,9 @@ class VerifyCsrfToken extends Middleware
|
|||||||
// 聊天发文本
|
// 聊天发文本
|
||||||
'api/dialog/msg/sendtext/',
|
'api/dialog/msg/sendtext/',
|
||||||
|
|
||||||
|
// 聊天发语音
|
||||||
|
'api/dialog/msg/sendrecord/',
|
||||||
|
|
||||||
// 聊天发文件
|
// 聊天发文件
|
||||||
'api/dialog/msg/sendfile/',
|
'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['type'] = in_array($value['ext'], ['jpg', 'jpeg', 'png', 'gif']) ? 'img' : 'file';
|
||||||
$value['path'] = Base::fillUrl($value['path']);
|
$value['path'] = Base::fillUrl($value['path']);
|
||||||
$value['thumb'] = Base::fillUrl($value['thumb'] ?: Base::extIcon($value['ext']));
|
$value['thumb'] = Base::fillUrl($value['thumb'] ?: Base::extIcon($value['ext']));
|
||||||
|
} else if ($this->type === 'record') {
|
||||||
|
$value['path'] = Base::fillUrl($value['path']);
|
||||||
}
|
}
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
@ -210,6 +212,8 @@ class WebSocketDialogMsg extends AbstractModel
|
|||||||
switch ($this->type) {
|
switch ($this->type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return $this->previewTextMsg($this->msg['text'], $preserveHtml);
|
return $this->previewTextMsg($this->msg['text'], $preserveHtml);
|
||||||
|
case 'record':
|
||||||
|
return "[语音]";
|
||||||
case 'file':
|
case 'file':
|
||||||
if ($this->msg['type'] == 'img') {
|
if ($this->msg['type'] == 'img') {
|
||||||
return "[图片]";
|
return "[图片]";
|
||||||
|
|||||||
@ -2174,6 +2174,39 @@ class Base
|
|||||||
return Min(Max(Base::nullShow(Request::input($inputName), $default), 1), $max);
|
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图片保存
|
* image64图片保存
|
||||||
* @param array $param [ image64=带前缀的base64, path=>文件路径, fileName=>文件名称, scale=>[压缩原图宽,高, 压缩方式] ]
|
* @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/>
|
<RightBottom/>
|
||||||
<NetworkException/>
|
<NetworkException/>
|
||||||
<PreviewImageState/>
|
<PreviewImageState/>
|
||||||
|
<AudioManager/>
|
||||||
<iframe v-if="manifestUrl" v-show="false" :src="manifestUrl"></iframe>
|
<iframe v-if="manifestUrl" v-show="false" :src="manifestUrl"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -19,9 +20,10 @@ import RightBottom from "./components/RightBottom";
|
|||||||
import PreviewImageState from "./components/PreviewImage/state";
|
import PreviewImageState from "./components/PreviewImage/state";
|
||||||
import {mapState} from "vuex";
|
import {mapState} from "vuex";
|
||||||
import NetworkException from "./components/NetworkException";
|
import NetworkException from "./components/NetworkException";
|
||||||
|
import AudioManager from "./components/AudioManager";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {NetworkException, PreviewImageState, RightBottom, Spinner},
|
components: {AudioManager, NetworkException, PreviewImageState, RightBottom, Spinner},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
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;
|
const isSupportTouch = "ontouchend" in document;
|
||||||
export default {
|
export default {
|
||||||
bind (el, binding) {
|
bind (el, binding) {
|
||||||
let isMove = false;
|
let isTouch = false;
|
||||||
el.__touchMouseDown__ = e => {
|
el.__touchMouseDown__ = e => {
|
||||||
isMove = false;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
isTouch = true;
|
||||||
|
binding.value("down", e);
|
||||||
};
|
};
|
||||||
el.__touchMouseMove__ = _ => {
|
el.__touchMouseMove__ = e => {
|
||||||
isMove = true;
|
if (isTouch) {
|
||||||
};
|
binding.value("move", e);
|
||||||
el.__touchMouseUp__ = e => {
|
|
||||||
if (isMove) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
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 ? 'touchstart' : 'mousedown', el.__touchMouseDown__);
|
||||||
el.addEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
|
document.addEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
|
||||||
el.addEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
|
document.addEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
|
||||||
},
|
},
|
||||||
update () {
|
update () {
|
||||||
|
|
||||||
},
|
},
|
||||||
unbind (el) {
|
unbind (el) {
|
||||||
el.removeEventListener(isSupportTouch ? 'touchstart' : 'mousedown', el.__touchMouseDown__);
|
el.removeEventListener(isSupportTouch ? 'touchstart' : 'mousedown', el.__touchMouseDown__);
|
||||||
el.removeEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
|
document.removeEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
|
||||||
el.removeEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
|
document.removeEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
|
||||||
delete el.__touchMouseDown__;
|
delete el.__touchMouseDown__;
|
||||||
delete el.__touchMouseMove__;
|
delete el.__touchMouseMove__;
|
||||||
delete el.__touchMouseUp__;
|
delete el.__touchMouseUp__;
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-input-box">
|
<div class="chat-input-box" :class="boxClass">
|
||||||
<div class="chat-input-wrapper" :class="modeClass" @click.stop="focus">
|
<div class="chat-input-wrapper" @click.stop="focus">
|
||||||
<div ref="editor" class="no-dark-content" :style="editorStyle" @click.stop="" @paste="handlePaste"></div>
|
<!-- 输入框 -->
|
||||||
<div class="chat-input-toolbar" @click.stop="">
|
<div
|
||||||
<slot name="toolbarBefore"/>
|
ref="editor"
|
||||||
|
class="no-dark-content"
|
||||||
|
:style="editorStyle"
|
||||||
|
@click.stop=""
|
||||||
|
@paste="handlePaste"></div>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<ul class="chat-toolbar" @click.stop="">
|
||||||
|
<!-- 桌面端表情(漂浮) -->
|
||||||
|
<li>
|
||||||
<EPopover
|
<EPopover
|
||||||
v-if="!emojiBottom"
|
v-if="!emojiBottom"
|
||||||
v-model="showEmoji"
|
v-model="showEmoji"
|
||||||
@ -19,14 +27,22 @@
|
|||||||
<ETooltip v-else ref="emojiTip" :disabled="!$isDesktop || showEmoji" placement="top" :content="$L('表情')">
|
<ETooltip v-else ref="emojiTip" :disabled="!$isDesktop || showEmoji" placement="top" :content="$L('表情')">
|
||||||
<i class="taskfont" @click="showEmoji=!showEmoji"></i>
|
<i class="taskfont" @click="showEmoji=!showEmoji"></i>
|
||||||
</ETooltip>
|
</ETooltip>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- @ # -->
|
||||||
|
<li>
|
||||||
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择会员')">
|
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择会员')">
|
||||||
<i class="taskfont" @click="onToolbar('user')"></i>
|
<i class="taskfont" @click="onToolbar('user')"></i>
|
||||||
</ETooltip>
|
</ETooltip>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择任务')">
|
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择任务')">
|
||||||
<i class="taskfont" @click="onToolbar('task')"></i>
|
<i class="taskfont" @click="onToolbar('task')"></i>
|
||||||
</ETooltip>
|
</ETooltip>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- 图片文件 -->
|
||||||
|
<li>
|
||||||
<EPopover
|
<EPopover
|
||||||
v-model="showMore"
|
v-model="showMore"
|
||||||
:visibleArrow="false"
|
:visibleArrow="false"
|
||||||
@ -44,20 +60,45 @@
|
|||||||
{{$L('文件')}}
|
{{$L('文件')}}
|
||||||
</div>
|
</div>
|
||||||
</EPopover>
|
</EPopover>
|
||||||
|
</li>
|
||||||
|
|
||||||
<div class="chat-send" :class="[value ? '' : 'disabled']" v-touchmouse="send">
|
<!-- 发送按钮 -->
|
||||||
<Loading v-if="loading"/>
|
<li class="chat-send" :class="sendClass" v-touchmouse="clickSend">
|
||||||
<ETooltip v-else placement="top" :disabled="!$isDesktop" :content="$L('发送')">
|
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('发送')">
|
||||||
<Icon type="md-send"/>
|
<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>
|
</ETooltip>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- 录音效果 -->
|
||||||
|
<li v-if="recordReady" class="chat-record-recwave">
|
||||||
|
<div ref="recwave"></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<slot name="toolbarAfter"/>
|
<!-- 移动端表情(底部) -->
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</transition>
|
||||||
<template v-if="emojiBottom">
|
|
||||||
<ChatEmoji v-if="showEmoji" @on-select="onSelectEmoji"/>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -67,11 +108,12 @@ import Quill from 'quill';
|
|||||||
import "quill-mention";
|
import "quill-mention";
|
||||||
import ChatEmoji from "./emoji";
|
import ChatEmoji from "./emoji";
|
||||||
import touchmouse from "../../../../directives/touchmouse";
|
import touchmouse from "../../../../directives/touchmouse";
|
||||||
|
import TransferDom from "../../../../directives/transfer-dom";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ChatInput',
|
name: 'ChatInput',
|
||||||
components: {ChatEmoji},
|
components: {ChatEmoji},
|
||||||
directives: {touchmouse},
|
directives: {touchmouse, TransferDom},
|
||||||
props: {
|
props: {
|
||||||
dialogId: {
|
dialogId: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@ -126,7 +168,7 @@ export default {
|
|||||||
_content: '',
|
_content: '',
|
||||||
_options: {},
|
_options: {},
|
||||||
|
|
||||||
modeClass: '',
|
mentionMode: '',
|
||||||
|
|
||||||
userList: null,
|
userList: null,
|
||||||
taskList: null,
|
taskList: null,
|
||||||
@ -138,6 +180,17 @@ export default {
|
|||||||
wrapperWidth: 0,
|
wrapperWidth: 0,
|
||||||
editorHeight: 0,
|
editorHeight: 0,
|
||||||
|
|
||||||
|
recordReady: false,
|
||||||
|
recordRec: null,
|
||||||
|
recordState: "stop",
|
||||||
|
recordBlob: null,
|
||||||
|
recordWave: null,
|
||||||
|
recordDuration: 0,
|
||||||
|
|
||||||
|
touchStart: {},
|
||||||
|
touchLimitX: false,
|
||||||
|
touchLimitY: false,
|
||||||
|
|
||||||
isSpecVersion: this.checkIOSVersion(),
|
isSpecVersion: this.checkIOSVersion(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -179,6 +232,37 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
return {};
|
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: {
|
watch: {
|
||||||
@ -257,7 +341,7 @@ export default {
|
|||||||
shortKey: true,
|
shortKey: true,
|
||||||
handler: _ => {
|
handler: _ => {
|
||||||
if (!this.enterSend) {
|
if (!this.enterSend) {
|
||||||
this.send();
|
this.onSend();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -268,7 +352,7 @@ export default {
|
|||||||
shiftKey: false,
|
shiftKey: false,
|
||||||
handler: _ => {
|
handler: _ => {
|
||||||
if (this.enterSend) {
|
if (this.enterSend) {
|
||||||
this.send();
|
this.onSend();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -390,6 +474,27 @@ export default {
|
|||||||
return delta
|
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
|
// Ready event
|
||||||
this.$emit('on-ready', this.quill)
|
this.$emit('on-ready', this.quill)
|
||||||
},
|
},
|
||||||
@ -439,18 +544,122 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
send() {
|
clickSend(action, event) {
|
||||||
if (this.loading) {
|
if (this.loading) {
|
||||||
return;
|
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')
|
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() {
|
hidePopover() {
|
||||||
this.showEmoji = false;
|
this.showEmoji = false;
|
||||||
this.showMore = 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) {
|
onSelectEmoji(item) {
|
||||||
if (!this.quill) {
|
if (!this.quill) {
|
||||||
return;
|
return;
|
||||||
@ -533,7 +742,7 @@ export default {
|
|||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
switch (mentionChar) {
|
switch (mentionChar) {
|
||||||
case "@": // @成员
|
case "@": // @成员
|
||||||
this.modeClass = "user-mention";
|
this.mentionMode = "user-mention";
|
||||||
if (this.userList !== null) {
|
if (this.userList !== null) {
|
||||||
resolve(this.userList)
|
resolve(this.userList)
|
||||||
return;
|
return;
|
||||||
@ -603,7 +812,7 @@ export default {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "#": // #任务
|
case "#": // #任务
|
||||||
this.modeClass = "task-mention";
|
this.mentionMode = "task-mention";
|
||||||
if (this.taskList !== null) {
|
if (this.taskList !== null) {
|
||||||
resolve(this.taskList)
|
resolve(this.taskList)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -25,6 +25,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div v-else-if="msgData.type === 'loading'" class="content-loading">
|
||||||
<Loading/>
|
<Loading/>
|
||||||
@ -88,6 +95,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import WCircle from "../../../components/WCircle";
|
import WCircle from "../../../components/WCircle";
|
||||||
import {mapState} from "vuex";
|
import {mapState} from "vuex";
|
||||||
|
import {Store} from "le5le-store";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "DialogView",
|
name: "DialogView",
|
||||||
@ -114,6 +122,7 @@ export default {
|
|||||||
popperLoad: 0,
|
popperLoad: 0,
|
||||||
popperShow: false,
|
popperShow: false,
|
||||||
timeShow: false,
|
timeShow: false,
|
||||||
|
recordPlay: false,
|
||||||
allList: [],
|
allList: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -122,6 +131,12 @@ export default {
|
|||||||
this.msgRead()
|
this.msgRead()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this.recordPlay) {
|
||||||
|
Store.set('audioSubscribe', false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['userToken', 'userId', 'dialogMsgs']),
|
...mapState(['userToken', 'userId', 'dialogMsgs']),
|
||||||
|
|
||||||
@ -246,6 +261,23 @@ export default {
|
|||||||
return text;
|
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) {
|
imageStyle(info) {
|
||||||
const {width, height} = info;
|
const {width, height} = info;
|
||||||
if (width && height) {
|
if (width && height) {
|
||||||
@ -270,6 +302,15 @@ export default {
|
|||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
playRecord() {
|
||||||
|
Store.set('audioSubscribe', {
|
||||||
|
src: this.msgData.msg.path,
|
||||||
|
callback: (play) => {
|
||||||
|
this.recordPlay = play;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
withdraw() {
|
withdraw() {
|
||||||
$A.modalConfirm({
|
$A.modalConfirm({
|
||||||
content: `确定撤回此信息吗?`,
|
content: `确定撤回此信息吗?`,
|
||||||
|
|||||||
@ -96,10 +96,15 @@
|
|||||||
</DynamicScrollerItem>
|
</DynamicScrollerItem>
|
||||||
</template>
|
</template>
|
||||||
</DynamicScroller>
|
</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-newmsg" @click="onToBottom">{{$L('有' + msgNew + '条新消息')}}</div>
|
||||||
<div class="dialog-input">
|
<DialogUpload
|
||||||
<slot name="inputBefore"/>
|
ref="chatUpload"
|
||||||
|
class="chat-upload"
|
||||||
|
:dialog-id="dialogId"
|
||||||
|
@on-progress="chatFile('progress', $event)"
|
||||||
|
@on-success="chatFile('success', $event)"
|
||||||
|
@on-error="chatFile('error', $event)"/>
|
||||||
<ChatInput
|
<ChatInput
|
||||||
ref="input"
|
ref="input"
|
||||||
v-model="msgText"
|
v-model="msgText"
|
||||||
@ -112,15 +117,6 @@
|
|||||||
@on-file="sendFileMsg"
|
@on-file="sendFileMsg"
|
||||||
@on-send="sendMsg"
|
@on-send="sendMsg"
|
||||||
:placeholder="$L('输入消息...')"/>
|
:placeholder="$L('输入消息...')"/>
|
||||||
<slot name="inputAfter"/>
|
|
||||||
</div>
|
|
||||||
<DialogUpload
|
|
||||||
ref="chatUpload"
|
|
||||||
class="chat-upload"
|
|
||||||
:dialog-id="dialogId"
|
|
||||||
@on-progress="chatFile('progress', $event)"
|
|
||||||
@on-success="chatFile('success', $event)"
|
|
||||||
@on-error="chatFile('error', $event)"/>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="dialogDrag" class="drag-over" @click="dialogDrag=false">
|
<div v-if="dialogDrag" class="drag-over" @click="dialogDrag=false">
|
||||||
<div class="drag-text">{{$L('拖动到这里发送')}}</div>
|
<div class="drag-text">{{$L('拖动到这里发送')}}</div>
|
||||||
@ -386,7 +382,18 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
/**
|
||||||
|
* 发送消息
|
||||||
|
* @param text
|
||||||
|
*/
|
||||||
sendMsg(text) {
|
sendMsg(text) {
|
||||||
|
if ($A.isJson(text)) {
|
||||||
|
if (text.type === 'record') {
|
||||||
|
// 发送录音 text.record
|
||||||
|
this.sendRecordMsg(text.data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let msgText;
|
let msgText;
|
||||||
if (typeof text === "string" && text) {
|
if (typeof text === "string" && text) {
|
||||||
msgText = 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) {
|
sendFileMsg(row) {
|
||||||
const files = $A.isArray(row) ? row : [row];
|
const files = $A.isArray(row) ? row : [row];
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
|
|||||||
@ -409,9 +409,8 @@
|
|||||||
:placeholder="$L('输入消息...')"
|
:placeholder="$L('输入消息...')"
|
||||||
@on-more="onEventMore"
|
@on-more="onEventMore"
|
||||||
@on-file="onSelectFile"
|
@on-file="onSelectFile"
|
||||||
@on-send="onSend">
|
@on-send="onSend"/>
|
||||||
<Badge slot="toolbarAfter" :count="taskDetail.msg_num"/>
|
<Badge class="input-badge" :count="taskDetail.msg_num"/>
|
||||||
</ChatInput>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="dialogDrag" class="drag-over" @click="dialogDrag=false">
|
<div v-if="dialogDrag" class="drag-over" @click="dialogDrag=false">
|
||||||
<div class="drag-text">{{$L('拖动到这里发送')}}</div>
|
<div class="drag-text">{{$L('拖动到这里发送')}}</div>
|
||||||
|
|||||||
@ -478,6 +478,8 @@ export default {
|
|||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return $A.getMsgTextPreview(data.msg.text)
|
return $A.getMsgTextPreview(data.msg.text)
|
||||||
|
case 'record':
|
||||||
|
return `[${this.$L('语音')}]`
|
||||||
case 'file':
|
case 'file':
|
||||||
if (data.msg.type == 'img') {
|
if (data.msg.type == 'img') {
|
||||||
return `[${this.$L('图片')}]`
|
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;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.mobile-tabbar {
|
.mobile-tabbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -2,15 +2,28 @@
|
|||||||
@import "~quill-mention/dist/quill.mention.min.css";
|
@import "~quill-mention/dist/quill.mention.min.css";
|
||||||
|
|
||||||
.chat-input-box {
|
.chat-input-box {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.chat-input-wrapper {
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 100%;
|
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-container {
|
||||||
.ql-mention-list-container {
|
.ql-mention-list-container {
|
||||||
.ql-mention-list {
|
.ql-mention-list {
|
||||||
@ -29,6 +42,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
.ql-container {
|
.ql-container {
|
||||||
display: block;
|
display: block;
|
||||||
@ -43,6 +63,7 @@
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 4px 7px;
|
margin: 4px 7px;
|
||||||
|
line-height: 22px;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
@ -87,6 +108,7 @@
|
|||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
|
//noinspection CssInvalidPropertyValue
|
||||||
overflow-y: overlay;
|
overflow-y: overlay;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
@ -218,101 +240,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-toolbar {
|
.chat-toolbar {
|
||||||
|
float: right;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
float: right;
|
> li {
|
||||||
width: 170px;
|
width: 30px;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
padding: 0 2px;
|
display: flex;
|
||||||
> i {
|
align-items: center;
|
||||||
display: inline-block;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 5px;
|
.taskfont {
|
||||||
|
display: inline-block;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 28px;
|
line-height: 30px;
|
||||||
&.disabled {
|
&.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.chat-send {
|
&.chat-send {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 20px;
|
|
||||||
padding-right: 16px;
|
|
||||||
margin: 0 -16px 0 7px;
|
|
||||||
height: 48px;
|
|
||||||
width: 48px;
|
width: 48px;
|
||||||
position: relative;
|
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 {
|
&:before {
|
||||||
position: absolute;
|
|
||||||
content: "";
|
content: "";
|
||||||
left: 2px;
|
position: absolute;
|
||||||
z-index: 1;
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: $primary-color;
|
background-color: $primary-color;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
&.disabled {
|
.el-tooltip {
|
||||||
> i {
|
height: 30px;
|
||||||
color: rgba($primary-text-color, 0.5);
|
|
||||||
}
|
}
|
||||||
&:after {
|
.taskfont {
|
||||||
transform: scale(1);
|
position: absolute;
|
||||||
opacity: 1;
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 3;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
&:before {
|
}
|
||||||
transform: scale(0);
|
&.chat-record-recwave {
|
||||||
opacity: 0;
|
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-wrapper {
|
||||||
.chat-emoji-box {
|
.chat-emoji-box {
|
||||||
width: auto;
|
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 {
|
.chat-emoji-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.chat-input-box {
|
.chat-input-box {
|
||||||
.chat-input-wrapper {
|
.chat-input-wrapper {
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
|
.ql-container {
|
||||||
|
.ql-editor {
|
||||||
|
font-size: 16px;
|
||||||
|
color: $primary-title-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.chat-emoji-wrapper {
|
.chat-emoji-wrapper {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-left: -10px;
|
|
||||||
margin-bottom: -8px;
|
|
||||||
width: calc(100% + 20px);
|
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
.chat-emoji-box {
|
.chat-emoji-box {
|
||||||
height: 246px;
|
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 {
|
.content-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
@ -637,6 +686,21 @@
|
|||||||
background-color: transparent;
|
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 {
|
.dialog-menu {
|
||||||
@ -660,15 +724,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dialog-footer {
|
.dialog-footer {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
padding: 0 28px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding: 0 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
.dialog-newmsg {
|
.dialog-newmsg {
|
||||||
display: none;
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: -38px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
line-height: 30px;
|
line-height: 30px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@ -679,25 +742,7 @@
|
|||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 2;;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-upload {
|
.chat-upload {
|
||||||
@ -707,9 +752,26 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.newmsg {
|
.chat-input-box {
|
||||||
margin-top: -50px;
|
.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 {
|
.dialog-newmsg {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@ -921,13 +983,23 @@
|
|||||||
background-color: #f8f8f8;
|
background-color: #f8f8f8;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
.dialog-input {
|
|
||||||
.chat-input-box {
|
.chat-input-box {
|
||||||
.chat-input-wrapper {
|
.chat-input-wrapper {
|
||||||
padding-left: 6px;
|
|
||||||
padding-right: 6px;
|
|
||||||
background-color: #ffffff;
|
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;
|
margin-left: 36px;
|
||||||
}
|
}
|
||||||
.no-input {
|
.no-input {
|
||||||
display: flex;
|
position: relative;
|
||||||
align-items: center;
|
|
||||||
margin: 22px 0 0 36px;
|
margin: 22px 0 0 36px;
|
||||||
background-color: #F4F5F7;
|
background-color: #F4F5F7;
|
||||||
padding: 10px 11px;
|
padding: 10px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
.chat-input-box {
|
.chat-input-box {
|
||||||
.chat-input-wrapper {
|
.chat-input-wrapper {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: #F4F5F7;
|
background-color: #F4F5F7;
|
||||||
|
.chat-toolbar {
|
||||||
|
> li {
|
||||||
|
&.chat-record-recwave {
|
||||||
|
background-color: #F4F5F7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.chat-input-toolbar {
|
}
|
||||||
position: relative;
|
}
|
||||||
.ivu-badge {
|
}
|
||||||
|
.input-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: scale(0.6) translateX(100%);
|
transform: scale(0.6) translateX(100%);
|
||||||
transform-origin: right center;
|
transform-origin: right center;
|
||||||
top: -8px;
|
top: 2px;
|
||||||
right: 10px;
|
right: 25px;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.drag-over {
|
.drag-over {
|
||||||
@ -651,7 +654,7 @@
|
|||||||
padding-left: 32px;
|
padding-left: 32px;
|
||||||
}
|
}
|
||||||
.dialog-footer {
|
.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