中国蓝tv手机版
143.87MB · 2025-10-13
大家好,最近在开发一个 AI 多模态项目时,需要实现 AI 回复内容的语音朗读功能。经过一番摸索和踩坑,终于做出了一个体验还不错的实时语音播放功能 —— 播放延迟能控制在 0.5-1 秒,用户几乎感觉不到等待。
今天就把这个功能的实现过程分享出来,包括技术选型、核心代码、踩过的坑和优化经验,希望能帮到有类似需求的朋友。
做语音合成首先要选合适的 TTS 服务,我对比了几个主流平台后,最终选了火山引擎平台的豆包 TTS,主要原因是它支持 WebSocket 流式传输(这对实时性很重要),而且音质不错、延迟低,文档也写得比较清楚,集成起来没那么费劲。
在调用豆包TTS需要到平台上先开通语音合成模型:
到火山引擎平台,管理页面开通:console.volcengine.com/ark/region:…
开通后需要到平台上获取认证信息:
获取APP ID 和 Access Token (放入环境变量文件.env)
豆包单向流式语音接入文档:www.volcengine.com/docs/6561/1…
整个功能的数据流大概是这样的:
用户点击播放 → 前端调用后端API → 后端连接TTS服务(WebSocket) → 接收音频流 → HTTP流式传输给前端 → 前端Audio元素实时播放
这里有几个关键设计点需要说明:
后端的核心工作是把 TTS 服务的 WebSocket 音频流转换成 HTTP 流式响应,传给前端。
火山引擎 TTS 用的是自定义二进制协议,帧结构是这样的:
[4字节头部] + [4字节事件类型] + [4字节会话ID长度] + [会话ID] + [4字节数据长度] + [数据内容]
为了处理这个协议,我封装了一个 TTSProtocol
类:
class TTSProtocol {
constructor() {
// 定义事件类型常量,方便后续判断
this.EVENT_TYPE = {
SESSION_FINISHED: 152, // 会话完成事件
AUDIO_DATA: 352, // 音频数据事件
}
}
// 构建请求帧:把请求参数转换成符合协议的二进制数据
buildRequestFrame(payload) {
// 把请求参数转换成JSON字符串,再转成Buffer
const payloadBuf = Buffer.from(JSON.stringify(payload))
// 协议头:固定格式,根据文档要求填写
const header = Buffer.from([0x11, 0x10, 0x10, 0x00])
// 4字节存储 payload 长度(大端序)
const sizeBuf = Buffer.alloc(4)
sizeBuf.writeUInt32BE(payloadBuf.length, 0)
// 拼接所有部分,形成完整请求帧
return Buffer.concat([header, sizeBuf, payloadBuf])
}
// 解析响应帧:把二进制响应转换成易于处理的对象
parseResponseFrame(frame) {
// 帧长度不足16字节,不符合协议,直接返回null
if (frame.length < 16) {
return null
}
// 读取事件类型(从第4字节开始,4字节长度)
const event = frame.readUInt32BE(4)
// 读取会话ID长度(从第8字节开始,4字节长度)
const sessionIdLen = frame.readUInt32BE(8)
// 计算数据长度的偏移量:12字节(前3个字段) + 会话ID长度
const dataLenOffset = 12 + sessionIdLen
// 读取数据长度(4字节)
const dataLen = frame.readUInt32BE(dataLenOffset)
// 计算音频数据的偏移量:16字节(前4个字段) + 会话ID长度
const dataOffset = 16 + sessionIdLen
// 提取音频数据
const data = frame.slice(dataOffset, dataOffset + dataLen)
return {
event,
isAudio: event === this.EVENT_TYPE.AUDIO_DATA, // 是否为音频数据
isFinished: event === this.EVENT_TYPE.SESSION_FINISHED, // 是否会话结束
data, // 音频数据
}
}
}
这里踩了个坑:一开始没注意字节序(大端序 vs 小端序),用了 readUInt32LE
(小端序),结果解析出来的事件类型完全不对。后来查文档才发现火山引擎用的是大端序,得用 readUInt32BE
才行,大家集成时也注意下这个细节。
接下来是 TTSService
类,负责和 TTS 服务建立 WebSocket 连接,并处理音频流:
async synthesize(options, onAudioData, onComplete, onError) {
const { text, speaker, encoding, speed_ratio, volume_ratio } = options
// 建立WebSocket连接,带上认证信息(根据平台要求填写 headers)
const ws = new WebSocket(this.wsUrl, {
headers: {
'X-Api-App-Id': this.appId, // 应用ID
'X-Api-Access-Key': this.accessKey, // 访问密钥
'X-Api-Resource-Id': this.resourceId, // 资源ID
'X-Api-Request-Id': uuidv4(), // 唯一请求ID,用于追踪
},
})
const protocol = new TTSProtocol()
// 连接成功后,发送合成请求
ws.on('open', () => {
// 构建请求参数
const request = {
user: { uid: uuidv4() }, // 用户唯一标识
req_params: {
text: text.trim(), // 需要合成的文本
speaker, // 发音人
audio_params: {
format: encoding, // 音频格式(如mp3)
sample_rate: 24000, // 采样率
speed_ratio, // 语速
volume_ratio, // 音量
},
},
}
// 转换成协议要求的帧格式并发送
const requestFrame = protocol.buildRequestFrame(request)
ws.send(requestFrame)
})
// 接收TTS服务返回的音频流
ws.on('message', (frame) => {
const message = protocol.parseResponseFrame(frame)
if (!message) return
if (message.isAudio) {
// 收到音频数据,通过回调传给外层处理
onAudioData(message.data)
} else if (message.isFinished) {
// 会话结束,关闭WebSocket连接
ws.close()
}
})
// 连接关闭时,触发完成回调
ws.on('close', () => {
onComplete()
})
// 处理错误
ws.on('error', (err) => {
onError({ error: 'WebSocket连接失败: ' + err.message })
})
// 返回控制对象,允许外部中断连接
return { ws, abort: () => ws.close() }
}
这个类的核心是通过回调函数把接收到的音频数据实时传递出去,不做任何缓存,这样才能保证低延迟。
有了 WebSocket 的音频流,还需要通过 HTTP 流式接口传给前端。用 Express 实现的控制器代码如下:
async speechSynthesis(req, res) {
// 从请求参数中获取配置
const { text, speaker, encoding = 'mp3', speed_ratio, volume_ratio } = req.query
const ttsService = new TTSService()
// 设置HTTP响应头,关键是声明分块传输
res.setHeader('Content-Type', 'audio/mpeg') // 音频类型
res.setHeader('Transfer-Encoding', 'chunked') // 分块传输
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') // 不缓存
res.setHeader('Accept-Ranges', 'bytes')
let totalSize = 0
// 音频数据回调:收到一块就立即发给前端
const onAudioData = (audioBuffer) => {
res.write(audioBuffer) // 写入响应流
totalSize += audioBuffer.length
}
// 合成完成回调:结束响应
const onComplete = () => {
console.log(`语音合成完成,总大小: ${(totalSize / 1024).toFixed(2)} KB`)
res.end()
}
// 错误处理回调
const onError = (errorInfo) => {
// 如果还没发送响应头,返回错误信息
if (!res.headersSent) {
return res.cc(1, errorInfo.error || '语音合成失败')
}
// 已经发送了部分数据,直接结束响应
res.end()
}
// 开始合成
const connection = await ttsService.synthesize(
{ text, speaker, encoding, speed_ratio, volume_ratio },
onAudioData,
onComplete,
onError
)
// 监听客户端断开连接事件,及时清理资源
req.on('close', () => {
if (connection && connection.abort) {
connection.abort() // 中断TTS连接
}
})
}
这里的关键是 Transfer-Encoding: chunked
这个响应头,它告诉浏览器这是分块传输的数据,不需要等完整内容,收到一块就可以处理一块。配合 res.write()
实时写入音频数据,就能实现前端的流式播放了。
另外,监听 req.on('close')
很重要,当用户关闭页面或中断请求时,我们可以及时断开和 TTS 服务的连接,避免资源浪费。
前端的核心是实现音频的实时播放,并做好状态管理,让用户体验更流畅。
我设计了一个单例的 aiVoiceManager
,统一管理所有语音播放相关的操作:
class AiVoiceManager {
constructor() {
this.currentItem = null // 当前播放的消息对象
this.isPlaying = false // 是否正在播放
this.isPaused = false // 是否处于暂停状态
this.currentAudio = null // 当前的Audio对象
this.pausedTime = 0 // 暂停时的播放位置(秒)
}
async playVoice(item) {
// 如果点击的是当前正在处理的消息
if (this.currentItem === item) {
if (this.isPlaying) {
this.pauseVoice() // 正在播放 -> 暂停
return
} else if (this.isPaused) {
this.resumeVoice() // 已暂停 -> 继续播放
return
}
}
// 切换到新消息,先停止当前播放
if (this.isPlaying || this.isPaused) {
this.stopVoice()
}
// 更新状态
this.currentItem = item
this.isPlaying = true
// 构建音频流URL
const streamUrl = this.buildStreamUrl(item.content)
// 开始播放
await this.playFromUrl(streamUrl)
}
// 暂停播放
pauseVoice() {
if (this.currentAudio && this.isPlaying) {
this.currentAudio.pause()
this.pausedTime = this.currentAudio.currentTime // 记录暂停位置
this.isPlaying = false
this.isPaused = true
}
}
// 继续播放
resumeVoice() {
if (this.currentAudio && this.isPaused) {
this.currentAudio.currentTime = this.pausedTime // 恢复到暂停位置
this.currentAudio.play()
this.isPlaying = true
this.isPaused = false
}
}
// 停止播放
stopVoice() {
if (this.currentAudio) {
this.currentAudio.pause()
this.currentAudio.src = '' // 清空源,释放资源
this.currentAudio = null
}
this.currentItem = null
this.isPlaying = false
this.isPaused = false
this.pausedTime = 0
}
}
单例模式在这里很关键,它能保证同一时间只有一个音频在播放,避免了多个音频重叠的问题。同时统一管理播放状态,让组件之间的状态同步更简单。
播放功能的核心是利用 HTML5 的 Audio 元素,配合流式 URL 实现实时播放:
async playFromUrl(audioUrl) {
return new Promise((resolve, reject) => {
// 创建Audio对象
this.currentAudio = new Audio()
this.currentAudio.preload = 'auto' // 自动预加载,加速播放
this.currentAudio.crossOrigin = 'use-credentials' // 跨域请求携带凭证
this.currentAudio.src = audioUrl // 设置流式音频URL
let playStarted = false
// 播放尝试函数:解决缓冲不足导致的播放失败
const tryPlay = async () => {
if (playStarted) return // 已经开始播放,不再尝试
try {
// 尝试播放
await this.currentAudio.play()
playStarted = true
resolve()
} catch (error) {
// 播放失败(可能是缓冲不足),50ms后重试
setTimeout(tryPlay, 50)
}
}
// 当音频缓冲到可以播放时,立即尝试播放
this.currentAudio.addEventListener('canplay', tryPlay, { once: true })
// 播放结束时,更新状态
this.currentAudio.addEventListener('ended', () => {
this.handlePlayEnd()
}, { once: true })
// 处理播放错误
this.currentAudio.addEventListener('error', () => {
reject(new Error('音频播放失败'))
}, { once: true })
// 开始加载音频
this.currentAudio.load()
})
}
// 播放结束处理
handlePlayEnd() {
this.currentItem = null
this.isPlaying = false
this.isPaused = false
this.pausedTime = 0
this.currentAudio = null
}
这里有几个关键的技术点:
canplay
事件:当浏览器缓冲了足够的数据可以开始播放时触发,利用这个事件可以在第一时间开始播放,减少等待感play()
方法可能因为缓冲不足而失败,设置一个短间隔(50ms)自动重试,能显著提升播放成功率preload="auto"
:让浏览器自动预加载音频数据,进一步缩短从点击到播放的延迟AI 回复的内容经常包含 Markdown 格式(比如代码块、链接、标题等),直接拿去合成语音会很奇怪(比如会读出 "星号星号")。所以需要先做一下预处理:
preprocessText(text) {
let cleanText = text
// 去除代码块(```包裹的内容)
.replace(/```[sS]*?```/g, '')
// 去除行内代码(`包裹的内容),保留文本
.replace(/`([^`]+)`/g, '$1')
// 去除标题标记(#)
.replace(/^#{1,6}s+/gm, '')
// 去除粗体(**)和斜体(*)标记,保留文本
.replace(/**([^*]+)**/g, '$1')
.replace(/*([^*]+)*/g, '$1')
// 去除链接,保留链接文本
.replace(/[([^]]+)]([^)]+)/g, '$1')
// 去除图片
.replace(/![[^]]*]([^)]+)/g, '')
// 清理多余空白(多个换行或空格)
.replace(/n{2,}/g, 'n')
.replace(/s{2,}/g, ' ')
.trim()
// 限制长度,避免合成过长的音频
if (cleanText.length > 1000) {
cleanText = cleanText.substring(0, 1000) + '...'
}
return cleanText
}
这个处理能让合成的语音更自然,用户体验更好。大家可以根据自己的需求调整正则表达式。
最后把语音播放功能集成到 Vue 组件中(以聊天消息组件为例):
<template>
<div class="message-actions">
<el-icon
@click="toggleVoice"
class="action-icon"
:class="{ playing: voiceStatus === 'playing' }"
:title="getVoiceTitle()">
<VideoPause v-if="voiceStatus === 'playing'" /> <VideoPlay v-else-if="voiceStatus === 'paused'" /> <Microphone v-else /> </el-icon>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import aiVoiceManager from '@/utils/aiVoiceManager'
import { VideoPause, VideoPlay, Microphone } from '@element-plus/icons-vue'
// 接收消息对象作为props
const props = defineProps({
message: { type: Object, required: true },
})
// 语音播放状态(idle/playing/paused)
const voiceStatus = ref('idle')
let statusCheckInterval = null
// 更新语音状态
const updateVoiceStatus = () => {
// 从全局管理器获取当前消息的播放状态
voiceStatus.value = aiVoiceManager.getPlayStatus(props.message)
}
// 切换播放状态(点击事件)
const toggleVoice = async () => {
// 调用全局管理器的播放方法
await aiVoiceManager.playVoice(props.message)
updateVoiceStatus()
}
// 获取按钮提示文本
const getVoiceTitle = () => {
switch (voiceStatus.value) {
case 'playing': return '暂停播放'
case 'paused': return '继续播放'
default: return '播放语音'
}
}
// 组件挂载时,启动状态检查定时器
onMounted(() => {
// 每100ms检查一次状态,保证UI和实际状态一致
statusCheckInterval = setInterval(updateVoiceStatus, 100)
})
// 组件卸载时清理
onBeforeUnmount(() => {
// 清除定时器
clearInterval(statusCheckInterval)
// 如果当前消息正在播放,停止播放
if (aiVoiceManager.currentItem === props.message) {
aiVoiceManager.stopVoice()
}
})
</script>
<style scoped>
.action-icon {
cursor: pointer;
font-size: 18px;
margin-left: 8px;
transition: color 0.2s;
}
.action-icon:hover {
color: #409eff;
}
.action-icon.playing {
color: #409eff;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
</style>
这段代码实现了完整的交互逻辑:
通过定时检查状态(100ms 一次),能保证 UI 显示和实际播放状态一致,避免用户 confusion。
经过优化,我把从点击到听到声音的延迟控制在了 0.5-1 秒,主要做了这几件事:
preload="auto"
让浏览器主动预加载数据canplay
事件,一有足够数据就开始播放,不等完整音频分享几个我在开发中遇到的问题和解决方法,希望能帮大家少走弯路:
坑 1:CORS 跨域问题
Access-Control-Allow-Origin
、Access-Control-Allow-Credentials
等)crossOrigin="use-credentials"
,允许跨域请求携带 Cookie坑 2:音频播放延迟高
res.write()
发给前端,让前端边接收边播放。坑 3:Audio 自动播放失败
audio.play()
时经常失败,报 NotAllowedError
。play()
方法是在用户点击事件中调用的(用户主动操作)play()
的错误,设置短间隔自动重试(因为有时是缓冲不足导致的失败)坑 4:音频重叠播放
坑 5:组件卸载后仍播放
onBeforeUnmount
钩子中检查,如果当前组件的消息正在播放,就调用 stopVoice()
停止。通过这次实践,我对实时语音合成和流式传输有了更深的理解。总结下来,有几个关键点:
后续计划优化的方向:
希望这篇文章能帮到有类似需求的朋友,大家如果有更好的实现方式,欢迎在评论区交流~