小宇宙Studio剪辑页面音频播放器
- Published on
- Authors
- Name
- 0neSe7en
- @0ne_Se7en
前言
小宇宙Studio的剪辑页面希望用户修改后能立即试听。所以实时预览的播放器必不可少。 因为播客的长音频特性,使得方案和传统的DAW有所不同。这里介绍一下小宇宙的播放器实现方式,遇到的坑,以及目前还存在的潜在问题,欢迎在下面讨论~
WebAudio Graph
小宇宙Studio的播放器基于WebAudio API实现。Web Audio API是浏览器提供的灵活播放声音、增加音效等的接口。用户可以操作WebAudio的各个Node,最终形成一个WebAudio Graph。
这两个网站有非常详细的介绍
- Web Audio API的书籍:比MDN的文档更有条理,循序渐进的介绍Web Audio的各种知识
- Web Audio API - Web APIs | MDN (mozilla.org)
在设计阶段,这两个技术问题影响了最终方案:
长音频解码
主播上传的音频素材一般是几十分钟,如果使用 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状态时,浏览器会大幅度降低 raf 和 setInterval 的触发频率,导致切到后台时,播放器调度时间不准。
解决办法是使用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。