【javascript】录音可视化


前言

分享一些在开发语音识别功能遇到的问题和解决方法,包含音频数据格式转换,大文件上传,录音权限获取,音频可视化


一、音频数据格式

1. 常见的数据格式

从音质,文件大小,应用场景三方面对比如下,文件大小无损(pcm,wav,flac)大于有损(mp3,ogg,opus),实时语音需要低延迟opus,而高进度语音识别需要高质格式wav或pcm

数据格式音质文件大小应用场景
MP3 (MPEG-1 Audio Layer III)有损压缩,支持多种比特率(如128/192/320kbps)较小适合网络传输和移动设备,音质在较高比特率下接近无损,但不适合高精度语音识别
OGG (Ogg Vorbis)有损压缩,支持多种比特率,类似mps3,但通常提供更好的压缩效率和音质较小适合网络传输和移动设备,不适合高精度语音识别
Opus高效的有损压缩,音质在低比特率下表现良好,支持多种比特率和采样率较小适合实时语音通信和语音识别系统,尤其是需要低延迟的场景
WMA (Windows Media Audio)有损压缩,支持多种比特率,类似mps3,但通常提供更好的压缩效率和音质,专为Windows平台优化较小适合Windows平台的音频传输和存储,不适合高精度语音识别
AMR (Adaptive Multi-Rate)有损未压缩,专为移动通信设计,能够根据网络条件动态调整比特率较小广泛用于移动设备的语音通话和语音识别
WAV (Waveform Audio File Format)无损压缩,支持多种采样率(8/16/44.1khz)和位深(8/6位)较大广泛用于语音识别、音频编辑和存储
FLAC (Free Lossless Audio Codec)无损压缩,能够将音频压缩到大约原始大小的一半,同时保持音质不变比wav小适合需要高质量音频但又希望减小文件大小的场景
PCM (Pulse Code Modulation)无损未压缩,直接将模拟音频信号转换为数字信号,提供最高的音质,支持多种采样率和位深较大常用于语音识别系统中的原始音频数据处理

二、获取音频方式

1. 用FileReader对象读取音频文件

FileReader 只能访问用户明确选择的文件内容,可以从input标签选择文件,或者从拖放对象里获取,可以将文件读取以二进制和文本输出,reader.readAsArrayBuffer(data)//data是 Blob 或 File 对象,reader.readAsDataURL(data),reader.readAsText(data),以下是通过input方式:

<input ref="fileInput" type="file" @change="getFile" />
const getFile = async (event: any) => {
  const file = event.target.files[0];
  const reader = new FileReader();
  reader.onload = (e: any) => {
  	const arrayBuffer = e.target.result;
  };
  reader.readAsArrayBuffer(file);
};

2. 通过url请求获取音频数据

以下是通过fetch获取本地音频资源:

import testurl from "@/assets/test.mp3"
async function fetchAndConvertToBlob(url: string) {
   const response = await fetch(url)
   const blob = await response.blob()
   return blob
}

3. 录音获取音频流

(1)设置触发事件
Pointer Events 提供了一种统一的方式来处理不同类型的输入设备,包括鼠标、触摸屏和手写笔。可能会存在一些旧版本兼容问题,(推荐polyfill和shim处理兼容

触摸事件防抖处理

const debounce = (fn: any, delay: any) => {
  let timer: any
  return (...args: any[]) => {
    if (timer) {
      clearTimeout(timer) // 清除前一个定时器
    }
    timer = setTimeout(() => {
      fn(...args) // 调用函数,传递参数
    }, delay)
  }
}

const debounceStartRecord = debounce(startRecord,500)
const debounceEndRecord = debounce(endRecord,500)
// startRecord和endRecord实现见下,音频格式转换处理

(2)判断应用设备麦克风授权,支持录音API

const handlePermissionDenied = async (fn: () => void) => {
  const checkPermissionSupport = 'permissions' in navigator
  const checkMediaDevicesSupport = async () => {
    if ('mediaDevices' in navigator && 'getUserMedia' in navigator.mediaDevices) {
      try {
        // 使用getUserMedia方法获取麦克风权限
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        // 录音权限已授权,去释放资源
        stream.getTracks().forEach((track) => track.stop());
        return true
      } catch (error) {
        console.error("navigator getUserMedia failed:", error);
        return false
      }
    } else {
      return false
    }

  }

  const permissionDeniedMessage = "您已拒绝麦克风权限。请在设置中授权麦克风权限,以便使用录音功能。";
  const permissionPromptMessage = "浏览器将提示您授权麦克风权限。请在设置中授权麦克风权限,以便使用录音功能。";
  const permissionQueryFailedMessage = "无法查询麦克风权限状态。请在设置中授权麦克风权限,以便使用录音功能。";
  const deviceNotSupportedMessage = "您的设备不支持录音功能。建议您使用支持该功能的浏览器或更新系统版本";

  if (checkPermissionSupport) {
    console.log("navigator.permissions is supported");
    try {
      const permissionStatus = await navigator.permissions.query({ name: "microphone" as PermissionName });
      console.log('permissionStatus', permissionStatus);
      if (permissionStatus.state === "granted") {
        // 录音权限已授权
        fn();
      } else if (permissionStatus.state === "prompt") {
        // 浏览器将提示用户进行授权,部分浏览器没有提示,需直接请求媒体设备
        const mediaDevicesPermission = await checkMediaDevicesSupport();
        if (mediaDevicesPermission) {
          // 录音权限已授权
          fn();
        } else {
          alert(permissionPromptMessage);
        }
      } else if (permissionStatus.state === "denied") {
        // 录音权限被拒绝
        alert(permissionDeniedMessage);
      }
    } catch (error) {
      console.error("Permission query failed:", error);
      alert(permissionQueryFailedMessage);
    }
  } else if (await checkMediaDevicesSupport()) {
    console.log("navigator.permissions is not supported, but navigator.mediaDevices.getUserMedia is supported");
    fn();
  } else {
    alert(deviceNotSupportedMessage);
  }
};

(3)使用audio-recorder-polyfill库处理音频文件转换

满足兼容不同系统和设备的WebRTC录音api支持,并提供释放麦克风方法,可以转换wav文件,自定义采样位数 (sampleBits)16,采样率(sampleRate)16000Hz,声道(numChannels)1

开始录音

const startRecord = (event?: any) => {
  event?.stopPropagation()
  handlePermissionDenied(() => {
    console.log("开始录音", isRecording.value)
    isPermissioning = false // 避免重复点击
    if (!isRecording.value) {
      try {
        navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
          pcmRecorder.value = new AudioRecorder(stream, {
            sampleBits: 16, // 采样位数,支持 8 或 16,默认是16
            sampleRate: 16000, // 采样率,支持 11025、16000、22050、24000、44100、48000,根据浏览器默认值,我的chrome是48000
            numChannels: 1 // 声道,支持 1 或 2, 默认是1
          })
          pcmRecorder.value.start()
          changeRecordState("start")
        })
      } catch (error) {
        console.error("开始录音失败", error)
      }
    }
  })
}

结束录音

const endRecord = (e?: any) => {
  e?.preventDefault()
  console.log("结束录音", isRecording.value, isPermissioning)
  if (isPermissioning) {
    isRecording.value = false
  }
  if (isFouceEnd || !isRecording.value) {
    return
  }
  try {
    if (pcmRecorder.value) {
      // console.log(pcmRecorder.value)
      pcmRecorder.value.stop()
      pcmRecorder.value.addEventListener("dataavailable", async (e: any) => {
        pcmData.value = e.data
        // console.log("pcmData.value", pcmData.value)
        let base64String: any = await blobToBase64(pcmData.value)
        // console.log("pcmstr", base64String)
        base64String = base64String.split(",")[1]
        audioToText({ voice: base64String }).then((res: any) => {
          console.log("res", res)
          if (res.text) {
            curText.value = res.text
            sendMsg({ type: "model" })
          } else {
            // 判断当前识别成功的文字,如果第一次对话res.text为空,仍然展示关键字跳转
            if (msgList.value.length == 1) {
              isShowLink.value = true
            }
          }
        })
      })
      if (pcmRecorder.value && pcmRecorder.value.stream) {
        pcmRecorder.value.stream.getTracks().forEach((track: any) => {
          track.stop()
          track.applyConstraints({ echoCancellation: false }).catch((error: any) => {
            console.error("更新轨道状态失败2:", error)
          })
        })
        pcmRecorder.value.stream = null // 释放 MediaStream 对象
      }
      changeRecordState("end")
    }
  } catch (error) {
    changeRecordState("end")
    console.error("停止录音失败:", error)
  }
}

@vitejs/plugin-legacy 插件的主要作用是为旧版浏览器提供兼容性支持,通过 Babel 转译代码,使其可以在旧版浏览器中运行。它主要处理 JavaScript 语法的兼容性问题,而不是特定的 Web API(如 navigator.mediaDevices.getUserMedia 或 navigator.permissions)。这里推荐webrtc-adapter 和 permissions-api ,webrtc-adapter 是一个 Polyfill,用于解决 WebRTC API 的兼容性问题,特别是 navigator.mediaDevices.getUserMedia 方法的兼容性问题;permissions-api 是一个 Polyfill,用于解决 navigator.permissions API 的兼容性问题。navigator.permissions 是一个较新的 API,用于查询和监听权限状态。

Recorder.js 提供了更底层的控制,适合需要自定义音频处理的场景。
AudioRecorder 是一个更简单的封装,适合快速实现录音功能。
https://blog.csdn.net/gitblog_00046/article/details/137811883
两者都能满足你的需求(16 位采样位数、16000Hz 采样率、单声道),并在录音结束后释放麦克风资源。

三、音频格式转换

二进制数据转换
在这里插入图片描述

数据格式概念场景
Data URL是一种将文件内容直接嵌入到页面中的方式,通常以 data: 协议开头。它将文件内容编码为 Base64 字符串适合嵌入到 HTML 或 CSS 中,便于显示和传输,适合小文件,会增加页面大小,可能受浏览器存储限制
Blob URL是一种指向 Blob 对象的 URL,通常以 blob: 协议开头临时的url,适用于大文件(如音频,视频),便于在 或 标签中直接播放,不增加页面大小
Blob(Binary Large Object)表示了一个不可变、原始数据的类文件对象。Blob 可以包含任意类型的数据,如文本、二进制数据、JSON 等常用于处理文件上传、下载等操作。Blob 的大小可以从 0 到浏览器所允许的最大值不等
ArrayBuffer表示通用的、固定长度的原始二进制数据缓冲区。ArrayBuffer 的内容不能直接操作,只能通过 DataView 对象或 TypedArrray 对象来访问适合底层处理,如音频分析或使用 Web Audio AP

备注: Blob.arrayBuffer() 方法是一种较新的基于 Promise 的 API,用于将文件读取为数组缓冲区。
const encodedData = window.btoa(“Hello, world”); // 编码字符串
const decodedData = window.atob(encodedData); // 解码字符串

1. buffer和blob互相转换


const fileToBlob = (file: File) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = function (e: any) {
      const blob = new Blob([e.target.result], { type: file.type });// buffer转blob
      const blobUrl = URL.createObjectURL(blob);//创建blob url
      resolve(blobUrl)
    };
    reader.onerror = (error) => {
      reject(error);
    };
    reader.readAsArrayBuffer(file); // 读取为 ArrayBuffer
  })
}

const bufferToAudioUrl = (buffer: any) => {
  /*
    MIME 类型:
    1、 mp3:audio/mpeg 或 audio/mp3(所有主流浏览器均支持)
    2、 wav:/wav、audio/wave 或 audio/x-wav(所有主流浏览器均支持)
    3、 mp4:audio/mp4 或 audio/x-m4a(所有主流浏览器均支持)
    4、 ogg:audio/ogg(Chrome、Firefox、Edge 支持,但 Safari 不支持)
    5、 webm:audio/webm(Chrome、Firefox、Edge 支持,但 Safari 支持有限)
  */
  const blob = new Blob([buffer], { type: "audio/mpeg" });
  return URL.createObjectURL(blob); //创建blob url
}

export const blobToBuffer = (blob: Blob) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      resolve(reader.result); // reader.result 是 ArrayBuffer
    };
    reader.onerror = (error) => {
      reject(error);
    };
    reader.readAsArrayBuffer(blob);
  });
}

2. blob转base64

const blobToBase64 = (blob: Blob) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => {
      resolve(reader.result)
    }
    reader.onerror = (error) => {
      reject(error)
    }
    reader.readAsDataURL(blob) // 将 Blob 转换为 Base64 字符串
  })
}

3. base64转Uint8Array

export function base64ToUint8Array(base64: string) {
  // 解码Base64字符串
  const byteCharacters = atob(base64.trim());
  // 将解码后的二进制字符串转换为Uint8Array
  const byteNumbers = new Uint8Array(byteCharacters.length);
  for (let i = 0; i < byteCharacters.length; i++) {
    byteNumbers[i] = byteCharacters.charCodeAt(i);
  }
  return byteNumbers;//byteNumbers.buffer是arraybuffer类型
};

四、音频可视化

主要是通过arraybuffer进行音频可视化将音频文件读取为 ArrayBuffer 是一种更底层的方法,适用于需要对音频数据进行进一步处理的场景,例如使用 Web Audio API 进行音频分析或处理。以下是实现思路和部分代码:

  1. 使用AudioContext创建音频上下文,获取音频节点source
    创建音频节点source可以3种方式:audiodom获取audCtx.createMediaElementSource(data),MediaStream媒体流audCtx.createMediaStreamSource(data),音频转成AudioBufferaudCtx.createBufferSource()
  2. 创建音频分析对象audCtx.createAnalyser(),并将音频源(source)连接到音频分析器source.connect(analyser)
  3. 启动音频源播放source.start(0)
  4. 绘制平滑动画使用canvasrequestAnimationFrame
  5. 从分析对象获取当前音频的频率数据,并将其填充到指定的 Uint8Array 数组analyser.getByteFrequencyData(dataArray)
  6. 使用dataArray分析数据进行音频条纹绘制
// 音频分析器
const initAudioAnalyser = async (
  data: any,
  type: "audiodom" | "mediastream" | "arraybuffer"
) => {
  // console.log(data, type);
  if (data) {
    // 创建音频节点source可以3种方式:audiodom获取,MediaStream媒体流,音频转成AudioBuffer
    if (type == "audiodom") {
      isRecording.value = true;
      animationLoop();
      source = audCtx.createMediaElementSource(data);
    } else if (type == "mediastream") {
      source = audCtx.createMediaStreamSource(data);
    } else if (type == "arraybuffer") {
      isRecording.value = true;
      animationLoop();
      source = audCtx.createBufferSource();
      const uint8Array = base64ToUint8Array(data);
      dataArray = uint8Array;
      const arrayBuffer = uint8Array.buffer;
      /*
            返回audiodom实现思路
      const audioUrl = bufferToAudioUrl(arrayBuffer);
      const audioPlayer: any = document.getElementById("audioPlayer");
      audioPlayer.src = audioUrl;
      audioPlayer.play();
      source = audCtx.createMediaElementSource(audioPlayer);
      */

      if (arrayBuffer instanceof ArrayBuffer) {
        try {
          const audioBuffer = await audCtx.decodeAudioData(arrayBuffer);
          console.log("audioBuffer 解码成功:", audioBuffer);
          source.buffer = audioBuffer;
        } catch (error: any) {
          isRecording.value = false;
          console.error("解码音频数据时出错:", error);
          // 报解码音频数据时出错: DOMException: Failed to execute 'decodeAudioData' on 'BaseAudioContext': Unable to decode audio data
          // 这是因为 ArrayBuffer 中的数据不是有效的音频文件格式
        }
      }
    }
    analyser = audCtx.createAnalyser();
    analyser.fftSize = 256; // 设置 FFT 大小,影响频率分析的精度,常见的值有 128, 256, 512, 1024, 2048
    console.log(
      "Audio stream connected to AnalyserNode:",
      source.numberOfOutputs > 0
    ); //为true表明已经连接到一个源
    //创建数组,用于接收分析器节点的分析数据
    dataArray = new Uint8Array(analyser.frequencyBinCount);//analyser.frequencyBinCount表示当前频率的数组长度
    source.connect(analyser);
    // analyser.connect(audCtx.destination); //输出设备 播放声音(如果需要将音频输出到扬声器,可以再连接到 audCtx.destination:)

    if (audCtx.state === "suspended") {
      // 如果你的代码在没有用户交互的情况下执行,AudioContext 可能不会从 suspended 状态变为 running
      source.start(0);
      source.onended = function () {
        console.log("音频播放结束");
        isRecording.value = false;
      };
    }
    console.log(
      "initAudioAnalyser",
      audCtx.state,
      audCtx.sampleRate,
      dataArray
    );
  }
};

// 绘制音频波动的函数
const draw = () => {
  if (!analyser) {
    return;
  }
  analyser.getByteFrequencyData(dataArray); //让分析器节点分析出数据到数组中
  const volumeThreshold = 40; // 设置音量阈值,只有当音量高于这个值时才更新可视化
  // 计算平均振幅
  let sum = 0;
  for (let i = 0; i < dataArray.length; i++) {
    sum += dataArray[i];
  }
  const averageAmplitude = sum / dataArray.length;// 计算平均振幅
  console.log(audCtx.state, dataArray, averageAmplitude, volumeThreshold);

  // 根据平均振幅决定是否更新可视化
  if (averageAmplitude > volumeThreshold) {
    // 更新可视化效果
    // console.log("音量足够,更新可视化")
    /*
      dataArray一直为0,可能导致的原因
      (1)音频未正确播放,audCtx.state不是running
      (2)source是否正确连接analyser,source.connect(analyser)
      (3)getByteFrequencyData调用时机不正确,是否音频播放后调用,需要持续调用,volumeThreshold设置音量
      (4)采样率不匹配(audCtx.sampleRate和audiobuffer.sampleRate)
      (5)调试时的时间延迟问题:AnalyserNode必须在音频播放一定时间后才能提供有效的数据

      如果音频源被静音或音量为0
    */
    // console.log("音乐节点", dataArray)
    const bufferLength = dataArray.length / 2.5; //一般两半波幅
    const barWidth = width / bufferLength;
    // 清空画布
    ctx.clearRect(0, 0, width, height);
    ctx.fillStyle = "#000000";
    for (let i = 0; i < bufferLength; i++) {
      const data = dataArray[i]; //<256
      const barHeight = (data / 255) * height; // 乘以height放大波幅
      // console.log(barHeight)
      // const x = i * barWidth
      const x1 = i * barWidth + width / 2;
      const x2 = width / 2 - (i + 1) * barWidth;
      // const y = height - barHeight //底部对齐
      const y = (height - barHeight) / 2; //中心对其
      // ctx?.fillRect(x, y, barWidth - 3, barHeight)
      ctx?.fillRect(x1, y, barWidth - 4, barHeight);
      ctx?.fillRect(x2, y, barWidth - 4, barHeight);
    }
  } else {
    // 音量较低,不更新可视化
    // console.log("音量较低,跳过可视化更新")
    ctx.clearRect(0, 0, width, height);
  }
};

const animationLoop = () => {
  if (!isRecording.value) {
    return;
  }
  draw();
  requestAnimationFrame(animationLoop);
};

在这里插入图片描述


五、大文件上传

语音识别为了保证识别准确性,选用高质量音频数据wav格式。由于WAV格式文件较大,直接上传可能导致网络传输效率低下或失败。因此,采用分片上传的方式将大文件分割成多个小片段依次上传。常见的分片上传方式有两种:

1. 固定大小分片

将文件按照固定的字节大小(如1MB)进行切分。这种方式实现简单,但可能会导致最后一个分片过小。这里提供两种传输方式 http 和 websocket,分片上传包含前端分片(遍历分割),分片上传(接口上传),分片合并(服务端),这里主要展示前两部分。

特性数据类型传输效率适用场景内存占用实现复杂度
HTTP application/octet-stream二进制(ArrayBuffer、Blob)文件上传、下载中等
WebSocket 二进制帧二进制(ArrayBuffer)实时文件传输
Base64 编码文本字符串低(体积增加 33%)小文件嵌入

(1)http

export const sendFile1 = (blob: any, sliceSize: any) => {
  let resstext = ''
  if (!blob) {
    return
  } else {
    const sliceNum = Math.ceil(blob.size / sliceSize)
    const fun: any = async () => {
      for (let i = 0; i < sliceNum; i++) {
        const start = i * sliceSize
        const end = Math.min(start + sliceSize, blob.size)
        const chunk = blob.slice(start, end) // 获取切片
        const isLastChunk = i === sliceNum - 1
        await new Promise((r) => {
        //audioToText参数分别:文件切片,是否最后一片,第几片
          audioToText(chunk, isLastChunk ? "1" : "0", i + 1).then((res: any) => {
            for (const item of res.body) {
              console.log(item)
              if (item?.ansStr) {
                resstext = resstext + JSON.parse(item.ansStr).ws_s
              }
              if (item.endFlag) {
                console.log('结束发送',resstext);
                break
              }
            }
            r(res)
          })
        })
      }
    }
    fun()
  }
}

(2)websocket

export const sendFile2 = (blob: Blob, sliceSize: number) => {
  let resstext = '';
  if (!blob) return;

  const sliceNum = Math.ceil(blob.size / sliceSize);
  const socket = new WebSocket('wss://example.com/socket'); // 替换为实际的 WebSocket 地址

  socket.onopen = () => {
    console.log('WebSocket connection established');

    const uploadChunks = async () => {
      for (let i = 0; i < sliceNum; i++) {
        const start = i * sliceSize;
        const end = Math.min(start + sliceSize, blob.size);
        const chunk = blob.slice(start, end); // 获取切片
        const isLastChunk = i === sliceNum - 1;

        const message = {
          chunk: chunk,
          isLastChunk: isLastChunk,
          chunkIndex: i + 1
        };

        socket.send(JSON.stringify(message));//实时发送
      }
    };

    uploadChunks();
  };

  socket.onmessage = (event) => {
    const response = JSON.parse(event.data);
    console.log('Received message from server:', response);

    if (response.body) {
      for (const item of response.body) {
        if (item?.ansStr) {
          resstext += JSON.parse(item.ansStr).ws_s;
        }
        if (item.endFlag) {
          console.log('获取识别后的文字', resstext);
          socket.close();
          break;
        }
      }
    }
  };

  socket.onerror = (error) => {
    console.error('WebSocket error:', error);
  };

  socket.onclose = () => {
    console.log('WebSocket connection closed');
  };
};

2. 按时间戳分片

根据录音的时间戳来划分片段,例如每秒生成一个分片。这种方式更适用于流式传输场景,能够更好地适应不同长度的音频文件。

//...此处省去录音开始和结束方法
let chunks = []; // 存储录音数据
mediaRecorder.ondataavailable = (event) => {
// 监听录音结束拿到录音数据
        if (event.data.size > 0) {
            chunks.push(event.data);

            // 按时间戳分片(例如每秒生成一个分片)
            const currentTime = Date.now();
            if (currentTime - startTime >= 1000) {
                const blob = new Blob(chunks, { type: 'audio/webm' });
                uploadChunk(blob); // 上传分片
                chunks = []; // 清空当前分片数据
                startTime = currentTime; // 重置开始时间
            }
        }
    };
    
// 上传分片
function uploadChunk(blob) {
    const formData = new FormData();
    formData.append('file', blob, `chunk_${Date.now()}.webm`);

    fetch('/upload', {
        method: 'POST',
        body: formData,
    })
    .then(response => response.json())
    .then(data => {
        console.log('分片上传成功:', data);
    })
    .catch(error => {
        console.error('分片上传失败:', error);
    });
}

总结

以上内容主要是围绕录音api拓展的功能和知识点,有FileReader文件读取,canvas绘制,文件分片上传都可以单独了解,希望可以帮助做为一些实现方案的参考,有错误遗漏希望指出,后续还会补充完善~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值