close
背景音浮

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

長音頻問題最初的技術方案

由於涉及到音頻編輯,並且播放和波形都需要實時響應,所以最常規最直接的方法自然就是使用 AudioBuffer,以解碼後的 PCM 數據來表示當前會話狀態,既方便編輯又方便波形繪製。音浮起初也是基於這個方案,對於一些中長時長(30min 以內)的音頻來說確實沒有什麼問題,但隨着項目的成熟,對所支持音頻時長要求越來越高,對各方面交互性能要求也越來越高,原本的技術方案面臨着非常多的優化和功能實現上的挑戰,進行技術改造甚至構建一套全新的前端音頻處理架構勢在必行。

技術挑戰

總結下來,傳統的基於 AudioBuffer 的音頻編輯在面對大體量音頻數據處理的場景中存在如下缺陷:

內存占用高
以常見的 44100hz 採樣率,雙聲道音頻為例,每小時解碼後的數據需占用內存 44100 * 2 * 3600 * 4B ≈ 1.2GB。

由於 AudioBuffer 長度不可變,因而每次編輯需要創建新的 AudioBuffer 實例,這樣新老實例並存就會產生相當於雙倍體量的瞬時內存占用,很容易因內存申請失敗而報錯,甚至導致瀏覽器崩潰。

編輯性能差

直接編輯解碼後的 AudioBuffer 由於需要操作大量內存,嚴重影響性能。

起播時間長

由於播放源為 AudioBuffer,需等待音頻全量解碼才能播放。在我的機器上每小時 mp3 音頻全量解碼約用時 8s。

首次波形繪製慢

首次繪製波形需等待音頻全量解碼,用時同起播時間。

不利於存儲

AudioBuffer 數據量大,使用戶會話難以存儲,對於本地存儲或許可以接收,但如果數百 MB 甚至過 GB 的數據要存在雲端顯然是不現實的。
切入點和難點

從以上總結不難看出,這些問題的根本原因在於長音頻全量解碼後的數據量巨大,因而我們很容易想到解決問題的核心切入點:流式解碼和隨機解碼(解碼音頻中的某一特定時間範圍的片段)。由於原始文件是一定要進行雲端存儲的,所以不如把用戶會話中的一等公民由解碼後的數據轉換為解碼前的數據,即原始音頻文件。

但問題是,瀏覽器並沒有提供可直接使用的 API(雖然 Chrome 從 94 開始實現了支持流式編解碼的 Web Codecs API,但下文會說到為什麼我們不使用它),所以這裡的核心難點在於瀏覽器中的音頻流式解碼和隨機解碼實現。

初識 ReolAudio

最初,我們只是希望基於以上背景來探索一種 Web 音頻流式解碼方案,以解決音浮的長音頻問題,後來發現它不僅能對音浮帶來顯著的優化效果,其中用以支撐這套方案的許多功能還具有較強的通用性,可以應用於除音浮以外的項目。例如我們在另一個項目里使用了基於分幀的音頻剪輯,也取得了性能和穩定性的大幅度提高;再比如像 Web DAW 那樣的重音頻編輯場景,在基礎架構層面面臨着與音浮相似甚至更高的挑戰,所以也天然適用。

因而在經過一段時間的迭代和優化後,我們把這套能力進行了重新梳理和封裝,也就產生了本文所要介紹的 ReolAudio,其功能目前主要涵蓋:

多種流行音頻格式(mp3/mp4/m4a/aac/wav)資源的解析和分幀,即獲取音頻文件的元信息以及封裝結構信息。

靈活的音頻解碼能力:全量解碼、流式解碼、隨機定位解碼。

一個高性能的採樣播放器,用以支撐高定製化的前端音頻播放器建設。

內置的幀序列化和反序列化工具,方便幀信息的本地或雲端存儲。

高性能、高可靠性的音視頻格式(mp3/mp4/m4a/aac/wav/flac/ogg/flv/mid)檢測。

一套完整的以幀序列為基礎的在線音頻剪輯、播放、波形、存儲方案。

簡單快速的音頻文件剪輯。

定位和目標

音頻相關應用的開發工作都可分為算法和工程兩大方面,ReolAudio 則聚焦於前端場景下的音頻工程。它致力於提供一套豐富、強大、通用的音頻處理基礎能力,並與原生 Web Audio API 相輔相成,使構建各種高級、高定製化的富音頻交互應用或框架成為可能。同時,我們希望它保持輕量、高性能以及良好的兼容性。

基本原理什麼是分幀

雖然音頻編碼算法多種多樣(如 mp3、aac、flac 等),但它們在最基本的層面上具有一定的共性,即都是先把原始 PCM 數據分為一幀一幀的最小單元(一幀時長通常在 20~60ms,這是因為基於聲學原理,該時間段內的聲音信號保持特徵穩定的概率較大),再分別對每一幀進行編碼,得到一個編碼後的幀序列,最後將它們以一定的格式封裝為完整的音頻文件。基於此,可以對封裝後的音頻文件進行分幀,並解析出每一幀的參數,這兩個過程在本文中分別稱為 「分幀」 和 「解幀」。

為什麼要分幀
分幀本質上是解封裝,不涉及編解碼,性能非常高,內存占用小。
不需要解碼也可以編輯音頻,通過幀序列的增刪和排序就能實現音頻的裁剪、拼接、插入等操作。
幀與幀是相對獨立(不一定完全獨立)的,一段連續的幀片段可以直接使用 Web Audio API 中的 decodeAudioData 進行解碼,因此可實現流式解碼、按需解碼。
幀信息相當於音頻文件的「地圖」,理論上有了幀信息才能完全保證精確 seek,否則對於變比特率格式(如 abr/vbr 的 mp3),很難精確算出特定時間的數據在源文件中的位置。
初次繪製波形可採樣解碼,耗時大大降低;幀中可維護波形摘要,無需存儲解碼後數據也能快速生成後續波形。
如果以後需要用到 Web Codecs API,解封裝相關操作仍需手動實現,可以直接復用。
整體架構

其中:

核心能力:一些與具體業務、使用場景無關,僅與音頻本身相關的通用底層能力。既包括針對各種已支持音頻格式的特定能力(如 mp3 解幀、mp4 box 的解析等),又包含格式無關的通用能力。
上層封裝:在核心能力的基礎之上所封裝的一些可能與具體業務場景相關的框架或功能。如專為音浮及相似應用場景打造的音頻處理框架 frame-based、為 SoundOn 項目所編寫的簡單在線音頻剪輯工具 cut-audio 等。
關於 FFmpeg+WebAssembly

談到音視頻,可能永遠繞不開 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 文件,這樣做的好處如下:

文件中的視頻內容會被直接剔除。視頻流的碼率往往可以達到音頻流的數倍乃至數十倍,所以只保留音頻會大大減小文件體積,便於傳輸和存儲。
轉成 aac 後可以直接復用 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 相似,這裡不再贅述。

WAV

Wav 文件中存儲的是未經編碼的 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,完全滿足本地或網絡存儲的要求。

序列化

多個幀的序列化只需將每幀的序列化結果拼接即可,對於單個幀,序列化流程為:

定義幀字段枚舉(FrameField)以及每個字段的值類型(FrameType),這樣幀的每個字段名都只需要用一個 uint8 存儲即可,字段值以特定的格式進行讀寫:
exportenumFrameField{uri=1,index=2,offset=3,size=4,sampleSize=5,sampleIndex=6,wave=7,}constFrameType:Record<FrameField,string>={[FrameField.uri]:'u16',[FrameField.index]:'u32',[FrameField.offset]:'u32',[FrameField.size]:'u16',[FrameField.sampleSize]:'u16',[FrameField.sampleIndex]:'f64',[FrameField.wave]:'wave',}

其中:wave 為波形摘要,它是一種自定義數據格式(專為 frame-based 框架而設計,下文有介紹),其結構為第一個字節存儲振幅樣本個數,後續每個字節存儲各個振幅樣本。

遍歷幀中每個字段,以 uint8 的格式寫入字段 id,再根據字段值類型寫入具體值,之後以相同的方式處理下一個字段。
所有字段序列化完畢後,在序列化結果開頭以 uint8 的格式寫入總長度即可。
反序列化

按照序列化時的規則反向解析出每一幀的信息即可,這裡不再贅述。

格式檢測

格式檢測作為音視頻處理中的基礎功能,有着廣泛的應用場景,例如對用戶的輸入進行過濾、根據格式對文件做不同的處理等。由於文件名可任意修改,根據擴展名來判斷文件格式通常是不可靠的,因而格式檢測旨在通過文件內容來推斷出文件真實格式。

基本目標

就我個人的總結,可靠的格式檢測應當實現以下兩個目標:

對於是某種格式的文件,一定能夠檢測出它是該種格式。
對於不是某種格式的文件,儘可能讓它不被檢測成該種格式。

目標 1 比較容易理解,假如輸入的是一個標準的 mp3 文件,那麼就一定要檢測出它是 mp3,保證不會出現「冤枉好人」的場景,因此是必須要 100% 確保的。

對於目標 2,其實是功能與成本的一個權衡。為保證性能,通常我們僅僅是檢測文件是否符合某種格式的特徵(Magic Number,通常是某幾個特定的字節),而這也就必然會產生不是某種格式的文件卻能通過檢測的可能性,為降低這種可能性,可以儘量檢測較多的特徵。

file-type

file-type 是 npm 上非常流行的文件格式檢測工具,它支持多種輸入形式(如 Buffer、TypedArray、Blob、Stream 等),以及幾乎所有常見文件格式,包括各種音視頻、圖片、文檔、壓縮格式等。

ReolAudio 最初也直接依賴了它,但因為存在一些不符合要求的點而已不再使用:

不能滿足目標 1。例如,如果一個 mp3 文件中記錄的 id3v2 tag 長度與實際不符,將不能檢測出它是 mp3 文件。

其針對瀏覽器的版本依然會引入 node 環境相關 polyfill(如 Buffer),這對於一個 library 來說是不可接受的。

檢測的格式太多,不夠輕量,且影響性能。

ReolAudio 的實現

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-based

framed-based 是專為音浮及相似音頻處理場景所打造的在線音頻處理框架。它以「幀序列」來表示當前會話狀態,支持不同格式不同文件中幀的組合,通過直接編輯幀序列來輕鬆實現音頻的裁剪、拼接、插入、替換等操作,同時封裝了強大的幀序列解碼器、播放器、波形繪製器。

解碼器

可獨立解碼片段:在完整的幀序列中,從屬於同一個資源、且與在源文件中順序一致且連續的子序列稱為一個「可獨立解碼片段」。一個完整的幀序列也可看作由多個可獨立解碼片段組成,每個片段的幀所對應的音頻數據可以進行一次性加載和解碼。

解碼器所實現的核心功能就是在一個幀序列中,對特定位置特定時長的音頻片段進行數據加載和解碼,為此,它定義以下輸入輸出:

輸入
frames:幀序列

resourceMap:資源表

beginIndex:解碼起始幀索引

targetDuration:目標解碼時長(因為可能超出當前可獨立解碼片段範圍,所以實際解碼時長不一定能達到目標數值)

targetFrameCount:目標解碼幀數(同上)

sampleRate:解碼採樣率

輸出

endIndex:實際解碼到的幀索引

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-based

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

相較於 frame-based,該方案的用戶會話狀態數據量非常小,編輯性能高,且編輯精度可達到採樣點級別,但它需要存儲的總幀數要大於 frame-based(因為前者需要存儲所有幀,而後者僅需存儲使用中的幀),且波形摘要需獨立於幀而單獨存儲。所以兩者均有各自適用的場景,具體使用哪一種還需根據具體場景和需求而定。

cut-audio

基於已有的分幀能力,可以輕易實現簡單的音頻剪輯。cut-audio 的工作原理類似我們在使用 FFmpeg 時指定了 -c copy 參數,當我們輸入了剪輯片段的起止時間,它會根據元數據和分幀信息尋找到最近似的數據片段並從源文件中截取下來,再進行簡單的封裝操作即可得到剪輯後的文件。整個過程只有數據拷貝,不涉及任何編解碼,因而具有極高的性能。

以 mp3 為例:

接入表現音浮

該套技術方案最初專為解決音浮的長音頻問題而設計,由於是全新的技術架構而非簡單優化,接入後各方面性能指標均有顯著提升。

以下性能指標對比數據均取自一個 1.5h、44100hz、雙聲道的 mp3 文件。

內存占用
接入前:常駐占用 1.9 G,編輯時瞬時占用高達 3.5 G
接入後:< 250M
起播時間
接入前:約等於全量解碼時間,12s 左右
接入後:< 100ms
首次波形繪製時間
接入前:約等於全量解碼時間,12s 左右
接入後:粗略波形繪製時間 < 1s

粗略波形:

精確波形:

本地/雲草稿
接入前:解碼後數據量太大,無法存儲
接入後:會話狀態通過幀序列/資源表保存,連同波形摘要數據量不超過 1.5M
SoundOn

SoundOn 是一個服務於海外音樂人的開放平台,幫助音樂人將他們的音樂分發到字節跳動旗下業務和其他平台上。

在線剪輯

平台提供音頻在線剪輯能力,該功能最初基於 @ffmpeg/ffmpeg,一個開源的 FFmpeg 到 WebAssembly 的編譯實現。據我當時得到的信息,用它做剪輯存在體積重、兼容性差、內存占用高、耗時長等缺陷,甚至有可能導致瀏覽器崩潰,非常影響用戶體驗。

儘管這些問題很可能是由不正確的使用方式或參數設定導致的,但基於 ReolAudio 來實現在線剪輯顯然是更好的選擇,而 cut-audio 正是在這種背景下誕生,接入後各項指標均有顯著提升:

不存在兼容性問題,因為它完全不依賴任何瀏覽器 API。

由於直接復用源文件數據,不涉及編解碼,其耗時、內存占用均可忽略。

經過一段時間的更新迭代後,剪輯成功率更是達到了 100%。

格式檢測

平台最初直接使用 file-type 做格式檢測,由於其不準確的問題,經常導致符合要求的文件無法被上傳。在切換到 ReolAudio 後,便完全避免了這種 case 的產生。

未來計劃更多的音頻格式支持

目前僅根據業務需要支持了幾種最流行的音視頻格式,未來會支持更多瀏覽器可播格式(如 flac、ogg),甚至某些瀏覽器原生不可播格式(如 flv)。

flac : 結構大體上與 mp3 相似。

flv : 音頻多為 aac 編碼,可以像處理 mp4 那樣先轉成 aac,且它的結構遠比 mp4 簡單。

webm : 有待調研。

API 文檔建設

由於目前主要是團隊內項目在使用,現有文檔也多為介紹性的技術文檔,缺乏完備的 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。

參考
MPEG Audio Frame Header - CodeProject

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

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()