Vue3 音频标注插件 wavesurfer

Vue3 音频标注插件 wavesurfer

最近前端在开发一个音频标注软件,需要加载一个mp3格式文件,然后展示出mp3音频文件的声波,然后通过鼠标在声波拖拽的方式,对某个时间段进行标注功能,记录出标注时间段的开始时间和结束时间,同时可以打标签,比如该区域是"发言人一"这类操作。

前期

这个功能看上去简单,但是实际开发起来还是有点难度的,首先是加载mp3音频数据,后端提供一个mp3音频文件的链接,比如:http://xxx/xxx/1.mp3,然后前端需要拿到对应的音频文件,将音频文件的声波显示出来。

起初没打算用插件,自己用canvas绘制了一下声波,通过鼠标事件,也成功的实现了需要的效果,还不错,除了有点Low,当然可以通过修改样式进行优化页面:

这样子之后,我发现加载几分钟的音频是没有什么问题的,但是加载像是长音频就会出现一些问题,最大的问题就是当前时间轴和音频播放位置对应不起来,会有几百毫秒的偏差,在一个就是加载音频声波时间太长了(当然使用插件绘制也有同样的问题),主要是获取到音频后,需要解码获取声波,所以时间越久解码时间越久。但是自己写的话,最大的好处就是你想怎么改就怎么改,想实现什么功能就实现什么功能,但是最后迫于某些原因,再加上时间不够,工期压的很紧,根本没时间去一点点维护和迭代,果断放弃了自己写,采用了插件 ------ wavesurfer.js

wavesurfer.js 安装

vue3 安装 wavesurfer 很简单,和其他 vue 安装插件一样:

bash 复制代码
npm i wavesurfer

等待安装完成就可以了。

我项目安装的版本是 7.12.1,是开发时候的最新版本。

使用

安装完成之后,就可以使用了。

首先呢,这个插件怎么说呢,比我想象中的难用,但是他确实帮我完成了很多功能,API暴露出的参数和函数上来说,很多我觉得可以提供的API或者参数,他都没有,所以说很多逻辑需要自己需写,但是我不确定后期会不会加上。

使用的话提供的API倒是也很简单,比如加载一个音频,展示声波:

首先需要引用以下必要的插件:

javascript 复制代码
import WaveSurfer from 'wavesurfer.js'
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.js'
import TimelinePlugin from 'wavesurfer.js/dist/plugins/timeline.esm.js'

然后编写一个函数用来加载:

javascript 复制代码
// 加载音频声波,url为音频链接
const initAudio = (url = "") => {
  if (!waveformRef.value) return
  // 销毁现有的实例
  if (wavesurfer.value) { destroyAudio() }
  annoList.value = JSON.parse(JSON.stringify(list))
  regionsPlugin.value = RegionsPlugin.create()
  // 创建新的 Wavesurfer 实例
  wavesurfer.value = WaveSurfer.create({
    container: waveformRef.value,
    waveColor: '#4a90e2',
    progressColor: '#A0CFFF',
    cursorColor: '#000',
    cursorWidth: 1,
    barWidth: 2,
    barRadius: 3,
    barGap: 3,
    height: 150,
    responsive: true,
    normalize: true,
    backend: 'WebAudio',
    plugins: [regionsPlugin.value, TimelinePlugin.create()],
  })
  // 音频加载完成
  wavesurfer.value.on('ready', () => {
    const totalDuration = wavesurfer.value.getDuration(); // 单位:秒
    audioTotalTime.value = totalDuration
  });
  // 音频播放时,更新当前时间
  wavesurfer.value.on('audioprocess', (currentTime) => {
    audioCurrentTime.value = currentTime  // 更新当前时间
  });
  // 点击 waveform 时,更新当前时间
  wavesurfer.value.on('click', () => {
    audioCurrentTime.value = wavesurfer.value.getCurrentTime()  // 点击鼠标时候获取当前时间
  });
  // 监听是否正在播放
  wavesurfer.value.on('play', () => {
    isPlaying.value = true  // 正在播放
  })
  // 暂停播放
  wavesurfer.value.on('pause', () => {
    isPlaying.value = false  // 暂停播放
  })
  // 播放完成
  wavesurfer.value.on('finish', () => {
    isPlaying.value = false   // 暂停播放
  })
  // 加载音频
  wavesurfer.value.load(url)
}

看一下效果:

拖拽绘制区域

首先鼠标绘制标注区域的功能是怎么实现呢,鼠标移动到声波上面,按下鼠标左键,创建一个临时(temp-前缀)的矩形区域,当鼠标移动的时候,随时更新临时矩形宽度,跟随鼠标绘制。鼠标松开后,删除临时矩形,绘制正经的标注矩形。

注册鼠标事件

首先我们需要注册鼠标事件,包括鼠标按下、鼠标移动、鼠标抬起、鼠标离开;

鼠标按下: 开始准备数据,在鼠标按下后,若移动,说明正在绘制矩形;若点击后抬起,则不是绘制;
鼠标移动: 如果是绘制矩形标注,则需要创建一个临时的矩形标注,跟随鼠标位置动态设置矩形宽度,可视化绘制区域;
鼠标抬起: 如果是绘制矩形标注,则抬起的时候,删除临时标注,生成一个正式的标注区域;
鼠标离开: 如果正在绘制矩形,鼠标移动的过程中脱离了声波区域,则删除绘制,当没有绘制处理;

创建鼠标监听

可以创建个函数,在初始化的时候,调用这个方法开启鼠标监听:

javascript 复制代码
  container.addEventListener('mousedown', handleMouseDown)
  container.addEventListener('mousemove', handleMouseMove)
  container.addEventListener('mouseup', handleMouseUp)
  container.addEventListener('mouseleave', handleMouseLeave)
鼠标按下事件

首先,鼠标按下不一定就是绘制,也可能就是单纯的点击,什么时候算绘制呢?第一是鼠标按下,第二是鼠标拖拽,只有鼠标按下后拖拽才算绘制。

其次,鼠标可能在已有的矩形标注上点击,这个就有歧义了,也不算歧义,比如我鼠标点在绿色的标注上,这个时候是想拖拽已有的绿色标注还是要绘制新的标注呢?这个需要通过业务来确定,这里就当拖拽已有的绿色标注,不再绘制新标注。

所以,在鼠标按下的时候,我们需要定义两个参数,一个参数用来表明是不是点击到已有的标注上了,如果点击到已有的标注上了的话,那么鼠标拖拽的时候什么也不处理,直接让wavesurfer插件自己处理就可。

所以在鼠标按下的时候,我们需要先判断是不是点击在了已有的标注上,如果点击在了已有的标注上则啥也不用干了。如果没有,则需要想办法根据点击的坐标,计算出标注的开始时间。

javascript 复制代码
// 鼠标按下事件
const handleMouseDown = (e) => {
  if (e.button !== 0) return
  const container = waveformRef.value
  // 检查是否点击在已有区域上
  const clickedRegion = checkClickOnRegion(e)
  if (clickedRegion) {
    isDraggingRegion = true
    return // 如果是点击区域,让 WaveSurfer 自己处理拖拽
  }
  // 开始绘制新区域
  isDraggingRegion = false
  isDrawing.value = true
  const rect = container.getBoundingClientRect()  
  const x = e.clientX - rect.left
  // 计算开始时间
  const duration = wavesurfer.value.getDuration()
  drawStartX.value = x
  drawStartTime.value = (x / rect.width) * duration
  // 移除旧的临时区域(如果存在)
  if (tempRegion.value) {
    tempRegion.value.remove()
    tempRegion.value = null
  }
  container.style.cursor = 'col-resize'
}

检查是不是点击在已有标注上的函数:

javascript 复制代码
// 检查是否点击在已有区域上
const checkClickOnRegion = (event) => {
  if (!regionsPlugin.value || !regions.value.length) return null
  // 获取点击位置
  const rect = waveformRef.value.getBoundingClientRect()
  const clickX = event.clientX - rect.left
  // 计算点击时间
  const duration = wavesurfer.value.getDuration()
  const clickTime = (clickX / rect.width) * duration
  // 检查是否点击在任何区域内
  for (const region of regions.value) {
    if (clickTime >= region.start && clickTime <= region.end) {
      return region
    }
  }
  return null
}
鼠标拖拽事件

鼠标拖拽监听,什么时候是绘制标注呢?是鼠标点下后的拖拽才说明是在拖拽。如果鼠标点击是在已有的标注上,则直接停止处理就可以了,全有wavesurfer插件来处理已有标注拖拽,不需要我们自己写代码实现。

如果不是点击在了然后我们判断isDrawing参数是不是true,如果是true说明鼠标已经按下了,然后我们就需要获取鼠标实时位置,从而计算出标注的结束时间,然后就可以操作临时区域,如果没有临时区域就创建,如果有临时区域了的话就修改临时区域的开始时间和结束时间就可以了。

javascript 复制代码
// 鼠标移动事件
const handleMouseMove = (e) => {
  if (isDraggingRegion) return
  const container = waveformRef.value
  if (isDrawing.value) {
    const rect = container.getBoundingClientRect()
    const currentX = e.clientX - rect.left
    // 计算当前时间
    const duration = wavesurfer.value.getDuration()
    const currentTime = (currentX / rect.width) * duration
    // 确定开始和结束时间
    const startTime = Math.min(drawStartTime.value, currentTime)
    const endTime = Math.max(drawStartTime.value, currentTime)
    // 确保区域有最小长度
    if (endTime - startTime < 0.01) return
    if (!tempRegion.value) {
      // 创建临时区域
      tempRegion.value = regionsPlugin.value.addRegion({
        id: `temp-${Date.now()}`,
        start: startTime,
        end: endTime,
        color: '#90939933',
        drag: false,
        resize: false
      })
    } else {
      // 更新现有临时区域
      try {
        // 直接更新区域的 start 和 end 属性
        tempRegion.value.setOptions({
          start: startTime,
          end: endTime
        })
      } catch (error) {
        // 如果更新失败,重新创建
        tempRegion.value.remove()
        tempRegion.value = regionsPlugin.value.addRegion({
          id: `temp-${Date.now()}`,
          start: startTime,
          end: endTime,
          color: '#90939933',
          drag: false,
          resize: false
        })
      }
    }
  }
}
鼠标抬起事件

鼠标抬起处理的事情有点小多了,首先你在鼠标按下的时候判断了是不是点击在了已有标注上,如果是的话,鼠标抬起后需要把isDraggingRegion参数重置回false,然后return就可以了,不需要其他的处理。

如果鼠标点击了,现在抬起来之后,则需要重置一下isDrawing参数重置为false

然后还要判断一下,有没有临时标注,如果有临时标注的话,说明是绘制的,这个时候需要根据临时区域创建一个正经的标注区域。然后在把临时的标注区域删除掉。

javascript 复制代码
// 鼠标释放事件
const handleMouseUp = (e) => {
  if (isDraggingRegion) {
    isDraggingRegion = false
    return // 让 WaveSurfer 处理区域拖拽的结束
  }
  const container = waveformRef.value
  if (!isDrawing.value) return
  isDrawing.value = false
  container.style.cursor = 'default'
  // 如果没有临时区域,直接返回
  if (!tempRegion.value) { return }
  const rect = container.getBoundingClientRect()
  const currentX = e.clientX - rect.left
  // 计算结束时间
  const duration = wavesurfer.value.getDuration()
  const currentTime = (currentX / rect.width) * duration
  const startTime = Math.min(drawStartTime.value, currentTime)
  const endTime = Math.max(drawStartTime.value, currentTime)
  // 如果区域太小,删除临时区域
  if (endTime - startTime < 0.1) { // 增加最小长度到0.1秒
    tempRegion.value.remove()
    tempRegion.value = null
    return
  }
  // 创建永久区域
  createPermanentRegion(startTime, endTime)
  // 清除临时区域
  tempRegion.value.remove()
  tempRegion.value = null
}

创建临时区域的话,是下面的函数:

javascript 复制代码
// 创建永久区域
const createPermanentRegion = (startTime, endTime) => {
  if (!regionsPlugin.value) return nul
  const regionId = `${uuidv4()}`
  const tempRegions = regionsPlugin.value.getRegions().filter(r => r.id.startsWith('temp-'))
  tempRegions.forEach(region => region.remove())
  regionsPlugin.value.regions = regionsPlugin.value.regions.filter(r => !r.id.startsWith('temp-'))
  // 创建永久区域
  const region = regionsPlugin.value.addRegion({
    id: regionId,
    start: startTime,
    end: endTime,
    color: '#90939933',
    drag: true,
    resize: true,
    minLength: 0.1,
    content: "标注",
  })
  region.element.id = regionId;
}
鼠标移除事件

如果鼠标在移出声波这个区域的时候,说明不想绘制了,我们就直接取消绘制就可以了,把该重置的数据重置了就可以了。

javascript 复制代码
// 鼠标离开事件
const handleMouseLeave = () => {
  if (isDrawing.value) {
    const container = waveformRef.value
    isDrawing.value = false
    container.style.cursor = 'default'
    // 删除临时区域
    if (tempRegion.value) {
      tempRegion.value.remove()
      tempRegion.value = null
    }
  }
  isDraggingRegion = false
}

在页面卸载的时候不要忘记销毁监听事件嗷

javascript 复制代码
  container?.removeEventListener('mousedown', handleMouseDown)
  container?.removeEventListener('mousemove', handleMouseMove)
  container?.removeEventListener('mouseup', handleMouseUp)
  container?.removeEventListener('mouseleave', handleMouseLeave)

标注事件监听

我们可以监听一下标注事件。

javascript 复制代码
  regionsPlugin.value.on('region-created', (region) => {
    // 创建完成回调
    
    // 监听区域更新完成
    region.on('update-end', () => {
    		// 修改完成回调
    })
  })

  // 监听标注区域点击
  regionsPlugin.value.on('region-clicked', (region, e) => {
    e.stopPropagation()
   	// 点击标注回调
  })

相关文档

https://wavesurfer.xyz/docs/types/wavesurfer.WaveSurferOptions

https://wavesurfer.xyz/examples/?timeline.js

相关推荐
铁蛋AI编程实战2 小时前
Gemini in Chrome 全实战:解锁+API调用+自定义扩展+本地推理
前端·人工智能·chrome
Hexene...2 小时前
【前端Vue】出现elementui的index.css引入报错如何解决?
前端·javascript·vue.js·elementui
红色的小鳄鱼2 小时前
Vue 监视属性 (watch) 超全解析:Vue2 Vue3
前端·javascript·css·vue.js·前端框架·html5
web小白成长日记2 小时前
Vue-实例从 createApp 到真实 DOM 的挂载全历程
前端·javascript·vue.js
晚霞的不甘2 小时前
Flutter for OpenHarmony实现高性能流体粒子模拟:从物理引擎到交互式可视化
前端·数据库·经验分享·flutter·microsoft·计算机视觉
晚霞的不甘2 小时前
Flutter for OpenHarmony 流体气泡模拟器:用物理引擎与粒子系统打造沉浸式交互体验
前端·flutter·ui·前端框架·交互
colicode2 小时前
发送语音通知接口技术手册:支持高并发的语音消息发送API规范
前端
橙露2 小时前
前端性能优化:首屏加载速度提升的8个核心策略与实战案例
前端·性能优化
查无此人byebye2 小时前
阿里开源Wan2.2模型全面解析:MoE架构加持,电影级视频生成触手可及
人工智能·pytorch·python·深度学习·架构·开源·音视频