小宇宙Studio剪辑页面音频播放器

Published on
Authors

前言

小宇宙Studio的剪辑页面希望用户修改后能立即试听。所以实时预览的播放器必不可少。 因为播客的长音频特性,使得方案和传统的DAW有所不同。这里介绍一下小宇宙的播放器实现方式,遇到的坑,以及目前还存在的潜在问题,欢迎在下面讨论~

WebAudio Graph

小宇宙Studio的播放器基于WebAudio API实现。Web Audio API是浏览器提供的灵活播放声音、增加音效等的接口。用户可以操作WebAudio的各个Node,最终形成一个WebAudio Graph。

这两个网站有非常详细的介绍

在设计阶段,这两个技术问题影响了最终方案:

长音频解码

主播上传的音频素材一般是几十分钟,如果使用 AudioBufferSourceNode,需要将几十分钟音频文件(比如mp3)解码为 AudioBuffer(PCM)

AudioBuffer 由若干Float32Array组成,每个Float32是一个采样。比如单声道、60分钟、44.1khz的音频文件,解码decodeAudioBuffer后的AudioBuffer就是 60 * 60 * 44100 * 4 / 1024 / 1024 = 600MB 。这600MB会一直保存在内存里。

这只是一个音频文件,如果文件很多,非常容易超过浏览器内存上限。

变速不变调

制作一期播客,主播需要重复听很多遍素材,为了提高效率,需要有变速不变调,也就是倍速试听的能力。WebAudio API中,AudioBufferSourceNode 的playbackRate是变速且变调的。如果想让 AudioBufferSourceNode 变速不变调播放声音,需要用 SoundTouch 处理。

为了解决 长音频解码 问题,使用 MediaElementAudioSourceNode 播放音频,这种方式较为Hack。

MediaElementAudioSourceNode 是让 <audio> 输出到WebAudio Graph中,而不是直接播放出声。 因为 MediaElementAudioSourceNode 用的是 <audio>,是浏览器的原生解码器、播放器,所以长音频不会导致内存问题。同样的原因,给 <audio> 设置playbackRate也能够保持声调

当这两个技术问题解决后,整个方案也就有了。

下图是WebAudio Graph的结构。

F68348 size=full,layout=center

  • 每组 MediaElementAudioSourceNode + gainNode 对应一个片段,每个片段可以调节音量
  • 每个音轨有一个 gainNode ,输出到 项目gainNode 调整项目音量
  • 项目gainNode 输出到AudioWorklet 计算当前音量
  • 最后输出到 masterGain,用于调整最终播放器音量。

Orchestrator

有了WebAudio Graph,还需要一个Orchestrator实现播放、暂停、获取当前进度、seek、变速等逻辑。

播放和调度

如果使用 AudioBufferSourceNode 实现播放器会很简单,只需要设置好合成后的 AudioBuffer ,或者每个片段分别生成AudioBuffer,用 AudioScheduledSourceNode.start([when [, offset [, duration]]]) 方法指定何时从何处播放,就不用管了。

但我们是基于 MediaElementAudioSourceNode ,而它只能使用 <audio> 提供的方法,比如 Audio.play()Audio.currentTime = xxx 设置。所以只能用 requestAnimationFrame 等定时器调度,当时间到了,就找到指定的 Audio 让其seek并播放并到指定位置,或者暂停。

requestAnimationFrame与setInterval的问题

当前Tab或者当前浏览器不处于focus状态时,浏览器会大幅度降低 rafsetInterval 的触发频率,导致切到后台时,播放器调度时间不准。

解决办法是使用Worker,在Worker里设置setTimeout,时间到了postMessage到主线程。我们使用的是 chrisguttandin/worker-timers 库,也可以看下 Tone.js的实现

当前进度获取

WebAudio获取当前播放进度的计算方式和 Audio.currentTime 有很大区别。AudioContext开始之后,它的 currentTime 是不停增长的。 播放音频时需要记录:当前 AudioContext.currentTime ,项目的起始位置 startOffsetTime ,本次播放的 playbackRate

播放进度为:

播放进度 = (AudioContext.currentTime - startPlayCurrentTime) * playbackRate + startOffsetTime

简化后的流程图

F68350 size=full, layout=center

存在的问题:延迟

上述方案存在的最大问题来自 MediaElementAudioSourceNode ,它可能会导致播放器延迟过大。

音频延迟是十分重要的,大部分浏览器会为WebAudio开一个独立的线程,保证时间精确。延迟大概在几毫秒至30毫秒。

<audio> 在播放时解码,延迟较大,再使用setTimeout调度,导致延迟更加明显,浏览器又无法修正蓝牙耳机的延迟。在有些情况下,延迟会非常明显,导致指针位置和实际听到的内容不符。

目前想到的解决办法:用户点击播放时,使用 WebAssembly 解码并混音将要播放的音频,得到30秒的Buffer,传给WebAudio。