Skip to content

音频录制

🎤 面试回答稿:浏览器音频录制模块封装

在项目中我封装过一个浏览器端的音频录制模块,主要用于用户留言或语音输入场景,技术上是基于 MediaRecorder 和 Vue 3 的组合式 API 来实现的,整体采用了工厂函数的形式封装,便于多处复用和状态隔离。

当调用这个模块的工厂函数时,会生成一个包含录音控制方法、播放、上传、时长计算等功能的实例对象。使用者可以直接调用这些方法来控制录音全流程,状态数据也是响应式的,方便在组件中直接绑定 UI。

🚀 功能点方面主要包括:

  • 开始录音:会清空上次的音频缓存,设置为录音中状态,并启动 MediaRecorder 进行录音,同时开启一个定时器,每秒计算一次录音时长,并转换成分钟:秒格式展示出来。

  • 录音时长控制:支持传入最大录音时长参数,内部会自动对时长做限制,一旦超过时间会自动调用停止录音,防止过长录音。

  • 结束录音:停止 MediaRecorder 的同时清除定时器,更新状态,保存录音片段数据。

  • 上传功能:使用 axios 将录音片段合并成一个 Blob 文件,通过 FormData 上传到后端接口,后端返回音频 URL 后会保存下来用于播放或展示。

  • 播放功能:基于 Blob 创建临时 URL,通过原生 Audio 类实现预览播放功能,用户可在上传前先试听录音。


⚙️ 技术实现方面:

  • 所有录音过程中的状态,包括是否正在录音、录音时长、Blob 数据队列、音频 URL 等,都是使用 Vue 的 ref 来实现响应式管理,组件中可以无缝绑定。

  • 模块本身是一个 async 工厂函数,在调用时会异步请求麦克风权限,只在真正需要的时候才初始化资源,避免在页面加载时过早弹出权限弹窗。

  • 在录音结束后,通过 Blob 对象来合并音频片段,不依赖第三方库就能完成录音上传、预览等核心功能。


✅ 模块亮点:

  • 模块结构清晰、职责分离,每个功能都通过独立的方法封装,如 startRecord()stopRecord()uploadAudio() 等,便于维护和调用。

  • 使用了懒初始化响应式状态管理结合的方式,提升了性能和使用体验。

  • 实现了自动录音时长监控,能在达到最大录音时间时自动处理逻辑,提升用户友好度。

  • 可轻松扩展到更多场景,如语音留言、语音识别前处理等。


🔧 后续可优化方向:

  • 支持更多编码格式,如接入 lamejs 转 MP3,提高兼容性;
  • 支持波形可视化,增强用户体验;
  • 抽离出为通用插件,支持 Vue 以外的框架;
  • 引入节流防抖机制,避免重复 start/stop 触发冲突;
  • 增加错误处理逻辑,比如权限拒绝时的提示,MediaRecorder 不支持的浏览器降级方案等。

📝 代码实现

ts
import axios from 'axios'
import { ref, Ref } from 'vue'

export interface InstanceRecord {
  startRecord: () => void
  stopRecord: () => void
  uploadAudio: () => Promise<void>
  playAudioStream: () => void
  audioStream: Ref<Blob[]>
  audioUrl: Ref<string>
  checkRecordedAudio: Ref<boolean>
  durationStr: Ref<string>
  duration: Ref<number>
}

function formatTime(seconds: number): string {
  const mins = Math.floor(seconds / 60)
  const secs = Math.floor(seconds % 60)
  return `${mins}:${secs < 10 ? '0' : ''}${secs}`
}

export default async function useRecord(maxDuration = 60): Promise<InstanceRecord> {
  const audioStream = ref<Blob[]>([])
  const audioUrl = ref('')
  const checkRecordedAudio = ref(false)
  const durationStr = ref('00:00')
  const duration = ref(0)

  let timer: number | undefined
  let startTime = 0

  const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
  const mediaRecorder = new MediaRecorder(stream)

  const updateDuration = () => {
    const now = Date.now()
    const seconds = (now - startTime) / 1000
    duration.value = seconds
    durationStr.value = formatTime(seconds)
    if (seconds >= maxDuration) stopRecord()
  }

  const clearTimer = () => {
    if (timer) clearInterval(timer)
    timer = undefined
  }

  mediaRecorder.addEventListener('dataavailable', (e) => {
    if (e.data.size > 0) {
      audioStream.value.push(e.data)
    }
  })

  mediaRecorder.addEventListener('stop', () => {
    checkRecordedAudio.value = false
    updateDuration()
    clearTimer()
  })

  const startRecord = () => {
    if (mediaRecorder.state === 'recording') return
    audioStream.value = []
    duration.value = 0
    durationStr.value = '00:00'
    startTime = Date.now()
    checkRecordedAudio.value = true
    mediaRecorder.start()
    timer = window.setInterval(updateDuration, 1000)
  }

  const stopRecord = () => {
    if (mediaRecorder.state !== 'recording') return
    mediaRecorder.stop()
  }

  const uploadAudio = async () => {
    try {
      const blob = new Blob(audioStream.value, { type: 'audio/ogg; codecs=opus' })
      const formData = new FormData()
      formData.append('file', blob)
      const { data } = await axios.post('http://localhost:3000/upload/record', formData, {
        headers: { 'Content-Type': 'multipart/form-data' },
      })
      audioUrl.value = data.url
    } catch (error) {
      console.error('音频上传失败:', error)
    }
  }

  const playAudioStream = () => {
    const blob = new Blob(audioStream.value, { type: 'audio/ogg; codecs=opus' })
    const audio = new Audio(URL.createObjectURL(blob))
    audio.play()
  }

  return {
    startRecord,
    stopRecord,
    uploadAudio,
    playAudioStream,
    audioStream,
    audioUrl,
    checkRecordedAudio,
    durationStr,
    duration,
  }
}