SoundCloud的web播放库Maestro演进之路
Maestro是一款用于處理SoundCloud Web播放的庫,它在soundcloud.com、SoundCloud移動網站、網頁插件、Chromecast和Xbox應用中每天成功處理數千萬次的播放。如今,我們正在考慮開源,這篇博文將介紹我們迄今為止在Maestro開發過程中所取得的技術成就。感謝前熊貓直播前端技術專家姜雨晴對本文的翻譯和審校。
文 / Tom Jenkinson?
譯 & 技術審校 / 姜雨晴
支持性
在SoundCloud,我們希望可以支持所有現代網絡瀏覽器、移動瀏覽器和IE 11。我們的目標是利用瀏覽器提供的功能提供最佳的播放體驗。
媒體流
我們目前支持三個解碼器的媒體流:?
mp3?
opus?
aac
我們的主要協議是HLS(HTTP Live Streaming)。這意味著音頻內容將被切割成片段,我們有一個單獨的文件(播放列表),其中包含所有片段的URL,以及它們在音頻內容中的相應時間。您可以在此處找到有關HLS的更多信息。
瀏覽器提供的內容
我們使用瀏覽器的audio標簽,媒體源擴展(MSE)和 Web Audio API。
我們需要瀏覽器至少支持 audio 標簽、擁有流媒體解碼和播放能力。MSE和Web Audio API 是獲得最佳體驗所必需的。
當 Web Audio API 或 MSE 丟失或播放期間出現錯誤時,我們可以正常降級。
我們將稍微介紹一下我們使用MSE和Web Audio API的內容,但首先,讓我們看看該audio 標簽為我們做了些什么。
audio
如果瀏覽器支持解碼,則可以獲取音頻文件的URL并進行播放。它會content-type在響應的標頭中通知編解碼器,它提供了一個API,可用于控制播放并確定瀏覽器是否支持解碼:
媒體源擴展
僅使用audio 標簽,瀏覽器就可以完成幕后的所有工作,但您無權訪問它的底層緩沖區。
使用MSE,我們可以為瀏覽器支持的解碼器創建緩沖區。然后我們可以處理自己下載媒體并將其附加到緩沖區。這意味著我們可以進行優化,如:預加載,這是我們在您單擊播放按鈕時,將其存儲在內存中,預先下載我們認為您將播放的音頻文件的前幾秒。然后當您單擊播放時,我們將此數據直接從內存中添加到緩沖區,而不必從網絡獲取:
const?audio?=?document.createElement('audio');
const?mse?=?new?MediaSource()
const?url?=?URL.createObjectURL(mse)
audio.src?=?url
audio.play()
mse.addEventListener('sourceopen',?()?=>?{
??//?'audio/mpeg'?for?mp3
??const?buffer?=?mse.addSourceBuffer('audio/mpeg');
??buffer.mode?=?'sequence';
??const?request?=?new?Request('http://example.invalid/segment0.mp3');
??fetch(request).then((response)?=>?response.arrayBuffer()).then((data)?=>?{
????buffer.appendBuffer(data);
??});
});
Web Audio API
Web Audio API是這里提到的最新的API。當您播放,暫停或搜索時,我們會使用此API的一小部分來快速淡入淡出。這使得播放體驗更加的爽快、播放/暫停不那么突然:
const?audio?=?document.createElement('audio');
const?context?=?new?AudioContext();
const?sourceNode?=?context.createMediaElementSource(audio);
const?gainNode?=?context.createGain();
sourceNode.connect(gainNode);
gainNode.connect(context.destination);
audio.src?=?'http://example.invalid/something.mp3';
audio.play();
//?Schedule?fade?out.
gainNode.gain.linearRampToValueAtTime(0,?context.currentTime?+?1);
Maestro的目標
簡單的API
插件架構
易于檢測功能
類型安全
支持所有主流瀏覽器
處理瀏覽器實現中的差異和錯誤
優異的性能?
能夠預加載
盡可能地響應
可配置的緩沖區長度和緩存大小
能夠在具有內存受限的設備上工作,如Chromecast
檢測?
提供錯誤數據和性能數據,對其進行監控,以檢測錯誤并進行改進
技術棧
TypeScript
Lerna
Yarn
WebPack
API
Maestro包含許多包。核心包提供了一個抽象BasePlayer類,它提供了播放器API。它將任務委派給特定的實現,外部通信通過BasePlayer。可以通過player 方法檢索最新狀態,并且在有任何更改時通知用戶。
例如,該play()方法返回Promise可以解析或拒絕。這BasePlayer將告知是縣城何時應該播放或暫停,實現層將告知BasePlayer實際播放的時間。每個播放器實現都與實際play()方法分離。這也意味著isPlaying()可以完全處理方法和相應的更新BasePlayer。另一個例子是getPosition(),除了通知實現層播放時間,除非正在seek,在這種情況下BasePlayer將返回請求的時間點。這意味著時間getPosition()總是有意義的,用戶在seek時可以保證它不會跳轉,并覆蓋它。
播放器實現包含在單獨的包中,并且它們都擴展BasePlayer。我們目前有以下播放器:
HTML5Player - 這是最簡單的播放器。它采用URL和MIME類型,它們直接傳遞給媒體元素。
HLSMSEPlayer- 這擴展了HTML5Player,它需要一個Playlist對象來供段數據。該播放器使用MSE。
ChromecastPlayer - 此播放器是一個控制Chromecast的代理。
ProxyPlayer - 此播放器可以控制另一個播放器以便隨時切換。它還具有一些提供新播放器同步相關的配置。該播放器的一個好處是,它可以在真正的播放器還沒有的時候同步提供給應用程序。然后,一旦真實播放器可用,其狀態將被同步以匹配代理。其他一些用例是在Chromecast上播放和本地播放,或切換質量。該應用程序只需與一個播放器進行交互,切換可以在幕后進行。
狀態管理和事件
在Maestro中,有很多播放狀態需要管理,它們大部分都包含在內部BasePlayer。用戶還想知道某些部分的狀態何時發生變化,有時會通過執行其他播放器操作來對變化作出反應。當我們在單個線程上運行時,這會帶來一些復雜性。有時我們還會以原子方式(跨多個函數)更新狀態的幾個部分。例如:如果用戶跳轉到媒體的結尾,我們也想要將ended標志更新為true。更新ended標志有關的邏輯,與代碼中的查找邏輯無關,但跳轉狀態和結束狀態的更新應該在API中一起發生。
為實現這一目標,我們構建了一個名為的組件StateManager,它使我們能夠:
在調用之前更新函數的多個部分,以通知用戶更改。
在播放器調用堆棧的末尾通知用戶狀態更改,以便他們與播放器的任何交互不會因此而在調用堆棧中交錯。(例如,執行工作然后觸發事件,而不是觸發事件然后執行工作。)
StateManager
StateManager維護一個狀態對象。對該對象的所有更改都是使用update()方法進行的,并且可以提供回調,然后在update()最后通知回調發生的任何狀態更改。這些調用可以嵌套:
type?ChangesCallback<State>?=?(changes:?Readonly<Partial<State>>,?state:?Readonly<State>)?=>?void;
type?Control?=?{
??remove:?()?=>?boolean;
};
type?Subscriber<State>?=?{
??callback:?ChangesCallback<State>,
??localState:?State
};
class?StateManager<State?extends?{?[key:?string]:?Object?|?null?}>?{
??private?_state:?State;
??private?_subscribers:?Array<Subscriber<State>>?=?[];
??private?_updating?=?false;
??constructor(initialState:?State)?{
????this._state?=?clone(initialState);
????//?...
??}
??public?update(callback:?(state:?State)?=>?void):?void?{
????const?wasUpdating?=?this._updating;
????this._updating?=?true;
????try?{
??????callback(this._state);
????}?catch(e)?{
??????//?error?handling...
????}
????if?(!wasUpdating)?{
??????this._updating?=?false;
??????this._afterUpdate();
????}
??}
??public?subscribe(callback:?ChangesCallback<State>,?skipPast?=?true):?Control?{
????//?...
??}
??private?_afterUpdate():?void?{
????this._subscribers.slice().forEach((subscriber)?=>?{
??????const?diff?=?this._calculateDiff(subscriber.localState);
??????//?We?always?recalculate?the?diff?just?before?calling?a?subscriber,
??????//?which?means?that?the?state?is?always?up?to?date?at?the?point?when
??????//?the?subscriber?is?called.
??????if?(Object.keys(diff).length)?{
????????subscriber.localState?=?clone(this._state);
????????deferException(()?=>?subscriber.callback(diff,?subscriber.localState));
??????}
????});
??}
??private?_calculateDiff(compare:?State):?Readonly<Partial<State>>?{
????//?...
??}
}
示例用法
type?OurState?=?{?a:?number,?b:?string,?c:?boolean,?d:?number?};
const?stateManager?=?new?StateManager<OurState>({
??a:?1,
??b:?'something',
??c:?true,
??d:?2
});
stateManager.subscribe(({?a,?b,?c,?d?})?=>?{
??//?On?first?execution:
??//?a?===?2
??//?b?===?'something?else'
??//?c?===?false
??//?d?===?undefined
??//?On?second?execution:
??//?a?===?undefined
??//?b?===?undefined
??//?c?===?undefined
??//?d?===?3
??updateD();
});
stateManager.subscribe(({?a,?b,?c,?d?})?=>?{
??//?a?===?2
??//?b?===?'something?else'
??//?c?===?false
??//?d?===?3
});
doSomething();
function?doSomething()?{
??stateManager.update((state)?=>?{
????state.a?=?2;
????updateB();
????state.c?=?false;
??});
}
function?updateB()?{
??stateManager.update((state)?=>?{
????state.b?=?'something?else';
??});
}
function?updateD()?{
??stateManager.update((state)?=>?{
????state.d?=?3;
??});
}
請注意,第一個訂閱回調將執行兩次,第二個訂閱也只執行一次,并且只執行最新狀態(即d === 3)。
另請注意,我們不會獲得嵌套調用堆棧,因為回調只在工作完成后才會執行。
瀏覽器限制
不幸的是,不同的瀏覽器具有不同的編解碼器支持(也可能取決于操作系統)和不同的容器需求。
例如,Chrome支持MSE中的原始MP3文件,但Firefox要求MP3位于MP4容器中。這意味著在Firefox中,我們需要將我們下載的MP3打包到瀏覽器中的MP4中。其他編解碼器具有類似的復雜性。
有bug也是不可避免的。為支持在安全的方式下,處理各種媒體的媒體處理管道,并且不破壞Web瀏覽器的向后兼容性,這是一項艱巨的任務!幸運的是,Maestro有能夠處理不同瀏覽器中各種錯誤的變通方法,其中一些在版本之間有所不同。
瀏覽器之間的自動播放策略也不同,這意味著我們目前必須在播放器之間共享媒體元素。這增加了復雜性,因為當元素的源被更改時,仍然會在之后的短時間內為前一個源發出事件,這意味著我們必須在嘗試使用它之前等待事件“清空”,并且我們必須保持跟蹤同時請求的所有內容。Maestro的HTML5Player通過使用provideMediaElement(mediaEl)和revokeMediaElement()讓這變得簡單。這允許您在運行時在播放器之間移動媒體元素。當播放器沒有媒體元素時,播放器就會暫停。
測試
在BasePlayer和播放器的實現是通過單元測試和集成測試覆蓋:我們采用Mocha,Sinon,karma,以及mocha-screencast-reporter。后者非常適合遠程查看測試的運行進度。
確保API的行為正確,該BasePlayer自身目前擁有超過700次測試。例如,測試檢查play()實現是否正在播放時解析了promise。一個測試play()如果在播放請求完成之前播放器被釋放,則另一個測試會被拒絕并返回正確的報錯。還有一些測試可以檢查播放器是否在檢測到不一致時報錯。 例如,一個播放器實現在BasePlayer從未請求過seek操作時,無法完成seek請求。
我們還使用SauceLabs在各種瀏覽器和瀏覽器版本(包括Chrome和Firefox beta)上運行所有測試。這需要幾個小時才能完成,因此我們測試了各主流瀏覽器,我們在發布之前測試所有內容。我們還每周運行所有測試,以確保新瀏覽器版本不會出現任何問題。這樣做,曾有一次高亮顯示了Firefox beta中的Web Audio錯誤,這會導致播放在前幾秒后停止。
漸進式流媒體(使用fetch()API)
我們最近添加了對漸進式流式傳輸的支持(在支持的瀏 這意味著在我們處理它并將其附加到緩沖區之前不必等待整個段被下載,我們能夠在數據到達時處理數據,這意味著我們能夠在段下載之前開始播放已完成。
這是通過fetch()API(以及moz-chunked-arraybuffer在Firefox中)實現的,它在下載時仍提供小部分數據:
fetch(new?Request(url)).then(({?body?})?=>?{
??return?body.pipeTo(new?WritableStream({
????write:?(chunk)?=>?{
??????console.log('Got?part',?chunk);
????},
????abort:?()?=>?{
??????console.log('Aborted');
????},
????close:?()?=>?{
??????console.log('Got?everything');
????}
??}));
});
在我們添加漸進式流式傳輸之前,如果下載失敗,我們只會重試它,這個邏輯非常獨立。使用漸進式流式傳輸更為復雜,因為如果下載部分失敗,整個管道已經開始處理數據。我們決定在錯誤時重試請求并丟棄我們已經看到的所有字節。如果重試失敗,那么我們就能夠在管道中產生報錯。
這也帶來了更多的復雜性。之前,我們知道每個段包含完整數量的有效音頻單元,這意味著管道的不同部分可以做出某些響應。現在,每個數據部分都可以包含一小部分音頻單元,因此我們需要能夠檢測到何時發生這種情況,并保留和等待一個完整單元到達的緩沖區。
下一步是什么?
我們自2017年6月開始運行Maestro,而且對播放問題的不良反饋很少。我們能夠實時監控性能和錯誤,并且在發生錯誤的情況下,我們能夠檢索播放日志,這有助于調試。
我們正在尋找Maestro的下一個目標,那就是你的參與:讓我們知道你將如何使用它,以及你想看到的功能:D
如果您對此帖有任何疑問,或者您發現soundcloud.com上有任何播放問題;),請與我們聯系!
姜雨晴的補充資料
MSDN上關于 AudioContext的一些解釋 https://developer.mozilla.org/zh-CN/docs/Web/API/AudioContext 有關AudioContext,會稍微吃性能。
漸進式流媒體,就是我們所說的直播流媒體,它會涉及到有時候一個片段并非完整的問題,必須要等待完整片段,之前寫的代碼中有做過處理。https://github.com/xiongmaotv/open-mccree/blob/f8491e33770c59fe6288f1a05daf8375d4f01820/packages/mccree-core-loaderbuffer/src/index.js#L46
文章中反復提到的狀態處理,是因為媒體播放很多方法是異步,尤其在不同瀏覽器直接也有差異,比如停止下載的cancel在chrome中是Promise,異步的,FireFox中就不是。
LiveVideoStack? 招募
LiveVideoStack正在招募編輯/記者/運營,與全球頂尖多媒及技術專家和LiveVideoStack年輕的伙伴一起,推動多媒體技術生態發展。了解崗位信息請在BOSS直聘上搜索“LiveVideoStack”,或通過微信“Tony_Bao_”與主編包研交流。
點擊【閱讀原文】,了解更多大會講師及內容信息。
總結
以上是生活随笔為你收集整理的SoundCloud的web播放库Maestro演进之路的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Cisco WebEx:企业协作服务中的
- 下一篇: 赵加雨:追求极致的习惯让我受益匪浅