AudioContext 实现音频流实时播放

使用场景

在项目中开发中,遇到这样的需求:有一段文字,需要通过后台接口转成语音传到前端进行播放。 因为文字是实时生成的,为保证实时性,需要在生成文字的过程中,转为一段一段的音频流通过websocket传递到前端,前端拿到音频流后立即开始播放,接收到后续的音频流后追加到播放音频里继续播放,达到实时生成文字,实时转换音频流,前端实时播放的效果。

解决方案

刚接到这个需求时,想到的解决方案是这样的

  1. 前端接收到音频数据后放入缓存数组
  2. 检测缓存数组中是否存在音频数据
  3. 存在音频数据则将音频数据转为Audio的src播放出来
  4. Audio播放完毕后转到第2步继续检测

实现后发现在音频传递过程中,每段音频和文字的断句并不一样,两段音频断在一个字的音中间,但是Audio的音频解析到播放需要消耗时间,导致播放时会有卡顿的感觉。 后来了解到Web Audio API中的AudioContext接口可以处理音频流数据并播放,就有了下面的方案。

  1. 创建AudioContext/MediaSource接口实例
  2. MediaSource实例打开后创建sourceBuffer,并监听update事件
  3. 接收到音频流数据后查看sourceBuffer是否空闲
  4. 如果sourceBuffer处于空闲状态,则将音频流追加到sourceBuffer内并开始播放
  5. 如果sourceBuffer处于工作状态,则将音频流放入缓存数组待用
  6. sourceBuffer监听到update事件后表示sourceBuffer空闲,则检测缓存数据是否有音频数据,如有则执行第4步

音频实时播放类

ts 复制代码
// 音频实时播放
class AudioPlayer {
  mediaSource: MediaSource // 媒体资源
  audio: HTMLAudioElement // 音频元素
  audioContext: AudioContext // 音频上下文
  sourceBuffer?: SourceBuffer // 音频数据缓冲区
  cacheBuffers: ArrayBuffer[] = [] // 音频数据列表
  pauseTimer: number | null = null // 暂停定时器

  constructor() {
    const AudioContext = window.AudioContext
    this.audioContext = new AudioContext()

    this.mediaSource = new MediaSource()

    this.audio = new Audio()
    this.audio.src = URL.createObjectURL(this.mediaSource)

    this.audioContextConnect()
    this.listenMedisSource()
  }

  // 连接音频上下文
  private audioContextConnect() {
    const source = this.audioContext.createMediaElementSource(this.audio)
    source.connect(this.audioContext.destination)
  }

  // 监听媒体资源
  private listenMedisSource() {
    this.mediaSource.addEventListener('sourceopen', () => {
      if (this.sourceBuffer) return

      this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg')

      this.sourceBuffer.addEventListener('update', () => {
        if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
          const cacheBuffer = this.cacheBuffers.shift()!
          this.sourceBuffer?.appendBuffer(cacheBuffer)
        }

        this.pauseAudio()
      })
    })
  }

  // 暂停音频
  private pauseAudio() {
    const neePlayTime = this.sourceBuffer!.timestampOffset - this.audio.currentTime || 0

    this.pauseTimer && clearTimeout(this.pauseTimer)
    // 播放完成5秒后还没有新的音频流过来,则暂停音频播放
    this.pauseTimer = setTimeout(() => this.audio.pause(), neePlayTime * 1000 + 5000)
  }

  private playAudio() {
    // 为防止下一段音频流传输过来时,上一段音频已经播放完毕,造成音频卡顿现象,
    // 这里做了1秒的延时,可根据实际情况修正
    setTimeout(() => {
      if (this.audio.paused) {
        try {
          this.audio.play()
        } catch (e) {
          this.playAudio()
        }
      }
    }, 1000)
  }

  // 接收音频数据
  public receiveAudioData(audioData: ArrayBuffer) {
    if (!audioData.byteLength) return

    if (this.sourceBuffer?.updating) {
      this.cacheBuffers.push(audioData)
    } else {
      this.sourceBuffer?.appendBuffer(audioData)
    }

    this.playAudio()
  }
}

export default AudioPlayer

WebSocket 封装

如果websocket需要支持心跳、重连等机制可以查看WebSocket 心跳检测,断开重连,消息订阅 js/ts

ts 复制代码
const BASE_URL = import.meta.env.VITE_WS_BASE_URL

type ObserverType<T> = {
  type: string
  callback: (data: T) => void
}

class SocketConnect<T> {
  private url: string
  public ws: WebSocket | undefined //websocket实例
  private observers: ObserverType<T>[] = [] //消息订阅者列表
  private waitingMessages: string[] = [] //待执行命令列表
  private openCb?: () => void

  constructor(url = '', openCb?: () => void) {
    this.url = BASE_URL + url
    if (openCb) this.openCb = openCb
    this.connect()
  }

  //websocket连接
  connect() {
    this.ws = new WebSocket(this.url)

    this.ws.onopen = () => {
      this.openCb && this.openCb()
      // 发送所有等待发送的信息
      const length = this.waitingMessages.length
      for (let i = 0; i < length; ++i) {
        const message = this.waitingMessages.shift()
        this.send(message)
      }
    }

    this.ws.onclose = (event) => {
      console.log('webSocket closed:', event)
    }

    this.ws.onerror = (error) => {
      console.log('webSocket error:', error)
    }

    this.ws.onmessage = (event: MessageEvent) => {
      this.observers.forEach((observer) => {
        observer.callback(event.data)
      })
    }
  }

  //发送信息
  send(message?: string) {
    if (message) {
      //发送信息时若websocket还未连接,则将信息放入待发送信息中等待连接成功后发送
      if (this.onReady() !== WebSocket.OPEN) {
        this.waitingMessages.push(message)
        return this
      }

      this.ws && this.ws.send(message)
    }

    return this
  }

  //订阅webSocket信息
  observe(callback: (data: T) => void, type = 'all') {
    const observer = { type, callback }
    this.observers.push(observer)

    return observer
  }

  //取消订阅信息
  cancelObserve(cancelObserver: ObserverType<T>) {
    this.observers.forEach((observer, index) => {
      if (cancelObserver === observer) {
        this.observers.splice(index, 1)
      }
    })
  }

  // 关闭websocket
  close() {
    this.ws && this.ws.close()
  }

  // websocket连接状态
  onReady() {
    return this.ws && this.ws.readyState
  }
}

export default SocketConnect

工具函数

ts 复制代码
// 从十六进制字符串转换为字节数组
export function hexStringToByteArray(hexString: string): Uint8Array {
  const byteArray: number[] = []
  for (let i = 0; i < hexString.length; i += 2) {
    byteArray.push(parseInt(hexString.substring(i, i + 2), 16))
  }
  return new Uint8Array(byteArray)
}

// 从字节数组转换为 ArrayBuffer
export function byteArrayToArrayBuffer(byteArray: Uint8Array): ArrayBuffer {
  const arrayBuffer = new ArrayBuffer(byteArray.length)
  const uint8Array = new Uint8Array(arrayBuffer)
  uint8Array.set(byteArray)
  return arrayBuffer
}

// 从十六进制字符串转换为 ArrayBuffer
export function hexStringToArrayBuffer(hexString: string): ArrayBuffer {
  return byteArrayToArrayBuffer(hexStringToByteArray(hexString))
}

函数调用

ts 复制代码
const ws = new SocketConnect<string>('/audio')

const audioPlayer = new AudioPlayer()

ws.observe((data) => {
    console.log('receivebytes:'+new Date().getTime())
    // 接收到的16进制字符串数据转换为ArrayBuffer传递给audioPlay
    const arrayBuffer = hexStringToArrayBuffer(data)
    audioPlayer.receiveAudioData(arrayBuffer)
})
相关推荐
dvlinker1 天前
【音视频开发】使用支持硬件加速的D3D11绘图遇到的绘图失败与绘图崩溃问题的记录与总结
音视频开发·c/c++·视频播放·d3d11·d3d11绘图模式
音视频牛哥6 天前
Android平台GB28181实时回传流程和技术实现
音视频开发·视频编码·直播
音视频牛哥8 天前
RTMP、RTSP直播播放器的低延迟设计探讨
音视频开发·视频编码·直播
音视频牛哥12 天前
电脑共享同屏的几种方法分享
音视频开发·视频编码·直播
x007xyz2 个月前
前端纯手工绘制音频波形图
前端·音视频开发·canvas
音视频牛哥2 个月前
Android摄像头采集选Camera1还是Camera2?
音视频开发·视频编码·直播
九酒2 个月前
【harmonyOS NEXT 下的前端开发者】WAV音频编码实现
前端·harmonyos·音视频开发
音视频牛哥2 个月前
结合GB/T28181规范探讨Android平台设备接入模块心跳实现
音视频开发·视频编码·直播
哔哩哔哩技术2 个月前
自研点直播转码核心
音视频开发