小宇宙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。