wx小程序-实时语音数据流开发记录
前端 前端 小程序 65

最近有个需求,需要使用 SSE 请求返回的多次分段语音数据流进行实时播放,并需要优化间隔,这可难住了我,经过多次辗转阅读资料后还是简单实现了一版。

首先我想到的是使用单个语音实例去实现

function play(data) {
  const instance = wx.createInnerAudioContext()
  instance.obeyMuteSwitch = false
  instance.autoplay = true
  const path = `${wx.env.USER_DATA_PATH}/audio_${Date.now()}.mp3`
  const fs = wx.getFileSystemManager()
  fs.writeFileSync(path, data, 'binary')
  instance.src = path
  instance.play()
}

但是这种方式在实际体验中每段语音中间会有中断的间隔,体验不是很好,需要优化。

后来在元宝和朋友提供的思路下,想到创建多个语音上下文实例,交替播放的方案,但是这样还是会有间隔,因为在上一个语音播放的停止事件中,轮换实例去播放,上一个已经播放完成了,才去播放下一个,岂不是还是无法连贯输出语音。

后来去官网文档里去查看,发现一个 onTimeUpdate 的方法,用来监听语音播放进度,而在这个事件里,可以拿到当前的播放到的时间 currentTime 和总时间 duration

这不是正好吗,我监听进度更新到100之前,就播放下一个,只有控制好时间,应该可以做到完美衔接

但是,要有这么简单就好了,实际测试中,这个事件大概 200ms 左右才触发一次,如果分段语音短的情况下,很有可能上一次进度还在 60%,下一次之间完成播放了,所以这个方案也 pass 掉了

之后,我想为何不自己去监听这个进度呢,在语音开始播放的时候,即 onPlay 事件里面,用 setInterval 去轮询查询,总的时间减去当前的时间,当每次剩0.05s的时候去播放下一个,同时不停止上一个的播放,只要查询时间够短就可以监听到,话不多说,直接开干,这是我的监听方法

private _startProgressTracker(index: number) {
    this._clearProgressTracker(index) // 先清理旧定时器
    const instance = this.instances[index]
    this.intervals[index] = setInterval(() => {
      if (!instance.duration || instance.duration === Infinity) return
      const percent = instance.duration - instance.currentTime
      // console.log(`实例${index} 播放进度`, instance.duration, `${percent}s`)
      if (percent < 0.045) {
        this._clearProgressTracker(index)
        this._handlePlayEnd()
      }
    }, AutoAudioPlayer.CHECK_INTERVAL) as unknown as number // 微信小程序的setInterval返回number
  }

实践之后,在 PC 端小程序开发工具里可以做到很不错的效果了,然而,小程序往往会给你深痛一击,直接真机调试,简直就是群魔乱舞,PC上调试的表现跟真机的实际调试还是相差太大了,最后我还是放弃了这个方案,但是,代码逻辑是没有什么问题的,下面是完整代码

export default class AutoAudioPlayer {
  private queue: string[] = []
  private instances: WechatMiniprogram.InnerAudioContext[]
  private currentIndex = 0
  private isSwitching = false
  private isPlaying = false
  private intervals: [number | null, number | null] = [null, null] // 改为setInterval
  private retryCount = 0
  private static MAX_RETRY = 3
  private static CHECK_INTERVAL = 10 // 检测间隔调整为100ms

  constructor() {
    this.instances = [this._createInstance(0), this._createInstance(1)]
  }

  private _createInstance(index: number): WechatMiniprogram.InnerAudioContext {
    const instance = wx.createInnerAudioContext()
    instance.obeyMuteSwitch = false
    instance.autoplay = true

    instance.onPlay(() => {
      console.log(`实例${index} 开始播放`)
      this._startProgressTracker(index)
      this.isPlaying = true
    })

    instance.onEnded(() => {
      console.log(`实例${index} 播放结束`)
    })

    instance.onError((err) => {
      console.error(`实例${index} 播放错误:`, err)
      // this._handleError(index)
    })

    return instance
  }

  private _startProgressTracker(index: number) {
    this._clearProgressTracker(index) // 先清理旧定时器

    const instance = this.instances[index]
    this.intervals[index] = setInterval(() => {
      if (!instance.duration || instance.duration === Infinity) return

      // const percent = Math.floor((instance.currentTime / instance.duration) * 100)
      const percent = instance.duration - instance.currentTime
      // console.log(`实例${index} 播放进度`, instance.duration, `${percent}s`)

      if (percent < 0.045) {
        this._clearProgressTracker(index)
        this._handlePlayEnd()
      }
    }, AutoAudioPlayer.CHECK_INTERVAL) as unknown as number // 微信小程序的setInterval返回number
  }

  private _clearProgressTracker(index: number) {
    if (this.intervals[index] !== null) {
      clearInterval(this.intervals[index]!)
      this.intervals[index] = null
    }
  }

  private async _handlePlayEnd() {
    if (this.isSwitching) return
    this.isSwitching = true

    try {
      const nextAudio = this.queue.shift()
      if (!nextAudio) {
        this.isPlaying = false
        return
      }

      const nextIndex = 1 - this.currentIndex
      await this._switchInstance(nextIndex, nextAudio)
      this.currentIndex = nextIndex
    } finally {
      this.isSwitching = false
    }
  }

  private _switchInstance(index: number, path: string): Promise<void> {
    return new Promise((resolve) => {
      const instance = this.instances[index]
      instance.stop()
      instance.src = path

      instance.onCanplay(() => {
        instance.play()
        resolve()
      })
    })
  }

  private _handleError(index: number) {
    if (this.retryCount < AutoAudioPlayer.MAX_RETRY) {
      this.retryCount++
      this._handlePlayEnd()
    } else {
      console.error('播放失败,已达到最大重试次数')
      this.destroy()
    }
  }

  public play(data: ArrayBuffer) {
    try {
      const path = this._cacheAudio(data)
      this.queue.push(path)

      if (!this.isPlaying) {
        this._handlePlayEnd()
      }
    } catch (err) {
      console.error('音频缓存失败:', err)
    }
  }

  private _cacheAudio(data: ArrayBuffer): string {
    const path = `${wx.env.USER_DATA_PATH}/audio_${Date.now()}.mp3`
    const fs = wx.getFileSystemManager()
    try {
      fs.writeFileSync(path, data, 'binary')
      return path
    } catch (err) {
      throw new Error(`文件写入失败: ${err}`)
    }
  }

  public destroy() {
    console.log('释放音频资源')
    // 清理所有定时器
    this.intervals.forEach((_, index) => this._clearProgressTracker(index))
    this.instances.forEach((instance) => {
      instance.stop()
      instance.destroy()
    })
    this.queue = []
    this.isPlaying = false
  }
}

wx小程序-实时语音数据流开发记录
http://blog.cirzear.cn/archives/wxxiao-cheng-xu-shi-shi-yu-yin-shu-ju-liu-kai-fa-ji-lu
作者
Cirzear
发布于
更新于
许可