音浮是 Hackathon(字節跳動內部的創新項目比賽)的獲獎項目。它是一個可視化的智能音頻剪輯工具,會自動將用戶輸入的音頻通過 ASR 引擎轉成文字,變「聽」為「看」,實現以編輯文本的方式剪輯音頻。同時,依託於強大的音頻 AI 技術,使用戶可以一鍵去除無效語氣詞、重複詞、靜音片段,自動降噪,甚至可以通過語音合成和音色克隆來糾正說錯的內容。

由於涉及到音頻編輯,並且播放和波形都需要實時響應,所以最常規最直接的方法自然就是使用 AudioBuffer,以解碼後的 PCM 數據來表示當前會話狀態,既方便編輯又方便波形繪製。音浮起初也是基於這個方案,對於一些中長時長(30min 以內)的音頻來說確實沒有什麼問題,但隨着項目的成熟,對所支持音頻時長要求越來越高,對各方面交互性能要求也越來越高,原本的技術方案面臨着非常多的優化和功能實現上的挑戰,進行技術改造甚至構建一套全新的前端音頻處理架構勢在必行。
技術挑戰總結下來,傳統的基於 AudioBuffer 的音頻編輯在面對大體量音頻數據處理的場景中存在如下缺陷:
由於 AudioBuffer 長度不可變,因而每次編輯需要創建新的 AudioBuffer 實例,這樣新老實例並存就會產生相當於雙倍體量的瞬時內存占用,很容易因內存申請失敗而報錯,甚至導致瀏覽器崩潰。
編輯性能差
起播時間長
首次波形繪製慢
不利於存儲
從以上總結不難看出,這些問題的根本原因在於長音頻全量解碼後的數據量巨大,因而我們很容易想到解決問題的核心切入點:流式解碼和隨機解碼(解碼音頻中的某一特定時間範圍的片段)。由於原始文件是一定要進行雲端存儲的,所以不如把用戶會話中的一等公民由解碼後的數據轉換為解碼前的數據,即原始音頻文件。
但問題是,瀏覽器並沒有提供可直接使用的 API(雖然 Chrome 從 94 開始實現了支持流式編解碼的 Web Codecs API,但下文會說到為什麼我們不使用它),所以這裡的核心難點在於瀏覽器中的音頻流式解碼和隨機解碼實現。
初識 ReolAudio最初,我們只是希望基於以上背景來探索一種 Web 音頻流式解碼方案,以解決音浮的長音頻問題,後來發現它不僅能對音浮帶來顯著的優化效果,其中用以支撐這套方案的許多功能還具有較強的通用性,可以應用於除音浮以外的項目。例如我們在另一個項目里使用了基於分幀的音頻剪輯,也取得了性能和穩定性的大幅度提高;再比如像 Web DAW 那樣的重音頻編輯場景,在基礎架構層面面臨着與音浮相似甚至更高的挑戰,所以也天然適用。
因而在經過一段時間的迭代和優化後,我們把這套能力進行了重新梳理和封裝,也就產生了本文所要介紹的 ReolAudio,其功能目前主要涵蓋:
靈活的音頻解碼能力:全量解碼、流式解碼、隨機定位解碼。
一個高性能的採樣播放器,用以支撐高定製化的前端音頻播放器建設。
內置的幀序列化和反序列化工具,方便幀信息的本地或雲端存儲。
高性能、高可靠性的音視頻格式(mp3/mp4/m4a/aac/wav/flac/ogg/flv/mid)檢測。
一套完整的以幀序列為基礎的在線音頻剪輯、播放、波形、存儲方案。
簡單快速的音頻文件剪輯。
音頻相關應用的開發工作都可分為算法和工程兩大方面,ReolAudio 則聚焦於前端場景下的音頻工程。它致力於提供一套豐富、強大、通用的音頻處理基礎能力,並與原生 Web Audio API 相輔相成,使構建各種高級、高定製化的富音頻交互應用或框架成為可能。同時,我們希望它保持輕量、高性能以及良好的兼容性。
基本原理什麼是分幀雖然音頻編碼算法多種多樣(如 mp3、aac、flac 等),但它們在最基本的層面上具有一定的共性,即都是先把原始 PCM 數據分為一幀一幀的最小單元(一幀時長通常在 20~60ms,這是因為基於聲學原理,該時間段內的聲音信號保持特徵穩定的概率較大),再分別對每一幀進行編碼,得到一個編碼後的幀序列,最後將它們以一定的格式封裝為完整的音頻文件。基於此,可以對封裝後的音頻文件進行分幀,並解析出每一幀的參數,這兩個過程在本文中分別稱為 「分幀」 和 「解幀」。
為什麼要分幀
其中:
談到音視頻,可能永遠繞不開 FFmpeg 這個話題,因為它幾乎是非瀏覽器環境音視頻處理中所必然用到的工具。因此在最終確定技術方案之前,我們也投入了較多的精力嘗試了基於 FFmpeg 的方案,探索了 FFmpeg 在前端落地的可行性與最佳實踐,也從多個維度對分幀和 FFmpeg 兩套方案進行了詳細對比和總結,其中也說明了為什麼最終選擇了基於分幀的方案。對於也希望在瀏覽器中使用 FFmpeg 的同學,這些信息或可作為參考。
解碼瀏覽器的 deocodeAudioData 可能是世界上最好用的音頻解碼工具,文件解封裝、解碼、重採樣、採樣格式轉換等全部流程都可以在一次簡單的函數調用中完成,並且是在獨立線程解碼,不阻塞渲染,對編碼格式的支持也較為完整,所以從必要性的角度看完全不需要 FFmpeg。
在性能方面,雖然瀏覽器解碼也是基於 FFmpeg,但 WebAssembly 代碼的執行性能終究達不到原生的水準。實測顯示對於 mp3、aac、flac 等常見編碼格式,WebAssembly 性能可以達到原生接口的 75% 左右,差別不是很明顯,還是比較符合甚至超出預期的。
測試環境
輸入格式
輸出 PCM 格式
原生接口用時
WebAssembly 用時
Mac OS 12
Intel Core i9
Chrome 96
編碼:mp3
聲道:1
時長:3:55
採樣率:44100
聲道:同輸入
樣本格式:float32
290ms
380ms
編碼:mp3
聲道:2
時長:1:22:45
10.5~11s
12.5~13s
編碼:aac
聲道:2
時長:4:04
250ms
330ms
編碼:flac
聲道:2
時長:3:18
320ms
400ms
定位與時長定位(Seek)與時長問題是決定我們選擇分幀而非 FFmpeg 的關鍵性因素。
我們發現,在處理網絡文件時,對於某些變比特率的音頻(如 aac 文件、abr/vbr 的 mp3 文件、沒有 SEEKTABLE 的 flac 文件等),FFmpeg 並不能準確獲取音頻時長以及進行精確定位。當然這並不是 FFmpeg 不夠完美,只是這種音頻由於比特率非恆定,其文件中數據位置與所對應時間點之間的對應關係通常是無規律的,這就導致它的時長計算與定位在理論上就無法做到極致精確,除非把資源完整加載到本地,但對於網絡資源來說,它所帶來的 IO 開銷顯然是不可接收的(尤其對於長文件)。因而要完美解決這個問題,對文件預處理並把必要的信息記錄下來可能是唯一的方法。
對於播放器來說,定位與時長不精確可能只是體驗上的問題,畢竟從整個音頻的角度看,其內容並沒有改變。而對於編輯器來說精確性是基本要求,在展示上都無法做到精確的話後續編輯也根本無法正常進行。
下面 3 個音頻文件的內容完全相同,真實時長均為 3:55,其中 CBR 為原始音頻,ABR、VBR 版本均由業界權威的 mp3 編碼器 Lame 重新編碼所得。當我們在瀏覽器中打開這些音頻,可以明顯發現 ABR 和 VBR 文件的時長展示和定位都是不準確的,其中 VBR 的時長誤差更是接近一分鐘。
輕量性
我自己嘗試編譯的精簡版的 FFmpeg 中 wasm 文件大小為 1.4M,其中開啟的解碼器與 Chrome 的音頻解碼能力對齊,具體如下:
--enable-decoder=mp3\--enable-decoder=aac\--enable-decoder=flac\--enable-decoder=vorbis\--enable-decoder=opus\--enable-decoder=pcm_s8\--enable-decoder=pcm_s16le\--enable-decoder=pcm_s24le\--enable-decoder=pcm_s32le\--enable-decoder=pcm_f32le\--enable-decoder=pcm_alaw\--enable-decoder=pcm_mulaw\--enable-decoder=adpcm_ms\--enable-decoder=gsm\而 ReolAudio 由於是純 JavaScript,其代碼量連同依賴一共也只有 80K(minified),僅相當於編譯 FFmpeg 所產生的 WebAssembly 膠水代碼的體量,所以從輕量性的角度說,ReolAudio 完勝。
成本和收益FFmpeg:需要開發者熟悉 C 語言和 FFmpeg API,需要維護大量 C 語言代碼,在實現 Seek 時還需要使用 SharedArrayBuffer 和 Atomics 處理多線程狀態同步,複雜度較高,對前端開發者不太友好。
分幀:最大的成本是手寫各種音頻格式的分幀實現。但流行的音頻格式也就固定的幾種,且常年不變,所以只是一次性成本,後續幾乎不存在額外的維護成本。雖然相較於 FFmpeg 方案來說也算是重複造輪子,但它在靈活性、可控性、高擴展性上所帶來的收益還是比較高的。
所以綜合來看,兩者在實現上都有一定的成本和複雜性,但如果從成本所帶來的收益上來看,還是分幀會具有一定的優勢。
關於命名ReolAudio,取自我非常喜歡的歌手 Reol,本名 れをる,是一位在演唱、作曲、作詞方面都才華橫溢且具有鮮明風格的日本藝術家。
核心能力基本數據結構在最基本的層面,ReolAudio 僅定義了「資源」和「幀」兩種數據結構。
資源(AudioResource)一個資源實例代表一個實際的音頻文件,並包含了在解析它時所得到的一些關鍵元信息,其中有些信息是通用的,有些信息則僅存在於特定格式。
exportinterfaceAudioResource{//音頻封裝格式type:AudioType;//文件總字節數size:number;//音頻總時長duration:number;//音頻採樣率sampleRate:number;//音頻聲道數channelCount:number;//最小幀的字節數minFrameSize?:number;//最大幀的字節數maxFrameSize?:number;//每幀中包含採樣點數(非恆定則為0)frameSampleSize?:number;//文件地址url?:string;//文件數據data?:ArrayBuffer;//音頻幀序列frames?:AudioFrame[];//文件名name?:string;//與特定格式相關的信息或其他自定義信息[other:string]:any;}資源表(ResourceMap)資源表其實就是一個資源 id 到資源實例的映射,方便其他對象通過資源 id 關聯和查找相應實例。
exporttypeResourceMap=Record<number,AudioResource>幀(AudioFrame)一幀可看做源文件中一小段編碼後的音頻數據,也是編碼和解碼操作的最小單元。每個幀實例都至少包含它在源文件中的位置和大小,也可以包含一些額外信息如所屬資源 id、採樣點個數等。
exportinterfaceAudioFrame{//該幀所屬的資源iduri?:number;//該幀在所屬資源所有幀中的位置index?:number;//該幀在源文件中的偏移位置offset:number;//該幀在源文件中的字節數size:number;//該幀所包含採樣點個數sampleSize?:number;//該幀第一個採樣點在源音頻所有採樣點中的次序sampleIndex?:number;//該幀波形摘要wave?:Uint8Array;}音頻的解析與分幀音頻的解析與分幀旨在針對每一種需要支持的音頻格式,分別實現對該類型文件的元信息讀取和幀結構解析,並對外暴露統一接口。這部分是其他所有功能的基礎,因為只有基於這些基本信息,才能支撐對音頻的各種後續操作。
下面是一個 1min 時長 mp3 音頻解析後得到的數據,可以看到整個音頻包含 1669 個幀,resource 中不僅包含了所有基礎音頻信息,還有一些僅針對 mp3 的特殊字段,如 id3 tag 長度(id3v1Size、id3v2Size)、mpegVersion 等。
下面簡要介紹下每種格式的基本處理方法。
MP3結構相對比較簡單,由可選的 id3v2 tag + mp3 frame 序列 + 可選的 id3v1 tag 組成,每個 mp3 frame 又分為 frame header 和 encoded data:
id3v2?
mp3 frame
mp3 frame
...
id3v1?
(128 byte)
frame header
encoded data
frame header
encoded data
...
在處理它時首先需要「掐頭去尾」,找出中間 mp3 frame 部分所在的位置。Id3v1 因為長度固定為 128 byte,所以比較好處理,只需檢測文件末尾是否存在即可;對於 id3v2 來說,雖然根據規範可以在特定位置直接讀取它的長度,但為了兼容長度記錄錯誤的文件(現實中還是存在這種文件的...),仍需要手動解析出它的長度作為兜底,具體不展開敘述。
接下來進行 mp3 frame header 解析即可,所有關於當前幀的信息都可以從幀頭讀取,幀頭詳細結構可見:http://www.mp3-tech.org/programmer/frame_header.html。
在解析出必要的幀信息後,需要通過以下公式計算出該幀長度,以得到下一幀位置,並重複解幀操作,直到結束。
constsize=Math.floor(sampleSize*bitrate*125/sampleRate+padding)*(layer===1?4:1)MP4/M4A不同於其他格式相對扁平的結構,mp4 由一堆層層嵌套的 box 組成,box 類型繁多且具有各自的解析方式,其官方規格說明文件更是超過 200 頁。不過好在幾乎所有 mp4 文件中音頻流都為 aac 編碼,也是瀏覽器唯一支持的 mp4 類型,因此我們會對用戶上傳的 mp4 文件進行預處理,提取其中的音頻並轉封裝為 aac 文件,這樣做的好處如下:
從 mp4 到 aac 的轉換主要是先解析出整個文件的結構,得到一個層層嵌套的 box 樹,然後單獨查找並解析某些特定的 box(如 stsc、stsz、stco 等等)以計算出源文件中音頻流每一幀的所在位置,再從源文件中切取出每一幀、添加 adts header 並組合成完整的 aac 文件。整個流程比較複雜,這裡不展開敘述。
AAC最簡單的格式,由一連串的 adts frame 組成,每個 adts frame 又分為 adts header 和 encoded data:
adts frame
adts frame
...
adts header
encoded data
adts header
encoded data
...
其結構和處理方法均與 mp3 相似,這裡不再贅述。
WAVWav 文件中存儲的是未經編碼的 PCM 數據(單個採樣可能經過編碼),最小存儲單元是採樣點,沒有幀的概念。不過根據使用場景的需要,也可以人為進行分幀,如把每 30ms 的數據分為一幀。
在最外層,wav 由一堆無序的 block 拼接而成,雖然 block 的類型多種多樣,但我們實際需要的只有 fmt、fact、data 三種,其中 fmt 存儲了音頻元數據,fact 是某些編碼格式特有且固定的,data 則存儲了實際採樣數據。在處理時我們首先解出文件中每個 block 的類型和位置(如下圖),然後再分別處理我們所關注的 block 即可。

提供音頻片段解碼能力,因為流式解碼和隨機解碼都是以音頻片段為最小解碼單元,而非完整的音頻文件,所以片段解碼是實現前兩者的基礎。
解碼方式瀏覽器提供了兩套 API 可用於音頻解碼,分別是 Web Audio API 中的 decodeAudioData 以及較新的 Web Codecs API。雖然前者的目標場景為完整文件的一次性全量解碼,但通過調研我們發現還是有簡單的辦法用它來解碼音頻片段的,再加上兼容性、易用性上的巨大優勢,我們毫無疑問選擇它作為底層的解碼引擎。兩者詳細對比如下:
decodeAudioData
WebCodecs
目標場景
一次性全量解碼
流式解碼 ✅
兼容性
chrome 14+, firefox 25+ ✅
chrome 94+
編碼格式支持
全部可播格式 ✅
wav 僅支持 a-law/u-law
其他全部可播格式
格式檢測
自動 ✅
無
重採樣
自動 ✅
無
異步
是 ✅
是 ✅
片段處理由於 decodeAudioData 的輸入必須是完整的音頻文件數據,所以在拿到音頻片段後,需要根據其格式來確定是否要做後處理來將其包裝為結構上的完整文件,再進行解碼。
MP3
無論是 id3v2 tag 還是 id3v1 tag 都不是必須存在的,所以由若干個 mp3 frame 組成的片段從結構上仍然可以看做完整的 mp3 文件,因此 mp3 數據片段無需處理。
MP4/M4A
decodeAudioData 只能解碼完整的 mp4 文件,但好在我們對 mp4 都會進行預處理轉成 aac,所以無需單獨處理 mp4。
AAC
本身就是一系列 aac 幀的組合,所以片段仍可看作完整文件,無需處理。
WAV
拿到 wav 數據片段後,需對其添加必要的 block,以拼裝成完整的 wav 文件,才能進行解碼。
由於 wav 中採樣點的編碼有多種(a-law, u-law, GSM 等),而每種文件中 block 結構也不盡相同,所以純手工加 block 是比較繁瑣的工作。因而我們採用的方式是在解析文件時直接從源文件提取必要的 block(fmt、fact),以 base64 格式保存在 resource 實例中,後續直接拼接並進行簡單的數據修改即可。
播放器SamplePlayer 是一個比較基礎、底層的播放器,並不能直接播放音頻資源或幀,而是需要向它輸入原始的採樣數據(float32 格式的 PCM 數據)。所以一般它是被用來繼承而非直接使用,根據具體應用場景和需求來擴展它的能力。
技術選型瀏覽器在音頻方面提供了大量的 API,其豐富的功能幾乎足以支撐任何複雜的音頻應用,但同時也容易使我們在選擇時犯難,因而在設計這個播放器之初,我們也詳細對比並嘗試了幾種不同方案:
方案
MSE
AudioBufferSourceNode
ScriptProcessorNode
AudioWorkletNode
可行性
因支持的封裝和編碼格式非常有限,且不支持多種格式數據的混合,而無法實現需求 ❌
AudioBuffer 拼接式播放 ✅
為每次輸入構建 AudioBufferSourceNode 並 schedule 到當前 AudioContext 時間線中
回調填充式播放 ✅
在 ScriptProcessorNode 回調中從緩衝區讀取數據
回調填充式播放 ✅
在 AudioWorkletNode 回調中從緩衝區讀取數據
優勢
兼容性好(chrome >= 14)
便攜:無需額外的腳手架配置
用法簡單,實現成本最低
便攜:無需額外的腳手架配置
是 ScriptProcessor 的官方替代品
回調執行在獨立線程,可保證播放流暢性
不足
雖然理論上功能可以滿足,但實現上不如回調填充式簡單、清晰、直接,許多地方比較 hack
暫停/繼續有一定開銷,緩存不易控制,暫停需清空已緩存的 SourceNode,繼續播放不能直接復用已加載數據
接口已被廢棄
回調執行在主線程,當主線程 CPU 占用高時不能保證回調可及時執行(與 setTimeout 類似),導致播放卡頓或產生尖刺音
僅支持 https 環境
便攜性不是很好,因為使用了 AudioWorklet,所以需要腳手架額外引入一定的配置才能支持
結論
不夠底層,靈活性達不到要求 ❌
不是特別契合使用場景,綜合評定不如 AudioWorkletNode ❌
受主線程 CPU 占用率影響過大 ❌
功能上完全滿足需求,不足可以接受 ✅
經過綜合評估,我們選擇了基於 AudioWorkletNode 的回調填充式播放方案,同時,它也是官方推薦的用以實現高精度、高定製化播放器的接口:
If sample-accurate playback of network- or disk-backed assets is required, an implementer should use AudioWorkletNode to implement playback.
摘自: https://webaudio.github.io/web-audio-api/#AudioBufferSourceNode
設計方案
雖然我們內部使用了 AudioWorkletNode,但並不希望開發者對此有所感知。因而我們僅暴露一個 SamplePlayer 類,由它去創建和維護 AudioWorkletNode 並負責與其通信和狀態同步,開發者只需要在主線程像使用常規類那樣去使用 SamplePlayer 即可。此外,它的實例本身也僅相當於一個 AudioNode,方便集成到任何複雜的音頻處理流水線(Audio Graph)中。
import{SamplePlayer}from'xxx'asyncfunctionmain(){constctx=newAudioContext({sampleRate:44100,})//初始化:加載 AudioWorklet 處理腳本awaitSamplePlayer.init(ctx)constplayer=newSamplePlayer({context:ctx,channelCount:2,//內部buffer最大容量為60s音頻數據bufferMaxDuration:60,})player.connect(ctx.destination)document.getElementById('play').onclick=()=>{player.play()}document.getElementById('pause').onclick=()=>{player.pause()}document.getElementById('push-data').onclick=async()=>{//每聲道一個Float32Array實例constpcm:Float32Array[]=awaitgetPcmDataSomeHow()player.push(pcm)}}main()序列化與反序列化序列化主要是解決幀序列的壓縮和存儲問題。雖然幀序列僅僅是普通的數組,可以用 JSON 來表示,但 JSON 的信息密度低(例如存儲一個 float32,二進制需要 4 個字節,而 JSON 可能需要十多個字節),即便可以進行再壓縮,但相較於轉換成緊密的二進制形式再壓縮,數據量還是會大一些,而且轉成二進制要比轉成 JSON 性能更高。
因此,序列化主要是設計了一個專門把幀序列轉成二進制形式的流程,轉換後再對其進行 deflate 壓縮,最終輸出為二進制或字符串的形式。經實測,每小時幀信息的最終序列化結果不超過 1M,完全滿足本地或網絡存儲的要求。
序列化多個幀的序列化只需將每幀的序列化結果拼接即可,對於單個幀,序列化流程為:
其中:wave 為波形摘要,它是一種自定義數據格式(專為 frame-based 框架而設計,下文有介紹),其結構為第一個字節存儲振幅樣本個數,後續每個字節存儲各個振幅樣本。
按照序列化時的規則反向解析出每一幀的信息即可,這裡不再贅述。
格式檢測格式檢測作為音視頻處理中的基礎功能,有着廣泛的應用場景,例如對用戶的輸入進行過濾、根據格式對文件做不同的處理等。由於文件名可任意修改,根據擴展名來判斷文件格式通常是不可靠的,因而格式檢測旨在通過文件內容來推斷出文件真實格式。
基本目標就我個人的總結,可靠的格式檢測應當實現以下兩個目標:
目標 1 比較容易理解,假如輸入的是一個標準的 mp3 文件,那麼就一定要檢測出它是 mp3,保證不會出現「冤枉好人」的場景,因此是必須要 100% 確保的。
對於目標 2,其實是功能與成本的一個權衡。為保證性能,通常我們僅僅是檢測文件是否符合某種格式的特徵(Magic Number,通常是某幾個特定的字節),而這也就必然會產生不是某種格式的文件卻能通過檢測的可能性,為降低這種可能性,可以儘量檢測較多的特徵。
file-typefile-type 是 npm 上非常流行的文件格式檢測工具,它支持多種輸入形式(如 Buffer、TypedArray、Blob、Stream 等),以及幾乎所有常見文件格式,包括各種音視頻、圖片、文檔、壓縮格式等。
ReolAudio 最初也直接依賴了它,但因為存在一些不符合要求的點而已不再使用:
其針對瀏覽器的版本依然會引入 node 環境相關 polyfill(如 Buffer),這對於一個 library 來說是不可接受的。
檢測的格式太多,不夠輕量,且影響性能。
ReolAudio 實現了一個音頻格式檢測函數:getType。它簡單、可靠、輕量,使用時只需傳入包含文件內容的 ArrayBuffer 即可:
import{getType,AudioType}from'xxx'consttyp=getType(fileData)if(typ===AudioType.MP3){console.log('Fileismp3')}getType 的不同之處在於它不僅檢測 Magic Number,更充分利用了已有的解幀能力,這使得它的檢測結果具有極高的準確性和可靠性,在非刻意偽造的情況下幾乎不可能產生錯誤檢測。
以下代碼展示了我們如何判斷一個文件是否為 mp3:
exportfunctionisMP3(buf:Uint8Array):boolean{//以"ID3"起始,說明是id3v2tag,該結構僅存在於mp3,因而檢測通過if(buf[0]===0x49&&buf[1]===0x44&&buf[2]===0x33){returntrue}//沒有id3v2tag的情況下,進行mp3幀檢測try{//首先需通過mp3幀頭檢測if(mp3.isFrameStart(buf,0)){//由於幀頭不具備明顯特徵,通過檢測也不能確保是mp3,//因而此處嘗試進行解幀,目的是得到下一幀位置const{frame}=mp3.parseFrame(buf,0)//若解出的下一幀位置仍通過幀頭檢測,則有足夠的信心判定源文件是mp3if(mp3.isFrameStart(buf,frame.size)){returntrue}}}catch(err){returnfalse}returnfalse}目前支持的格式有:mp3、mp4/m4a、aac、wav、flac、ogg、flv、mid。
上層封裝frame-basedframed-based 是專為音浮及相似音頻處理場景所打造的在線音頻處理框架。它以「幀序列」來表示當前會話狀態,支持不同格式不同文件中幀的組合,通過直接編輯幀序列來輕鬆實現音頻的裁剪、拼接、插入、替換等操作,同時封裝了強大的幀序列解碼器、播放器、波形繪製器。
解碼器可獨立解碼片段:在完整的幀序列中,從屬於同一個資源、且與在源文件中順序一致且連續的子序列稱為一個「可獨立解碼片段」。一個完整的幀序列也可看作由多個可獨立解碼片段組成,每個片段的幀所對應的音頻數據可以進行一次性加載和解碼。

解碼器所實現的核心功能就是在一個幀序列中,對特定位置特定時長的音頻片段進行數據加載和解碼,為此,它定義以下輸入輸出:
resourceMap:資源表
beginIndex:解碼起始幀索引
targetDuration:目標解碼時長(因為可能超出當前可獨立解碼片段範圍,所以實際解碼時長不一定能達到目標數值)
targetFrameCount:目標解碼幀數(同上)
sampleRate:解碼採樣率
輸出
duration:實際解碼時長
data:解碼後的 PCM 數據,每聲道一個 float32Array
ended:是否已觸達最後一幀
根據輸入和可獨立解碼片段邊界,我們可以得到本次解碼的幀範圍,再通過幀範圍確定該片段在源文件中的偏移和長度,最後通過 HTTP Range 頭加載下來並調用已有的片段解碼能力去解碼即可。
在確定解碼幀範圍時,有一個特殊處理,我們稱之為「墊幀」:
In the case of Layer I or Layer II, frames are totally independent from each other, so you can cut any part of an MPEG audio file and play it correctly. The player will then play the music starting from the first full valid frame it will find. However, in the case of Layer III, frames are not always independant. Due to the possible use of the "byte reservoir", wich is a kind of internal buffer, frames are often dependent of each other. In the worst case, 9 input frames may be needed before beeing able to decode one single frame.
摘自: http://www.mp3-tech.org/programmer/frame_header.html
根據上述資料,幀間的數據並非完全獨立,對於 mp3 文件,在最壞的情況下,需要有 9 幀的預輸入才能得到 1 幀的準確解碼數據,因此我們在確定幀範圍時,都在目標範圍的基礎上往前追溯最多 9 幀,待解碼完成後,再將這些幀對應的數據剔除。對於 aac 編碼,我們目前沒有找到相關資料,但實測顯示,把「墊幀」數調到 2 就可以完全避免解碼數據不完整的問題,因此我們目前將其設定為 2。
播放器基於 SamplePlayer 和已有的幀序列解碼能力,要實現幀序列的播放,其實僅額外需要一個數據加載調度機制即可。FramePlayer 繼承自 SamplePlayer,在此基礎之上實現了一個定時任務,用於根據當前播放進度自動調用解碼器進行數據的預加載和解碼,同時處理 Seek、解碼數據裁剪等細節問題。
由於是繼承關係,其使用方法與 SamplePlayer 基本一致,只需要額外指定要播放的幀序列和資源表、以及一些必要的參數來定製化它的數據加載策略即可:
import{FramePlayer}from'xxx'asyncfunctionmain(){constctx=newAudioContext()awaitFramePlayer.init(ctx)constplayer=newFramePlayer({context:ctx,})player.connect(ctx.destination)player.resourceMap=resourceMapplayer.setFrames(frames)//每次最多加載的音頻時長,單位splayer.decodeDuration=20//起播時最多加載的音頻時長,單位s,通常設定較小的值以降低起播時間player.startupDecodeDuration=10//加載音頻提前時間,單位splayer.loadBefore=10//檢測是否需要加載新數據的定時任務執行間隔,單位splayer.tickInterval=0.5player.seek(20)player.play()}main()波形繪製器為了繪製出精確的波形,對音頻進行全量解碼仍然是不可避免的操作。因此當首次加載一個音頻時,我們仍會對它全量解碼,但並不需要保存解碼後的數據,我們只是用這些數據來一次性生成「波形摘要」並保存起來。
波形摘要可以看作是降低時間精度(振幅精度基本不變)後的波形。與波形相同,它也是振幅序列,不同的是它僅存儲一個聲道的數據,每個振幅並不代表一個採樣點,而是一小段時間內(默認 20ms,這樣每幀大概 1~2 個幅值)的最大振幅,且用 uint8 來存儲。根據這個規則,我們可以計算出對於 44100hz、雙聲道的音頻文件,波形摘要的數據量僅為解碼後 PCM 數據總量的 (1000 / 20) / (44100 * 2 * 4) = 1 / 7065,但即便降低到如此精度,對於音浮以及大部分對波形時間精度要求不高的應用來說都是足夠的。我們將這些摘要存儲在對應的幀中,之後無論怎樣編輯幀序列,都只需要重新遍歷一次即可繪製出波形,無需額外的解碼操作。
此外,由於構建波形摘要本身需要一定時間,為了降低用戶看到波形前的等待時間,我們充分利用了隨機解碼的能力,在構建波形摘要的同時,在音頻內取若干等距時間點做採樣解碼,得到一個粗略的振幅序列,雖然它在振幅值上會有誤差,但其構建速度非常快,可以快速呈現給用戶,等摘要構建完成後再用精確的波形來覆蓋它。

下面的波形對比取自一個時長 13min 的 mp3 音頻,可以看到其粗略波形和精確波形的整體相似度還是比較高的,但粗略波形的繪製用時要遠小於精確波形。
粗略波形(用時約 400ms):

精確波形(用時約 3500ms):

基於 Clip 的音視頻編輯和處理是業界比較標準的方案。與 frame-based 不同,它將會話狀態表示為 Clip 序列的形式,每個 Clip 相當於對某個音頻資源中某一連續時間片段的引用,在播放時,根據 Clip 信息來實時加載和解碼必要的音頻資源片段。

相較於 frame-based,該方案的用戶會話狀態數據量非常小,編輯性能高,且編輯精度可達到採樣點級別,但它需要存儲的總幀數要大於 frame-based(因為前者需要存儲所有幀,而後者僅需存儲使用中的幀),且波形摘要需獨立於幀而單獨存儲。所以兩者均有各自適用的場景,具體使用哪一種還需根據具體場景和需求而定。
cut-audio基於已有的分幀能力,可以輕易實現簡單的音頻剪輯。cut-audio 的工作原理類似我們在使用 FFmpeg 時指定了 -c copy 參數,當我們輸入了剪輯片段的起止時間,它會根據元數據和分幀信息尋找到最近似的數據片段並從源文件中截取下來,再進行簡單的封裝操作即可得到剪輯後的文件。整個過程只有數據拷貝,不涉及任何編解碼,因而具有極高的性能。
以 mp3 為例:
該套技術方案最初專為解決音浮的長音頻問題而設計,由於是全新的技術架構而非簡單優化,接入後各方面性能指標均有顯著提升。
以下性能指標對比數據均取自一個 1.5h、44100hz、雙聲道的 mp3 文件。
內存占用粗略波形:

精確波形:

SoundOn 是一個服務於海外音樂人的開放平台,幫助音樂人將他們的音樂分發到字節跳動旗下業務和其他平台上。
在線剪輯
平台提供音頻在線剪輯能力,該功能最初基於 @ffmpeg/ffmpeg,一個開源的 FFmpeg 到 WebAssembly 的編譯實現。據我當時得到的信息,用它做剪輯存在體積重、兼容性差、內存占用高、耗時長等缺陷,甚至有可能導致瀏覽器崩潰,非常影響用戶體驗。
儘管這些問題很可能是由不正確的使用方式或參數設定導致的,但基於 ReolAudio 來實現在線剪輯顯然是更好的選擇,而 cut-audio 正是在這種背景下誕生,接入後各項指標均有顯著提升:
由於直接復用源文件數據,不涉及編解碼,其耗時、內存占用均可忽略。
經過一段時間的更新迭代後,剪輯成功率更是達到了 100%。
平台最初直接使用 file-type 做格式檢測,由於其不準確的問題,經常導致符合要求的文件無法被上傳。在切換到 ReolAudio 後,便完全避免了這種 case 的產生。
未來計劃更多的音頻格式支持目前僅根據業務需要支持了幾種最流行的音視頻格式,未來會支持更多瀏覽器可播格式(如 flac、ogg),甚至某些瀏覽器原生不可播格式(如 flv)。
flv : 音頻多為 aac 編碼,可以像處理 mp4 那樣先轉成 aac,且它的結構遠比 mp4 簡單。
webm : 有待調研。
由於目前主要是團隊內項目在使用,現有文檔也多為介紹性的技術文檔,缺乏完備的 API 文檔和使用手冊,因此後續會補充這部分內容。
實現 clip-based 框架基於 Clip 的音視頻處理是業界較為標準和成熟的方案。對於 ReolAudio 來說,得益於其強大的核心能力,完全可以在其之上以較低的成本實現 clip-based 架構。因此未來計劃實現它並與 frame-based 作為兩套並列的音頻處理方案。
在 Web DAW 中的應用探索相較於音浮,類似 Web DAW 的重音頻編輯場景更加複雜,對內存占用、編輯性能、渲染性能等方面具有更高的要求,對音頻處理基礎架構有更高的挑戰。另一方面,傳統的基於 AudioBuffer 的音頻編輯內存占用高,且單個會話需要保存大量 PCM 音頻數據,導致項目文件體積龐大,非常不利於會話狀態的本地/雲端持久化、撤銷重做、多人協作編輯等。而 clip-based 架構的特性天然與 Web DAW 應用場景高度吻合,可以完美解決上述問題。
歡迎加入我們是字節跳動音樂前端團隊,我們業務範圍廣泛,既有 toC 的音樂流媒體 Resso、智能音樂創作工具、TikTok 端內音樂,又有 toB 的海內外音樂人服務、音樂中台等;同時技術氛圍濃厚、技術棧豐富,在跨端(Electron、Lynx)、全棧、音視頻、基礎架構等領域均有深耕,相信一定會有你的發揮空間!歡迎加入我們:https://job.toutiao.com/s/RvtUpNR。
參考MP3' Tech - Frame header
[Developer Information - ID3.org](<https://id3.org/Developer Information>)
ISO - ISO/IEC 14496-12:2020 - Information technology — Coding of audio-visual objects — Part 12: ISO base media file format
ADTS - MultimediaWiki
Wav file format - musicg-api
Audio Worklet Design Pattern | Web | Google Developers
MIME Sniffing Standard