岁寒之松柏:小程序的canvas如何绘制视频

251 阅读11分钟

背景

最近公司开展了新的业务,制作一个数字人,但是因为没有建模和游戏相关开发人员,所以想要做一个很简单的项目,用户在小程序上点击长按按钮,和数字人对话,然后录音发送到后台,后台接收到反馈后 会把语音流和要执行的动作发送到小程序,而小程序端需要播放对应的语音流和根据动作切换相应的视频。我接受到需求的时候也觉得很简单,但是没想到噩梦才刚刚开始。

小小梦魇:录音

因为项目使用到录音并且需通过websocket将960字节PCM帧进行发送,所以我就看了小程序的文档找到如下API:

image.png

看文档似乎可以满足我的需求因为可以设置录音格式和frameSize 这样就可以在如下的

image.png

回调中获取到需要发送的PCM帧数据。但是问题就是这个frameSize 因为我需要的960字节,而这个frameSize 在真机上最小值就是 1 ,而需要的设置 960/1024 = 0.9375在模拟器中可以使用但是真机上却不行。没办法了只能自己将录音的数据切成960字节了 源码如下:

    // 对原有API进行了简单封装
    
    this.audioDataBuffer = new Uint8Array(0)
    
    
    // 这个地方的data 就是 官方文档中 onFrameRecorded 回调函数中的data
    this.recorder.on(RECORDER_EVENTS.FRAME_RECORDED, (data) => {
      
      
      if (!data.isLastFrame) {
        let newData = new Uint8Array(data.frameBuffer)
        // 将新数据添加到缓存中
        let combinedData = new Uint8Array(this.audioDataBuffer.length + newData.length)
        combinedData.set(this.audioDataBuffer)
        combinedData.set(newData, this.audioDataBuffer.length)

        // 分片发送,每次最多960字节
        const maxChunkSize = 960
        let offset = 0

        while (offset + maxChunkSize <= combinedData.length) {
          let chunk = combinedData.slice(offset, offset + maxChunkSize)
          let buffer = chunk.buffer
          mylog.log("发送音频数据块,大小:", buffer.byteLength)
          this.scoket.sendBuffer(buffer)
          offset += maxChunkSize
        }

        // 保存剩余数据到缓存中
        if (offset < combinedData.length) {
          this.audioDataBuffer = combinedData.slice(offset)
          mylog.log("缓存剩余数据,大小:", this.audioDataBuffer.length)
        } else {
          this.audioDataBuffer = new Uint8Array(0)
        }
      }
      //  this.scoket.send(data)
    })

层层恐惧:播放语音流

另外项目需要把服务端发送的PCM帧进行播放,刚刚接收到需求的时候我其实是不太确定小程序是否可以播放PCM的帧数据。研究之后发现了如下API:

image.png

这个API可以创建类似web端使用AudioContext进行PCM帧数据进行播放。但是web端如何进行播放呢,查询一下发现了如下仓库 pcm-player,但是并不直接支持小程序,查看源码发现如下片段。

image.png

结合上面的小程序API合理推测this.audioCtx 换成 wx.createWebAudioContext() 应该就可以直接在小程序中使用这个仓库了 也就改成这样


class PCMPlayer {
  constructor(option) {
    this.init(option)
  }

  init(option) {
    const defaultOption = {
      inputCodec: 'Int16', // 传入的数据是采用多少位编码,默认16位
      channels: 1, // 声道数
      sampleRate: 8000, // 采样率 单位Hz
      flushTime: 1000, // 缓存时间 单位 ms
      fftSize: 2048 // analyserNode fftSize 
    }

    this.option = Object.assign({}, defaultOption, option) // 实例最终配置参数
    this.samples = new Float32Array() // 样本存放区域
    this.interval = setInterval(this.flush.bind(this), this.option.flushTime)
    this.convertValue = this.getConvertValue()
    this.typedArray = this.getTypedArray()
    this.initAudioContext()
    this.bindAudioContextEvent()
  }

  getConvertValue() {
    // 根据传入的目标编码位数
    // 选定转换数据所需要的基本值
    const inputCodecs = {
      'Int8': 128,
      'Int16': 32768,
      'Int32': 2147483648,
      'Float32': 1
    }
    if (!inputCodecs[this.option.inputCodec]) throw new Error('wrong codec.please input one of these codecs:Int8,Int16,Int32,Float32')
    return inputCodecs[this.option.inputCodec]
  }

  getTypedArray() {
    // 根据传入的目标编码位数
    // 选定前端的所需要的保存的二进制数据格式
    // 完整TypedArray请看文档
    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
    const typedArrays = {
      'Int8': Int8Array,
      'Int16': Int16Array,
      'Int32': Int32Array,
      'Float32': Float32Array
    }
    if (!typedArrays[this.option.inputCodec]) throw new Error('wrong codec.please input one of these codecs:Int8,Int16,Int32,Float32')
    return typedArrays[this.option.inputCodec]
  }

  initAudioContext() {
    /*
     * 这个地方改成小程序的版本
     */
    this.audioCtx = wx.createWebAudioContext()
    // 控制音量的 GainNode
    // https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/createGain
    this.gainNode = this.audioCtx.createGain()
    this.gainNode.gain.value = 0.1
    this.gainNode.connect(this.audioCtx.destination)
    this.startTime = this.audioCtx.currentTime
    this.analyserNode = this.audioCtx.createAnalyser() 
    this.analyserNode.fftSize = this.option.fftSize;
  }

  static isTypedArray(data) {
    // 检测输入的数据是否为 TypedArray 类型或 ArrayBuffer 类型
    return (data.byteLength && data.buffer && data.buffer.constructor == ArrayBuffer) || data.constructor == ArrayBuffer;
  }

  isSupported(data) {
    // 数据类型是否支持
    // 目前支持 ArrayBuffer 或者 TypedArray
    if (!PCMPlayer.isTypedArray(data)) throw new Error('请传入ArrayBuffer或者任意TypedArray')
    return true
  }

  feed(data) {
    this.isSupported(data)

    // 获取格式化后的buffer
    data = this.getFormattedValue(data);
    // 开始拷贝buffer数据
    // 新建一个Float32Array的空间
    const tmp = new Float32Array(this.samples.length + data.length);
    // console.log(data, this.samples, this.samples.length)
    // 复制当前的实例的buffer值(历史buff)
    // 从头(0)开始复制
    tmp.set(this.samples, 0);
    // 复制传入的新数据
    // 从历史buff位置开始
    tmp.set(data, this.samples.length);
    // 将新的完整buff数据赋值给samples
    // interval定时器也会从samples里面播放数据
    this.samples = tmp;
    // console.log('this.samples', this.samples)
  }

  getFormattedValue(data) {
    if (data.constructor == ArrayBuffer) {
      data = new this.typedArray(data)
    } else {
      data = new this.typedArray(data.buffer)
    }

    let float32 = new Float32Array(data.length)

    for (let i = 0; i < data.length; i++) {
      // buffer 缓冲区的数据,需要是IEEE754 里32位的线性PCM,范围从-1到+1
      // 所以对数据进行除法
      // 除以对应的位数范围,得到-1到+1的数据
      // float32[i] = data[i] / 0x8000;
      float32[i] = data[i] / this.convertValue
    }
    return float32
  }

  volume(volume) {
    this.gainNode.gain.value = volume
  }

  destroy() {
    if (this.interval) {
      clearInterval(this.interval)
    }
    this.samples = null
    this.audioCtx.close()
    this.audioCtx = null
  }

  flush() {
    if (!this.samples.length) return
    const self = this
    var bufferSource = this.audioCtx.createBufferSource()
    if (typeof this.option.onended === 'function') {
      bufferSource.onended = function (event) {
        self.option.onended(this, event)
      }
    }
    const length = this.samples.length / this.option.channels
    const audioBuffer = this.audioCtx.createBuffer(this.option.channels, length, this.option.sampleRate)

    for (let channel = 0; channel < this.option.channels; channel++) {
      const audioData = audioBuffer.getChannelData(channel)
      let offset = channel
      let decrement = 50
      for (let i = 0; i < length; i++) {
        audioData[i] = this.samples[offset]
        /* fadein */
        if (i < 50) {
          audioData[i] = (audioData[i] * i) / 50
        }
        /* fadeout*/
        if (i >= (length - 51)) {
          audioData[i] = (audioData[i] * decrement--) / 50
        }
        offset += this.option.channels
      }
    }

    if (this.startTime < this.audioCtx.currentTime) {
      this.startTime = this.audioCtx.currentTime
    }
    // console.log('start vs current ' + this.startTime + ' vs ' + this.audioCtx.currentTime + ' duration: ' + audioBuffer.duration);
    bufferSource.buffer = audioBuffer
    bufferSource.connect(this.gainNode)
    bufferSource.connect(this.analyserNode) // bufferSource连接到analyser
    bufferSource.start(this.startTime)
    this.startTime += audioBuffer.duration
    this.samples = new Float32Array()
  }

  async pause() {
    await this.audioCtx.suspend()
  }

  async continue() {
    await this.audioCtx.resume()
  }

  bindAudioContextEvent() {
    const self = this
    if (typeof self.option.onstatechange === 'function') {
      this.audioCtx.onstatechange = function (event) {
        self.audioCtx && self.option.onstatechange(this, event, self.audioCtx.state)
      }
    }
  }

}

export default PCMPlayer

试了一下果然可以,感谢开源大佬,但是因为我的项目的需要在播放50帧完成后发送一个websocket信息告诉服务端,客户端已经播放完成了50帧,请继续发送。加上需要知道音频是否正在播放,所以我还需要了解一下这个项目的原理,还好大佬写的比较清晰,加上查询资料,基本了解了在web端播放音频的原理。总体的流程基本大纲如下:

image.png

但是如何播放数据帧还需要其他的设计,比如上文的pcm-player就是实现一个缓存接收帧数据然后使用一个定时器定时刷新缓存的数据进行播放,当然也可以有其他设计比如采取递归的方式每次播放完成就在onended回调中检查是否存在缓存数据如果有就再进行一次播放,比如还可以设计当设计接收到多少缓存的数据之后再进行播放,总体来说知道了具体的使用流程,读者可以对上文的player进行魔改实现各种功能。

最终恐惧:小程序如何平滑的播放和切换视频

大体说一下我的悲催历程,因为项目需要频繁的切换视频,在苹果端一直会有一秒左右的黑屏现象。我简单猜了一下感觉是因切换视频时候视频还没有加载出来导致的。顺着这个思路,我就想能不能不切换时候就把播放的视频切换了呢,我想大家应该知道我的意思了,我如果能实现一个画布(canvas)把视频的内容绘制到canvas上不就可以了吗,然后就去小程序的官方文档找能否可以把视频绘制到画布的方案。找了一遍,说实话没有找到。好在我没有死心找到如下内容:

image.png

咱就是说微信官方能不能单独弄了tab 你在示例代码里面真的很难崩呀。打开代码片段看了一下如下

const w = 300
let h = 200

Page({
  data: {
    h,
  },
  onLoad: function () {
    console.log('代码片段是一种迷你、可分享的小程序或小游戏项目,可用于分享小程序和小游戏的开发经验、展示组件和 API 的使用、复现开发问题和 Bug 等。可点击以下链接查看代码片段的详细文档:')
    console.log('https://mp.weixin.qq.com/debug/wxadoc/dev/devtools/devtools.html')
  },
  onReady() {
    wx.showLoading({
      title: '加载中',
    })
    wx.downloadFile({
      url: 'http://wxsnsdy.tc.qq.com/105/20210/snsdyvideodownload?filekey=30280201010421301f0201690402534804102ca905ce620b1241b726bc41dcff44e00204012882540400&bizid=1023&hy=SH&fileparam=302c020101042530230204136ffd93020457e3c4ff02024ef202031e8d7f02030f42400204045a320a0201000400',
      success: (e) => {
        this.setData({src: e.tempFilePath})
      },
      fail(e) {
        console.log('download fail', e)
      },
      complete() {
        wx.hideLoading()
      }
    })
  },
  loadedmetadata(e) {
    h = w / e.detail.width * e.detail.height
    this.setData({
      h,
    }, () => {
      this.draw()
    })
  },
  draw() {
    const dpr = wx.getSystemInfoSync().pixelRatio
    wx.createSelectorQuery().select('#video').context(res => {
      console.log('select video', res)
      const video = this.video = res.context

      wx.createSelectorQuery().selectAll('#cvs1').node(res => {
        console.log('select canvas', res)
        const ctx1 = res[0].node.getContext('2d')
        res[0].node.width = w * dpr
        res[0].node.height = h * dpr

        setInterval(() => {
          ctx1.drawImage(video, 0, 0, w * dpr, h * dpr);
        }, 1000 / 24)
      }).exec()
    }).exec()
  },
})

<view style="text-align: center;">
  <video id="video" autoplay="{{true}}" controls="{{false}}" style="width: 300px; height: 200px;" src="{{src}}" bindloadedmetadata="loadedmetadata"></video>
  <view style="display: inline-block; width: 300px; height: 200px;">
    <canvas id="cvs1" type="2d" style="display: inline-block; border: 1px solid #CCC; width: 300px; height: {{h}}px;"></canvas>
  </view>
</view>

总结一下就是drawImage 是可以传送videoContext 获取当前视频播放的帧 ,然后videoContext可以通过 SelectQuery获取,但是我的场景中要想连贯播放就需要两个video,和一个canvas,结论就是非常卡顿,能明显的感受到。所以这个方案不行 虽然不行但是有一个还是要说一下 这个方案的视频必须缓存到本地才可以播放,所以示例中需要download 。


那除了这个还有办法用canvas绘制视频吗?查看小程序的官方文档找到如下的API,给了我一点希望。

image.png

视频解码器,似乎可以对视频进行解码然后获取到每一帧的数据进一步查看

image.png

这个接口还支持解码在线视频,进一步调研发现这个接口使用的逻辑大概是这个流程 解码成功开始之后每调用一次getFrameData就可以获取到一帧的图片数据,那我需要做的就是把这一帧的数据渲染到画布上应该就可以了,再结合 createImageData的文档可以实现如下代码

   let canvas = null
   let ctx = null
   this.createSelectorQuery()
    .select('#myCanvas')
    .fields({ node: true, size: true })
    .exec(res => {
      canvas = res[0].node
      ctx = canvas.getContext('2d')
      canvas.width = res[0].width * dpr
      canvas.height = res[0].height * dpr
    })

  const decoder = wx.createVideoDecoder()
  
  /*
   * 必须使用2d类型只要2d类型存在createImageData方法 
   *
   */
  const offCanvas = wx.createOffscreenCanvas({
        type: "2d", 
        width: imgWidth,
        height: imgHeight
  })
  const offctx = offCanvas.getContext("2d")
  decoder.start({
    source:"视频地址",
    abortAudio:true //不需要音频数据
  })
  
  
  const loop = ()=>{
    // 获取到当前帧数据
    const frameData = this.decoderManager.getFrameData()
    // 创建ImageData类型数据
    const imageData = offCanvas.createImageData(imgWidth, imgHeight)
    // 把帧数据传入iamgeData中
    imageData.data.set(new Uint8ClampedArray(frameData.data))
    // 把imageData绘制到离屏canvas上
    offctx.putImageData(this.reusableImageData, 0, 0)
    
    
    const canvasRatio = canvas.width / canvas.height
    const imageRatio = imgWidth / imgHeight

    let drawWidth, drawHeight, offsetX = 0, offsetY = 0

    if (canvasRatio > imageRatio) {
          drawWidth = canvas.width
          drawHeight = canvas.width / imageRatio
          offsetY = (canvas.height - drawHeight) / 2
     } else {
          drawHeight = canvas.height
          drawWidth = canvas.height * imageRatio
          offsetX = (canvas.width - drawWidth) / 2
     }
     
    // 
    ctx.drawImage(offCanvas, offsetX, offsetY,drawWidth,drawHeight) 
    // 绘制下一帧
    canvas.requestAnimationFrame(this.loop.bind(this))
  }
  decoder.on("start",()=>{
      loop()
  })
  
  decoder.on("end",()=>{
      // 解码完成 重新回到0点 可以实现循环播放
      decoder.seek(0)
  })

<!--components/avatar-interface/avatar-sdk/avatar-decoder-canvas/avatar-decoder-canvas.wxml-->
<canvas type="2d" id="myCanvas" class="canvas-video"></canvas>

这是实现绘制解码数据的简单逻辑,因为需要适配不同的手机所以需要一个离屏canvas先把图片绘制出来然后通过计算再绘制到真正的canvas上,然后可以通过解码完成后把播放时间拨回0实现循环播放,另外createImageData只有web端的RendererContext存在该方法 所以canvas和离屏canvas都只能使用2d的。


但是不幸上面这个方案存在掉帧严重的问题,虽然这两个方案都没有切换的问题但是都有新的问题,真的就没有办法了吗?是的,我又要看文档了,重新整理了一下思路,或许不是视频的问题呢,现在我所有的问题个人感觉都是web端的性能太差导致的,或者因为web端的技术负债导致的,如果可以不使用web技术渲染是不是就可以了?是的,对于小程序开发者我们还有skyline呀。

我参考 webview迁移的文档把当前页面切换使用skyline 是的根本不要什么绘制两个视频(双缓存),也不需要绘制两个canvas 就可以实现简单流畅的视频切换 但是不能所有的界面都使用skyline 我们只需要进行视频切换的页面json配置文件加上这个

{
  "usingComponents": {

  },
  "renderer": "skyline",
  "rendererOptions": {
    "skyline": {
      "disableABTest": true,
      "sdkVersionBegin": "3.0.1", 
      "sdkVersionEnd": "15.255.255"
    }
  },
  "componentFramework": "glass-easel"
}

就可以单独指定某个页面任何版本都使用skyline渲染引擎了

AI总结

一、录音功能的实现

  • 面临的问题:需要通过WebSocket发送960字节的PCM帧数据,但小程序的录音API不支持直接设置如此精确的frameSize。
  • 解决方案:自行将录音数据切分为960字节的小块进行发送,并在回调函数中处理数据分片和缓存。

二、播放语音流的探索

  • 初步尝试:利用小程序的Web Audio API结合开源的PCM播放器库实现PCM帧数据的播放。
  • 深入研究:理解音频播放原理后,对PCM播放器进行改造,以满足项目需求,如播放进度反馈和播放状态监控。

三、视频平滑切换的难题与突破

  • 遇到的挑战:频繁切换视频时出现黑屏现象,影响用户体验。
  • 尝试的方案:
    • 使用Canvas绘制视频帧,但由于性能问题未能实现流畅切换。
    • 探索使用VideoDecoder API解码视频并绘制到Canvas,虽有所改善但仍存在掉帧问题。
  • 最终解决方案:采用Skyline渲染引擎,通过修改页面配置实现流畅的视频切换,避免了复杂的绘制逻辑和性能瓶颈