给我的视频聊天项目加一个音频可视化的需求

前言

大家好!我叫。。。额。。。小米爱老鼠(没有笔名,就用户名吧),你们可以称呼我小米;前几天我写了我第一篇掘金文章,分享了我的视频聊天室项目,虽然它主要功能都已实现,但是还是有一些有趣想法没有写到里面,我觉得有趣好玩的想法我都想实现一下;这不就来了一个音量可视化的需求,我想在语音&视频通话时显示音量大小的变化。

需求描述

我希望它是一个信号图案,根据音量的强弱,改变信号强度

需求实现

使用到的Web API有AudioContextCanvas

AudioContext

AudioContext接口表示由链接在一起的音频模块构建的音频处理图,每个模块由一个AudioNode表示。音频上下文控制它包含的节点的创建和音频处理或解码的执行。在做任何其他操作之前,你需要创建一个AudioContext对象,因为所有事情都是在上下文中发生的。建议创建一个AudioContext对象并复用它,而不是每次初始化一个新的AudioContext对象,并且可以对多个不同的音频源和管道同时使用一个AudioContext对象。

AudioContext.createAnalyser()

创建一个AnalyserNode,可以用来获取音频时间和频率数据,以及实现数据可视化。

AnalyserNode.getByteFrequencyData()

将当前频率数据复制到传入的 Uint8Array(无符号字节数组)中。

设计

  • 我希望它有一定的扩展性,使用者能够自己定制可视化的图案,所以将绘画的权限交给使用者。
typescript 复制代码
// 音频来源可以是媒体流(MediaStream)或者是HTML媒体元素
type Source = MediaStream | HTMLAudioElement | HTMLVideoElement

export function audioVisualizer(draw) {
  return function start(audioSource: Source, canvas: HTMLCanvasElement) {
    ...
    draw(...)
    ...
    return function cancel() {
      ...
    }
  }
}
  • 实现一个函数,它接收一个绘画函数,返回一个开始绘画的函数,开始函数又会返回一个取消绘画的函数,接下来我们使用AudioContextCanvas来填充其余部分。
typescript 复制代码
const isType = (data: any, type: string) => toString.call(data) === `[object ${type}]`
const isMediaStream = (source: Source) => isType(source, 'MediaStream')
const isHTMLMediaElement = (source: Source) => isType(source, 'HTMLAudioElement') || isType(source, 'HTMLVideoElement')

type Source = MediaStream | HTMLAudioElement | HTMLVideoElement

export function audioVisualizer(
  draw: (dataArray: Uint8Array, ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement) => void
) {
  return function start(audioSource: Source, canvas: HTMLCanvasElement) {
    const audioCtx = new window.AudioContext()
    // 创建AnalyserNode,它提供获取当前音频的频域数据的方法
    const analyser = audioCtx.createAnalyser()
    // 创建音频源
    const source = createSource()
    // 将声音数据定向到analyser节点
    source.connect(analyser)
    // 设置频域数据样本区域大小,其值是2的幂,它决定了analyser.frequencyBinCount大小
    analyser.fftSize = 32
    // fftSize的1/2,表示可视化的数据值的数量
    const bufferLength = analyser.frequencyBinCount
    // 将来收集频域数据的数组
    const dataArray = new Uint8Array(bufferLength)
    
    let id: number
    (function drawVisual() {
      // 反复收集当前音频的频域数据
      id = requestAnimationFrame(drawVisual)
      // 当前频率数据复制到传入的Uint8Array数组中,频率数据由0到255范围内的整数组成
      analyser.getByteFrequencyData(dataArray)
      const context = canvas.getContext('2d')
      draw(dataArray, context, canvas)
    }())
    
    return function cancel() {
      cancelAnimationFrame(id);
      source.disconnect(analyser);
      analyser.disconnect(audioCtx.destination);
    }

    function createSource() {
      if (isMediaStream(audioSource)) {
        return audioCtx.createMediaStreamSource(audioSource as MediaStream)
      } else if (isHTMLMediaElement(audioSource)) {
        // 将声音数据定向到音频设备(扬声器),如果没有则会导致媒体元素无声音
        analyser.connect(audioCtx.destination);
        return audioCtx.createMediaElementSource(audioSource as HTMLMediaElement)
      }
    }
  }
}
  • 代码中我们看到analyser.fftSize被写死32,他决定了收集频域数据的数组的长度为16,如果我们需要更多的数据怎么办,所以修改上述代码,使fftSize作为function audioVisualizer的参数。fftSize 属性的值必须是从3232768范围内的2的非零幂; 其默认值为2048.
typescript 复制代码
export function audioVisualizer(
  draw: (dataArray: Uint8Array, ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement) => void,
  options?: { fftSize: number }
) {
  return function start(audioSource: Source, canvas: HTMLCanvasElement) {
    ...
    const { fftSize = 2048 } = options || {}
    analyser.fftSize = fftSize
    ...
  }
}
  • 现在就已经实现好了,在扩展我要绘制的信号图案之前,先分析一下这个图如何绘制
  • 我需要红色部分,正方形表示canvas元素,需要canvas绘画的部分就是红色部分,使用CanvasRenderingContext2D.arc(x, y, radius, startAngle, endAngle, counterclockwise)来画这部分扇形,它接收6个参数,xy是圆心坐标,radius是半径,startAngleendAngle分别代表圆弧的起始点和终点,x 轴方向开始计算,单位以弧度表示,anticlockwise表示逆时针(true)还是顺时针(false)开始绘画,默认值false
  • 确定圆心
    • 由上面图能够看出,圆心坐标为 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( c a n v a s . w i d t h / 2 , c a n v a s . h e i g h t ) (canvas.width / 2, canvas.height) </math>(canvas.width/2,canvas.height),
  • 确定半径
    • 画的扇形半径由音量强度canvas.height元素决定,我需要画三个不同大小的扇形来表示音量强度,这三条线将canvas.height划分为四段,所以半径就以 <math xmlns="http://www.w3.org/1998/Math/MathML"> h e i g h t / 4 ∗ x height / 4 * x </math>height/4∗x表示,x为音量强度等级。
  • 确定弧度
    • 圆的一周为,画出一个圆的参数是CanvasRenderingContext2D.arc(x, y, radius, 0, 2 * Math.PI),默认顺时针绘画,如下图所示,可以看出我需要的弧度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 π ∗ 5 / 8 2π * 5 / 8 </math>2π∗5/8到 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 π ∗ 7 / 8 2π * 7 / 8 </math>2π∗7/8
    • 如果逆时针画的话弧度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> − 2 π ∗ 1 / 8 -2π * 1 / 8 </math>−2π∗1/8到 <math xmlns="http://www.w3.org/1998/Math/MathML"> − 2 π ∗ 3 / 8 -2π * 3 / 8 </math>−2π∗3/8
  • 全部参数已确认,现在就实现绘画函数
typescript 复制代码
function draw(dataArray, ctx, canvas) => {  
  const width =  canvas.width;
  const height = canvas.height;
  // 频率数据最大值, dataArray频率数据由0到255范围内的整数组成
  const max = Math.max(...dataArray);
  // 频率最大值占canvas.height的部分
  const av_height = max * height / 255
  ctx.fillRect(0, 0, width, height) // 重置画布样式
  ctx.lineWidth = 1 // 线段宽度
  ctx.strokeStyle = "#73c991" // 线段颜色
  const base_y = height / 4 // 三条线分四等分,三条线代表三个音量等级
  let lv = 1 // 音量等级
  while(av_height > base_y * lv) {
    ctx.beginPath();
    ctx.arc(width / 2, height, base_y * (lv - 0.5), Math.PI / 4 * 5,  Math.PI / 4 * 7);
    ctx.stroke()
    lv++
  }
}
// 注册draw,得到一个音频可视化函数;
// 取频率数据最大值,不需太多的频域数据,fftSize给最小值即可
export const audioVisible = audioVisualizer(draw, { fftSize: 32 }) 
  • 注册draw后得到一个新的函数,接收两个参数,第一个是音频源,可以是HTML元素MediaStream媒体流,第二个参数就是你要绘画的canvas元素。

测试

  • 代码
html 复制代码
<template lang="">
  <div class="audio-visualizer">
    <canvas ref="canvas" width="100" height="100" style="border-radius: 20%"></canvas>
    <!-- &nbsp; -->
    <!-- <audio ref="audio" controls :src="audioSrc"></audio> -->
  </div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount } from "vue"
import { audioVisible } from "@/utils/audio/audioVisualizer";
import audioSrc from "@/assets/Stay_tonight.wav"

const canvas = ref<HTMLCanvasElement>(null)
const audio = ref<HTMLAudioElement>(null)
let close = () => {}

onMounted(() => {
  // HTMLMediaElement
  // audio.value.onloadedmetadata = () => {
  //   audio.value.play();
  //   close = audioVisual(audio.value, canvas.value)
  // };

  // MediaStream 
  navigator.mediaDevices.getUserMedia({audio: true}).then((stream) => {
    close = audioVisible(stream, canvas.value)
  })
})

onBeforeUnmount(() => {
  close()
})
</script>
  • 效果

扩展阅读

参考

未经作者授权 禁止转载

相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang2 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、5 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui