前言
因为最近在研究 AI 对话,里面涉及 ASR 和 TTS 的场景,因为本身大模型进行对应的转化需要时间,而这个过程中要用户等待是不应该,因此就需要实现对流的处理, 这里就要涉及 SSE、音频格式、二进制处理的相关知识,而对于我这种 只是接触 HTTP、Webscoket 的人员来讲不太优化,因此列下这个文件来实现。
技术术语
SSE (Server-Sent Events)
主要解决单向流的问题,即服务端推送客户端,它是运行 HTTP 协议之上的,不需要额外的服务器配置。
SSE 规范
- 服务端响应头
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
- 数据格式 (Data Format)
- SSE 传输的是纯文本,每一条消息由多行组成,行与行之间用 \n 分割,消息体之间用 \n\n 分割。每一行都有特定的前缀:
- data::实际的数据内容。如果数据很长,可以用多个 data: 行拼接。
- event::自定义事件名称(客户端可以用 addEventListener 监听)。
- id::消息的唯一 ID。如果连接断开,浏览器会自动发送 Last-Event-ID 头部给服务器,请求从这里断点续传。
- retry::通知浏览器在断开后等待多少毫秒再尝试重连。
- 以冒号开头的是注释行,常用于“心跳检测”,防止中间代理因长时间没数据而关闭连接
因为上述原因, 加上 TCP 数据包的特性,有可能会把文件截断,即我们接受到 chunk 分片的时候不是完整的 json(API 文档是提供 json), 需要我们等到下次消息判断是完整的在进行处理。
按照 SSE规范 使用 \n 来做切割, lines 这里就确保了 lines 一定是 Array<string>, 可以参照下方的伪代码。
let responseBuffer = ""
requestTask.onChunkReceived((chunk) => {
if (!canPlay.value) return // 如果禁用了,不再处理数据
const uint8Array = new Uint8Array(chunk.data)
const str = String.fromCharCode(...uint8Array)
responseBuffer += str
const lines = responseBuffer.split('\n')
responseBuffer = lines.pop() || ''
}
接下来根据 SSE 规范弹出 data: , 然后 JSON.parse 后就是 API 返回的 json 数据。
lines.forEach((line) => {
const trimmedLine = line.trim()
if (!trimmedLine || !trimmedLine.startsWith('data:')) return
try {
const jsonStr = trimmedLine.substring(5)
const json = JSON.parse(jsonStr)
const base64Data = json.output?.audio?.data
if (base64Data) {
// 执行解码并播放
playBuffer(base64Data)
}
// 检查结束标志 (finish_reason)
if (json.output?.finish_reason === 'stop') {
const finalUrl = json.output?.audio?.url
console.log('%c--- 播报合成完毕 ---', 'color: green; font-weight: bold;')
console.log('%c完整音频 URL:', 'color: #8e44ad', finalUrl)
}
} catch (e) {
console.error('JSON解析异常', e)
}
})
})
PCM 格式
PCM 音频堪称音频“生肉”,核心特点:无损未压缩、线性数据流、无头格式、计算资源友好、文件体积庞大。
所以它可以实现判断 音量大小、 实时无损推送。
无协议头
即二进制中未包含协议信息,无法推断出采样率、位深度、通道数等信息,因此 pcm 文件是无法直接用音频文件播放的,如果想要播放 https://pcm.qer.im/ 可以体验。
采样率、位深度、通道数
采样率:每秒钟对声音波形进行采样的次数,单位为 Hz(赫兹)。
位深度:记录每个采样点音量大小(振幅)的精确程度,单位为 Bit(位),决定音床(Noise Floor,底噪)高度。
通道数:双通道(立体声),单通道
相关 API 接口返回是:24Hz、16-bit、单通道(1)
Base64
在通常的传输协议,是不建议直接传输二进制数据的,因此二进制数据中包含一些不可见字符容易导致兼容问题,所以 一般来讲都是 Base64 进行编码。
编码原理:将每 3 个字节(共 24 位)转换为 4 个 ASCII 字符(每个字符占 6 位),编码后的数据体积会比原始二进制数据增加约 33%。
所以 Base64 编码比原始二进制的通常大 33%。
arrayBuffer
JS 针对存储二进制定义的的结构,无法直接读取必须通过 视图 View 才能访问。
Web Audio API 的标准
处理音频的标准协议(Web Audio API)规定,所有送入播放通道(AudioBuffer)的振幅数据必须符合以下格式:
- 数据类型:Float32(32位浮动小数点)
- 数值范围:[-1.0, 1.0]
因此需要把 PCM 数据进行归一化处理
#【数据转换】将 Int16 的 [-32768, 32767] 映射到 Float32 的 [-1.0, 1.0]
for (let i = 0; i < pcmData.length; i++) {
channelData[i] = pcmData[i] / 32768.0
}
疑问解答
- 如果存在吞字的问题,可以获取原始 url 进行播放,没有问题,大概率是 SSE 分片没处理好,采样率、位深只会影响质量,比如完全听不清(超级快、杂音、超级慢)。
相关核心代码
/**
* 发起语音合成请求
* @param text 需要合成的文字
*/
const speakStream = async (text: string) => {
try {
if (text.length === 0 || !canPlay.value) {
return
}
const apiKey = await getTtsApiKey()
const requestTask = wx.request({
url: TTS_URL,
method: 'POST',
enableChunked: true, // 开启小程序流式传输
header: {
Authorization: `Bearer ${apiKey}`,
'X-DashScope-SSE': 'enable',
'Content-Type': 'application/json',
},
data: {
model: 'qwen3-tts-flash',
input: {
text: text,
voice: currentVoice.value.voice, // 音色
language_type: 'Chinese',
},
},
success: (res) => {
console.log('TTS流传输结束', res)
},
fail: (err) => {
console.error('TTS请求失败', err)
},
})
responseBuffer = ''
requestTask.onChunkReceived((chunk) => {
if (!canPlay.value) return // 如果禁用了,不再处理数据
const uint8Array = new Uint8Array(chunk.data)
const str = String.fromCharCode(...uint8Array)
// console.log('--- 收到原始分片 ---')
// console.log('分片长度:', str.length)
// console.log('分片末尾字符:', JSON.stringify(str.slice(-5))) // 查看是否以 \n 结尾
responseBuffer += str
const lines = responseBuffer.split('\n')
responseBuffer = lines.pop() || ''
lines.forEach((line) => {
const trimmedLine = line.trim()
if (!trimmedLine || !trimmedLine.startsWith('data:')) return
try {
const jsonStr = trimmedLine.substring(5)
const json = JSON.parse(jsonStr)
const base64Data = json.output?.audio?.data
if (base64Data) {
// 执行解码并播放
playBuffer(base64Data)
}
// 检查结束标志 (finish_reason)
if (json.output?.finish_reason === 'stop') {
const finalUrl = json.output?.audio?.url
console.log('%c--- 播报合成完毕 ---', 'color: green; font-weight: bold;')
console.log('%c完整音频 URL:', 'color: #8e44ad', finalUrl)
}
} catch (e) {
console.error('JSON解析异常', e)
}
})
})
return requestTask
} catch (error) {
console.error('初始化 TTS 失败', error)
throw error
}
/**
* 将 Base64 PCM 数据解码并推入播放队列
*/
const playBuffer = (base64Data: string) => {
console.log('TTS: 开始播放', canPlay.value, !!audioCtx)
if (!canPlay.value || !audioCtx) return
// 1. 将 base64 转为 ArrayBuffer
const arrayBuffer = wx.base64ToArrayBuffer(base64Data)
// 2. 将 ArrayBuffer 转为 Int16Array (因为 PCM 是 16bit)
const alignedLength = arrayBuffer.byteLength - (arrayBuffer.byteLength % 2)
const pcmData = new Int16Array(arrayBuffer.slice(0, alignedLength))
// 3. 【关键】手动创建 AudioBuffer,不再使用异步解码
// 参数:单声道(1), 样本数(pcmData.length), 采样率(24000)
const audioBuffer = audioCtx.createBuffer(1, pcmData.length, 24000)
// 4. 获取第 0 声道的写入引用
const channelData = audioBuffer.getChannelData(0)
// 5. 【数据转换】将 Int16 的 [-32768, 32767] 映射到 Float32 的 [-1.0, 1.0]
for (let i = 0; i < pcmData.length; i++) {
channelData[i] = pcmData[i] / 32768.0
}
// 6. 正常播放
const source = audioCtx.createBufferSource()
source.buffer = audioBuffer
source.connect(audioCtx.destination)
const startTime = Math.max(nextStartTime, audioCtx.currentTime)
source.start(startTime)
// 更新时间轴
nextStartTime = startTime + audioBuffer.duration
console.log(`[PCM-Play] 播放长度: ${audioBuffer.duration.toFixed(3)}s`)
}
const initAudioContext = () => {
if (!audioCtx) {
responseBuffer = ''
audioCtx = wx.createWebAudioContext()
nextStartTime = audioCtx.currentTime
}
// 如果之前是 suspended 状态,需 resume
if (audioCtx.state === 'suspended') {
audioCtx.resume()
}
}

你好,在小程序接入/使用过程中遇到疑问,可以在 小程序专区- 选择「提问」进行发帖提问,平台会流转到相关业务同学协助解答。若选择「写文章」后发布非文章类内容,将会被平台隐藏处理。