评论

微信小程序实现 TTS 实时播放(SSE)

解决在微信小程序上使用 createWebAudioContext 来进行实时音频播放

前言

因为最近在研究 AI 对话,里面涉及 ASR 和 TTS 的场景,因为本身大模型进行对应的转化需要时间,而这个过程中要用户等待是不应该,因此就需要实现对流的处理, 这里就要涉及 SSE、音频格式、二进制处理的相关知识,而对于我这种 只是接触 HTTP、Webscoket 的人员来讲不太优化,因此列下这个文件来实现。

技术术语

SSE (Server-Sent Events)

主要解决单向流的问题,即服务端推送客户端,它是运行 HTTP 协议之上的,不需要额外的服务器配置。

SSE 规范

  1. 服务端响应头

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

  1. 数据格式 (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
    }

疑问解答

  1. 如果存在吞字的问题,可以获取原始 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()
    }
  }
最后一次编辑于  01-23  
点赞 1
收藏
评论

1 个评论

  • 社区运营专员-wwen
    社区运营专员-wwen
    01-23

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

    01-23
    赞同
    回复
登录 后发表内容