使用场景
在项目中开发中,遇到这样的需求:有一段文字,需要通过后台接口转成语音传到前端进行播放。 因为文字是实时生成的,为保证实时性,需要在生成文字的过程中,转为一段一段的音频流通过websocket传递到前端,前端拿到音频流后立即开始播放,接收到后续的音频流后追加到播放音频里继续播放,达到实时生成文字,实时转换音频流,前端实时播放的效果。
解决方案
刚接到这个需求时,想到的解决方案是这样的
- 前端接收到音频数据后放入缓存数组
- 检测缓存数组中是否存在音频数据
- 存在音频数据则将音频数据转为Audio的src播放出来
- Audio播放完毕后转到第2步继续检测
实现后发现在音频传递过程中,每段音频和文字的断句并不一样,两段音频断在一个字的音中间,但是Audio的音频解析到播放需要消耗时间,导致播放时会有卡顿的感觉。 后来了解到Web Audio API
中的AudioContext
接口可以处理音频流数据并播放,就有了下面的方案。
- 创建
AudioContext
/MediaSource
接口实例 MediaSource
实例打开后创建sourceBuffer
,并监听update
事件- 接收到音频流数据后查看
sourceBuffer
是否空闲 - 如果
sourceBuffer
处于空闲状态,则将音频流追加到sourceBuffer
内并开始播放 - 如果
sourceBuffer
处于工作状态,则将音频流放入缓存数组待用 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)
})