小宇宙Studio音频波形图优化

前言

做小宇宙Studio的过程中,剪辑页面的音轨区域用了很长时间做性能优化,具体的效果可以看视频,基本覆盖音轨区域的所有交互。

这里讲一下踩到的坑,以及各种性能优化的方案。

概念

  • 项目:当前剪辑页面打开的就是一个剪辑项目
  • 音轨:中间的一条由波形组成的就是音轨。目前只有一条音轨
  • 片段:一条音轨由若干个片段组成,每个片段会对应一个音频文件的一个区间。

截图是现在小宇宙Studio剪辑页面的样子,这里划分几个部分,方便后续讲解。

image.png (1×1 px, 213 KB)

其中蓝色音轨部分主要由这几个组件组成:

  • 时间轴:绿色
  • 某个片段:红色
  • 滚动条:黄色
  • 两个悬浮的Toolbar

音轨区域迭代

方案1:不做Virtualized,使用浏览器滚动条

这是最直观,最好理解的方案。在没有任何例子和性能测试之前,没必要直接进行优化。

image.png (400×1 px, 31 KB)

整个音轨的宽度,就是项目在当前ZoomLevel下的实际宽度。只需要把片段从前到后依次排列,Toolbar的定位只需要相对于项目找到left值即可。可以直接使用浏览器给的滚动条,不需要监听滚动事件。基于此实现了第一版音轨区域。

但很快就发现下列问题:

  • 时间轴使用canvas进行绘制,当ZoomLevel越大,元素宽度就越大,就会 超过canvas的最大宽度,也就是32767像素
  • 同样的,波形图也使用canvas绘制,一个片段如果过长,当ZoomLevel变大时,也会超过浏览器限制,导致canvas无法绘制。
  • 另外,当Zoomlevel变化时,会导致所有片段重绘,从而导致所有波形图也会重绘。这个过程是很慢的,尤其是使用触摸板或者Slider微调时,卡顿尤为明显。

可以发现这些问题几乎都是canvas导致的,所以就有下一个方案,做Virtualized。

方案2:做Virtualized,使用浏览器滚动条
image.png (408×1 px, 27 KB)

针对方案1的问题,主要有两点优化:

  • 时间轴只绘制可见部分
  • 波形图只绘制可见部分,但是片段正常绘制

时间轴区域:使用 Konva 绘制,可以方便的实现鼠标拖拽且仅能沿X轴移动等。实际上会创建时间轴上的所有元素,因为用Konva和canvas,最终只有可见部分被渲染。

片段:一个片段在音轨的位置有五种可能,需要分别处理:

  • 完全不可见:仅渲染片段容器
  • 完全可见:完整渲染
  • 前半部分可见,后半部分不可见:波形图从片段资源起始位置绘制到屏幕
  • 前半部分不可见,后半部分可见:波形图从屏幕位置绘制到片段资源结束为止,并且加上 flex-end
  • 前半部分和后半部分都不可见,该片段撑满屏幕:绘制可见区域,再用 translate-x 将波形图移到屏幕内

滚动条

  • 拖拽滚动条时,调整时间轴的位置,以及被virtualized的波形图
  • 拖拽时间轴时,调整滚动条的offset,以及virtualized的波形图。
  • 调整滚动条offset是一个副作用,但其它都不是,处理 onScroll 时需要注意这一点。

这个方案在性能上已经可以接受,也不会触及到任何浏览器限制。小宇宙剪辑工具的第一版就采用该方案。

但该方案存在一个严重影响体验的问题: 缩放时,无法相对于鼠标位置居中缩放,就像Figma等大多数工具那样。基于该方案实现过相对鼠标位置居中缩放,实现方式是:

当缩放时(修改ZoomLevel),基于鼠标当前位置计算新的ZoomLevel下 scrollLeft 的期望值,修改容器的 scrollLeft ,但由于滚动是异步的,当ZoomLevel变化速度(触摸板产生的wheel事件)太快时,修改 scrollLeft 的速度不如ZoomLevel变化的速度,就导致滚动条的位置反复横跳。

最终该方案在修改ZoomLevel时,不修改 scrollLeft。但这个问题会导致用户在调整ZoomLevel时,在时间轴中迷失,很影响体验。

当前方案:做Virtualized,自己实现滚动条

image.png (438×1 px, 27 KB)

针对方案2的问题,有这几点优化:

  • 使用自己实现的滚动条
  • 只绘制屏幕可见的片段
  • 音轨容器宽度从原本的项目长度变为屏幕宽度
  • 时间轴进一步优化性能

该方案自己实现滚动条比较麻烦,其他逻辑相较于方案2反而更加简单。因为不需要监听滚动事件,不需要处理 scrollLeft 这些副作用。所有的信息都可以从store中获取,所有的改动都可以通过修改store完成。

这里说一下实现滚动条需要注意的几点:

  • Drag'n Drop时,如果被拖拽元素在音轨外,需要自己处理滚动条的滚动。
  • 滚动条包含滑块(Thumb)和轨道(Track)。滑块有最小宽度
  • 已知音轨总长度(trackLength),当前可见区域的宽度(viewWidth),Scrollbar的宽度(scrollbarWidth),以及可见区域相对音轨的位置(viewOffset)。来计算Thumb的宽度和Thumb的位置(thumbOffset)。
image.png (582×1 px, 26 KB)
  • 一开始很容易想到,用 ratio=trackLength/scrollbarWidth 计算出一个比例。然后等比缩小,就得出Thumb的宽度和offset。但如果等比缩小后,Thumb的宽度小于最小宽度时,这样的计算结果就有问题。所以需要这样计算:
const viewRatio = viewWidth / trackLength // 先计算一个比例
const thumbWidth = Math.max(25, viewRatio * scrollbarWidth) // 根据这个比例以及最小值,算出Thumb实际的宽度
const maxOffset = scrollbarWidth - thumbWidth // 根据新的thumbWidth计算滚动条里Thumb的最大offset
// 因为 thumbWidth 可能是25或者是 viewRatio计算出来的,所以需要重新计算offset的ratio
const ratio = maxOffset / (userMaxOffset ?? totalWidth - viewWidth)
const thumbOffset = ratio * offset

当把滚动条实现完成后,发现性能大幅度提高,并且也很容易做到相对鼠标位置居中缩放。

但发现调整ZoomLevel时会掉帧,一开始怀疑是转文字区域文字太多导致的。经过Profile看到慢的地方都和Konva有关,因为整个项目中只有时间轴用了Konva,所以把时间轴暂时隐藏掉,就非常流畅。

后来发现,虽然Konva当元素在屏幕外时并不会绘制,也不会有很多事件监听,但它依然会产生大量的Konva Object。当ZoomLevel改变时,就会重新创建所有的Konva Object,导致卡顿。

到此位置,音轨区域和波形图的性能优化就告一段落。


最后再简单说几个比较细节的优化点:

优化波形图生成和计算速度

生成波形图的方式是:

  1. 先下载音频文件
  2. 执行 decodeAudioData ,产生 AudioBuffer
  3. 从AudioBuffer中 getChannelData 即可获得音频的原始采样数据。

但因为是播客音频,单文件动辄几十分钟,如果采样率是44100hz的话,第3步一个声道就有过亿的采样数。为减少采样数,在解码时创建一个采样率只有8000的 AudioContext,用它来执行 decodeAudioData , 就可以减少采样数,计算也会更快。另外也会把生产的波形图数据缓存到IndexedDB里,避免重复生成。

平滑缩放

当使用鼠标滚轮或者触摸板产生Wheel时间时,想要尽可能顺滑的改变ZoomLevel。这里直接使用 d3-zoom的函数

export function wheelDelta(deltaY: number, deltaMode: number): number {
  return -deltaY * (deltaMode === 1 ? 0.05 : deltaMode ? 1 : 0.002)
}

function zoomByDelta(delta: number): number {
  const next = this.zoomLevel * Math.pow(2, delta)
  return this.setZoomLevel(next,)
}
0neSe7en

0neSe7en