官网地址
音频可视化插件:wavesurferjs
需求示例
最近接触到了音频标注,以下是demo示例。
代码才是硬实力
话不多说,直接上代码:
<template>
<div>
<div class="mixin-components-container">
<el-card class="box-card" v-if="audioUrl">
<!-- 人员类型 -->
<div class="primaryType">
<p
v-for="(tag, index) in fixedType"
:key="index"
@click = "changeCurType(index)"
:style='`background: ${curType === index ? primaryColor[index].dark : primaryColor[index].shallow}; color: ${curType === index ? "#FFF" : "#000"}`'
>{{tag}}</p>
</div>
<!-- waveform -->
<div id="waveform" ref="waveform"></div>
<!--时间轴 -->
<div id="wave-timeline" ref="wave-timeline"></div>
<!-- 控件元素 -->
<div v-show="wavesurfer !== null && audioUrl !== ''" class="control">
<!-- 播放按钮 -->
<div @click="playMusic" class="playBtn">
<i class="el-icon-video-play" v-if="!playFlag"></i>
<i class="el-icon-video-pause" v-else></i>
</div>
<!-- 纵向缩放 -->
<div class="zoom">
<p>振幅缩放</p>
<input
data-action="zoom"
@change="changeBarHeight()"
v-model="barHeight"
type="range"
min="1"
max="10"
value="1"
/>
</div>
<!-- 水平缩放 -->
<div class="zoom">
<p>水平缩放</p>
<input
data-action="zoom"
@change="zoom(zooms)"
v-model="zooms"
type="range"
min="20"
max="1000"
value="20"
/>
</div>
<!-- 音量 -->
<div class="grid-content bg-purple-dark">
<el-popover
placement="top-start"
trigger="click"
width="45"
min-width="45"
style="min-width: 38px"
>
<div class="block" style="width: 42px">
<el-slider
v-model="volumeValue"
vertical
height="100px"
@change="setVolume(volumeValue)"
/>
</div>
<el-button class="normal allbtn primary" slot="reference">
音量
</el-button>
</el-popover>
</div>
<!-- 倍速播放 -->
<div class="grid-content bg-purple-dark">
<el-tooltip
class="item"
effect="dark"
content="倍速调整"
placement="bottom"
>
<el-popover
placement="top"
width="180"
trigger="click"
style="margin-left: 10px"
>
<el-input-number
v-model="speed"
width="180"
:precision="2"
:step="0.25"
:min="0.5"
:max="2"
@change="doubleSpeed(speed)"
/>
<el-button slot="reference" round>
{{ speed + " X" }}
</el-button>
</el-popover>
</el-tooltip>
</div>
</div>
</el-card>
<!-- 空状态 -->
<el-empty :image-size="200" v-else></el-empty>
</div>
<!-- 标注内容编辑区 -->
<ul class="labelTextArea" v-if="showList.length > 0" >
<div :style="`border:2px solid ${setColor()};color:${setColor()}`" @click="isChange = !isChange">
{{ showList[curEditRegionIndex].speaker }}
<i :class="isChange ? 'el-icon-caret-top' : 'el-icon-caret-bottom'"></i>
<ul v-show="isChange">
<li
v-for="(tag, index) in fixedType"
:key="index"
@click="editClass(index,$event)"
:style="`color: ${primaryColor[index].dark}`"
>{{ tag }}</li>
</ul>
</div>
<div v-show="primaryTypeName.length>0" class="tagArea">
<p>标注标签:</p>
<div class="tagList">
<p
v-for="(v, k) of primaryTypeName"
:key="k"
>
<input
type="checkbox"
v-model="showList[curEditRegionIndex].class"
:value="v"
>
{{ v }}
</p>
</div>
</div>
<li class="detailInfo">
<div>
<p>标注ID: {{ showList[curEditRegionIndex].id }}</p>
<span class="deleteBtn" @click.prevent="deleteRegion(showList[curEditRegionIndex].id)">删除</span>
</div>
<textarea
@mouseenter="changeActive(showList[curEditRegionIndex], 'enter')"
@mouseleave="changeActive(showList[curEditRegionIndex], 'leave')"
v-model.lazy="showList[curEditRegionIndex].label"
style="height: 100;"
placeholder="暂无标注信息,请点击输入..."
></textarea>
</li>
</ul>
</div>
</template>
<script>
import WaveSurfer from 'wavesurfer.js';
import RegionsPlugin from 'wavesurfer.js/dist/plugin/wavesurfer.regions.min.js';
import CursorPlugin from 'wavesurfer.js/dist/plugin/wavesurfer.cursor.js';
import Timeline from 'wavesurfer.js/dist/plugin/wavesurfer.timeline.js';
export default {
name: 'AudioLabel',
props: ['markerImgs', 'pid'],
data () {
return {
isChange: false,
zooms: 100, // 缩放
volumeValue: [1], // 音频音量
speed: 1.0, // 倍速
barHeight: 1, // 振幅比例(波线高度)
fixedType: ['第一种', '第二种', '第三种','第四种'], // (固定三个)
wavesurfer: null, // 音频波线承载器
playFlag: false, // 播放按钮切换器
audioUrl: '', // 音频地址
initGetData: {}, // 初始化数据留存根
curType: -1, // 当前标注类型 -1默认 0-9对应primaryTypeName分类下标
curEditRegionIndex: 0, // 当前可编辑数据下标
primaryTypeName: [], // 预分类名(最多10个)
showList: [], // 标注信息列表
primaryColor: [
{
shallow: 'rgba(255, 0, 0, 0.3)',
dark: 'rgba(255, 0, 0, 1)'
}, // 第一种
{
shallow: 'rgba(0, 0, 255, 0.3)',
dark: 'rgba(0, 0, 255, 1)'
}, // 第二种
{
shallow: 'rgba(40, 190, 130, 0.3)',
dark: 'rgba(40, 190, 130, 1)'
}, // 第三种
{
shallow: 'rgba(0, 255, 255, 0.3)',
dark: 'rgba(0, 255, 255, 1)'
}, // 第四种
{
shallow: 'rgba(255, 0, 255, 0.3)',
dark: 'rgba(255, 0, 255, 1)'
}, // 第五种
{
shallow: 'rgba(34, 193, 34, 0.3)',
dark: 'rgba(34, 193, 34, 1)'
}, // 第六种
{
shallow: 'rgba(51, 161, 201, 0.3)',
dark: 'rgba(51, 161, 201, 1)'
}, // 第七种
{
shallow: 'rgba(255, 192, 203, 0.3)',
dark: 'rgba(255, 192, 203, 1)'
}, // 第八种
{
shallow: 'rgba(244, 164, 96, 0.3)',
dark: 'rgba(244, 164, 96, 1)'
}, // 第九种
{
shallow: 'rgba(138, 199, 140, 0.3)',
dark: 'rgba(138, 199, 140, 1)'
} // 第十种
] // 预选标注颜色(十种,只需前端页面展示使用,后端数据无需提供和保存)
};
},
watch: {
audioUrl() {
this.initAudioWave()
}
},
mounted() {
this.audioUrl = 'http://music.163.com/song/media/outer/url?id=447925558.mp3'
},
beforeDestroy () {
this.wavesurfer && this.wavesurfer.unAll();
this.wavesurfer && this.wavesurfer.destroy();
this.wavesurfer = null;
this.audioUrl = '';
},
methods: {
/** 设置标题颜色 */
setColor () {
if (!this.showList[this.curEditRegionIndex].color) return '#CCC';
const color = this.showList[this.curEditRegionIndex].color.split(',');
color[3] = '1)';
return color.join(',');
},
/**
* 点击标注片段切换对应文本框信息
* @param {object} regionInfo 标注片段信息
*/
changeTextArea (regionInfo) {
this.curEditRegionIndex = this.showList.findIndex((item) => item.id === regionInfo.id); // 查找是否有对应标注
},
/** 初始化音频波插件 */
initAudioWave () {
this.$nextTick(() => {
this.wavesurfer = WaveSurfer.create({
container: this.$refs.waveform, // 音频波线的容器
waveColor: '#ccc', // 波形的填充颜色(未播放区域)
progressColor: 'skyblue', // 进度颜色
backend: 'MediaElement',
scrollParent: true, // 开启滚动
cursorColor: 'red', // 指定进度光标颜色
barMinHeight: 1, // 振幅最小高度
barHeight: Number(this.barHeight), // 波形振幅
mediaControls: false, // 启用媒体基本控件
audioRate: '1', // 音频波放速度 数字约小播放约慢
autoCenter: true, // 有滚动条音频线居中展示
plugins: [
RegionsPlugin.create({}), // 开启标注区
Timeline.create({ container: '#wave-timeline' }), // 开启时间线轴
CursorPlugin.create({
showTime: true, // 展示鼠标位置对应时间
opacity: 1, // 透明度
customShowTimeStyle: {
backgroundColor: '#000',
color: '#fff',
padding: '2px',
fontSize: '10px'
} // 指针轴时间展示区样式
}) // 插件--指针轴的配置
] // 插件
});
// 线上地址直接引用
this.wavesurfer.load(this.audioUrl);
this.wavesurfer.on('finish', () => { this.playFlag = false; }); // 播放完毕自动关闭
this.wavesurfer.on('region-update-end', (obj) => { this.toUpdateShowList(obj); }); // 标注区更新
this.wavesurfer.on('region-click', (obj) => { this.changeTextArea(obj); }); // 单击标注片段
this.wavesurfer.on('waveform-ready', () => { // 音波图渲染完毕
// 默认全选
if (this.showList.length === 0) {
this.showList.push({
id: new Date().getTime(),
label: '',
start: 0,
end: this.wavesurfer.getDuration(), // 获取音频全部时常
color: this.primaryColor[0].shallow, // 如果选了类型则为对应类型颜色,否则为默认灰色
class: [],
speaker: this.fixedType[0]
});
this.drawRegion();
}
});
this.wavesurfer.enableDragSelection({ color: 'rgba(0,0,0,.3)' }); // 允许鼠标拖动创建标注区
this.drawRegion(); // 有标注则自动绘制已标注部分
});
},
/**
* 选择标注类型
* @param {number} index 标注类型索引
*/
changeCurType (index) {
this.curType = this.curType === index ? -1 : index;
},
/**
* 通过自定义属性查找元素
* @param {string} tag 元素
* @param {string} attr 自定义属性名
* @param {number} value 自定义属性值
*/
getElementByAttr (tag, attr, value) {
var aElements = document.getElementsByTagName(tag);
var aEle = [];
for (var i = 0; i < aElements.length; i++) {
if (aElements[i].getAttribute(attr) === value) aEle.push(aElements[i]);
}
return aEle;
},
/**
* 划过标注信息对应标注区高亮
* @param {object} regionInfo 标注片段信息
* @param {string} type 交互类型 enter->进入 leave->滑出
*/
changeActive (regionInfo, type) {
if (this.wavesurfer === null) return;
let elm = this.getElementByAttr(
'region',
'data-id',
`${regionInfo.id}`
)[0];
const color = regionInfo.color.split(',');
color[3] = type === 'enter' ? '0.8)' : '0.3)';
elm.style.backgroundColor = color.join(',');
elm.style.border = type === 'enter' ? '2px dashed blue' : 'none';
elm.style.boxSizing = 'border-box';
},
/**
* 新建/更改 标注片段+自动切换文本框信息
* 选择标注类型->创建新的标注片段
* 未选择标注类型->仅预生成灰色选中区且不作为新的标注片段添加
*/
toUpdateShowList (info) {
const newArr = JSON.parse(JSON.stringify(this.showList));
let ind = newArr.findIndex((item) => item.id === info.id); // 查找是否有对应标注
let newRegion = this.curType > -1
? {
id: new Date().getTime(),
label: '',
start: info.start,
end: info.end,
color: this.primaryColor[this.curType].shallow || '#ccc', // 如果选了类型则为对应类型颜色,否则为默认灰色
class: [],
speaker: this.fixedType[this.curType] || '坐席'
}
: { };
if (ind === -1) { // 有类型才能添加标注数据
this.curType > -1 && newArr.push(newRegion);
} else { // 更新选择区选择范围
newArr[ind].start = info.start;
newArr[ind].end = info.end;
}
this.showList = newArr;
this.drawRegion();
ind === -1 && this.curType > -1 && this.changeTextArea(newRegion); // 创建新的选择区后自动切换文本框内容
},
/**
* "播放/暂停"按钮
* 单击触发事件,暂停的话单击则播放,正在播放的话单击则暂停播放
*/
playMusic () {
this.playFlag ? this.wavesurfer.pause() : this.wavesurfer.play();
this.playFlag = !this.playFlag;
},
/** 绘制音轨标注片段 */
drawRegion () {
this.wavesurfer.clearRegions();
this.showList.forEach((regionInfo) => {
this.wavesurfer.addRegion({
id: regionInfo.id,
start: regionInfo.start, // 区域开始时间
end: regionInfo.end, // 区域结束时间
color: regionInfo.color, // 区域颜色
drag: true, // 是否可拖拽
resize: true // 是否可改变大小
});
});
},
/**
* 点击删除标注 自动切换文本框信息为已标注片段的最后一项
* @param {number} id 标注信息ID
*/
deleteRegion (id) {
const newArr = JSON.parse(JSON.stringify(this.showList));
let ind = newArr.findIndex((item) => item.id === id); // 查找是否有对应标注
if (ind === -1) return;
newArr.splice(ind, 1);
this.showList = newArr;
this.drawRegion();
this.changeTextArea(newArr.length > 0 ? newArr[newArr.length - 1] : 0);
},
/**
* 更改已标注片段的类型
* @param {number} key 索引
* @param {object} e 点击元素信息
*/
editClass (key, e) {
e.stopPropagation();
const newArr = JSON.parse(JSON.stringify(this.showList));
let index = newArr.findIndex((item) => item.id === this.showList[this.curEditRegionIndex].id);
if (index === -1) return;
newArr[index].speaker = this.fixedType[key];
newArr[index].color = this.primaryColor[key].shallow;
this.showList = newArr;
this.drawRegion();
this.isChange = false;
},
/**
* 振幅放大/缩小
* 该功能支持需要摒弃音频资源失效策略
*/
changeBarHeight () {
this.wavesurfer.unAll();
this.wavesurfer.destroy();
this.wavesurfer = null;
this.initAudioWave();
},
/**
* 波形图缩放
* @param {number} val 缩放值
* 调用zoom() API更改水平比例
*/
zoom (val) {
this.wavesurfer && this.wavesurfer.zoom(val);
},
/**
* 设置音量
* @param {number} val 音量值
*/
setVolume (val) {
this.wavesurfer && this.wavesurfer.setVolume(val / 100);
},
/**
* 倍速播放
* @param {number} rate 0.5-2的取值范围 加减差值为0.25
*/
doubleSpeed (rate) {
this.wavesurfer && this.wavesurfer.setPlaybackRate(rate);
}
}
};
</script>
<style scoped lang="scss">
.mixin-components-container {
background: #fff;
height: max-content;
border: none;
}
.colorTag {
display: inline-block;
height: 10px;
width: 30px;
}
.primaryType {
width: 100%;
color:#000;
display: flex;
>p {
padding: 0 3px;
margin-left: 10px;
}
}
.labelTextArea {
width: 90%;
height: max-content;
margin-top: 20px;
padding-left: 10px;
>div {
padding: 2px 10px;
width: max-content;
}
.detailInfo {
width: 100%;
>div {
height: 40px;
display: flex;
justify-content: space-between;
align-items: center;
>p {
height: max-content;
margin-bottom: 0;
}
.deleteBtn {
padding: 3px 10px;
display: inline-block;
border-radius: 5px;
text-align: center;
background-color: red;
font-size: 12px;
color: #fff;
}
}
textarea {
width: 100%;
height: 80px;
border: 1px solid #ccc;
}
}
.tagArea {
width: 100%;
display: flex;
padding: 0;
margin: 10px auto 0;
p {
margin-bottom: 0;
}
.tagList {
width: max-content;
display: flex;
flex-wrap: wrap;
margin-bottom: 0;
p {
margin-right: 30px;
margin-bottom: 0!important;
}
input {
margin-right: 3px;
}
}
}
}
.control {
display: flex;
height: max-content;
align-items: center;
.playBtn{
margin: auto;
height: max-content;
i {
display: block;
font-size: 60px;
color: #409EFF;
}
}
.zoom {
width: max-content;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
margin-right: 10px;
>p {
width: max-content;
margin-bottom: 0;
}
input {
width: 100px;
}
}
}
</style>
实现功能
可能有的小伙伴看完还是有点懵,那么,总结一下基本上实现的功能吧:
- 语音可视化(频波图绘制)
- 对应音频时间轴绘制
- 音频播放与暂停控制
- 手动点击可变更播放进度
- 音频部分区域选中(多区域)
- 选中区域可更改(移动,拉伸)
- 可更改标注区的展示与隐藏
- 可删除标注区
- 鼠标滑动至标注信息,对应标注区高亮
- 音量、倍速、横/纵向调整
依赖包版本相关
{
"name": "vh",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.8.3",
"element-ui": "^2.15.13",
"vue": "^2.6.14",
"vue-router": "^3.5.1",
"vuex": "^3.6.2",
"wavesurfer.js": "^6.4.0"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.32.7",
"sass-loader": "^12.0.0",
"vue-template-compiler": "^2.6.14"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
],
"_id": "vh@0.1.0",
"readme": "ERROR: No README data found!"
}
求点赞+关注
小伙伴们可根据自己实际需求更改代码,代码区呢也进行了详细的功能注释,创作不易,多多点赞+关注,谢谢支持,同时,还有很多好的技术网站点关注之后即可查看~
更多技术分享,敬请期待