SoundCloud的web播放库Maestro演进之路
Maestro是一款用于處理SoundCloud Web播放的庫,它在soundcloud.com、SoundCloud移動網(wǎng)站、網(wǎng)頁插件、Chromecast和Xbox應用中每天成功處理數(shù)千萬次的播放。如今,我們正在考慮開源,這篇博文將介紹我們迄今為止在Maestro開發(fā)過程中所取得的技術(shù)成就。感謝前熊貓直播前端技術(shù)專家姜雨晴對本文的翻譯和審校。
文 / Tom Jenkinson?
譯 & 技術(shù)審校 / 姜雨晴
支持性
在SoundCloud,我們希望可以支持所有現(xiàn)代網(wǎng)絡(luò)瀏覽器、移動瀏覽器和IE 11。我們的目標是利用瀏覽器提供的功能提供最佳的播放體驗。
媒體流
我們目前支持三個解碼器的媒體流:?
mp3?
opus?
aac
我們的主要協(xié)議是HLS(HTTP Live Streaming)。這意味著音頻內(nèi)容將被切割成片段,我們有一個單獨的文件(播放列表),其中包含所有片段的URL,以及它們在音頻內(nèi)容中的相應時間。您可以在此處找到有關(guān)HLS的更多信息。
瀏覽器提供的內(nèi)容
我們使用瀏覽器的audio標簽,媒體源擴展(MSE)和 Web Audio API。
我們需要瀏覽器至少支持 audio 標簽、擁有流媒體解碼和播放能力。MSE和Web Audio API 是獲得最佳體驗所必需的。
當 Web Audio API 或 MSE 丟失或播放期間出現(xiàn)錯誤時,我們可以正常降級。
我們將稍微介紹一下我們使用MSE和Web Audio API的內(nèi)容,但首先,讓我們看看該audio 標簽為我們做了些什么。
audio
如果瀏覽器支持解碼,則可以獲取音頻文件的URL并進行播放。它會content-type在響應的標頭中通知編解碼器,它提供了一個API,可用于控制播放并確定瀏覽器是否支持解碼:
媒體源擴展
僅使用audio 標簽,瀏覽器就可以完成幕后的所有工作,但您無權(quán)訪問它的底層緩沖區(qū)。
使用MSE,我們可以為瀏覽器支持的解碼器創(chuàng)建緩沖區(qū)。然后我們可以處理自己下載媒體并將其附加到緩沖區(qū)。這意味著我們可以進行優(yōu)化,如:預加載,這是我們在您單擊播放按鈕時,將其存儲在內(nèi)存中,預先下載我們認為您將播放的音頻文件的前幾秒。然后當您單擊播放時,我們將此數(shù)據(jù)直接從內(nèi)存中添加到緩沖區(qū),而不必從網(wǎng)絡(luò)獲取:
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
插件架構(gòu)
易于檢測功能
類型安全
支持所有主流瀏覽器
處理瀏覽器實現(xiàn)中的差異和錯誤
優(yōu)異的性能?
能夠預加載
盡可能地響應
可配置的緩沖區(qū)長度和緩存大小
能夠在具有內(nèi)存受限的設(shè)備上工作,如Chromecast
檢測?
提供錯誤數(shù)據(jù)和性能數(shù)據(jù),對其進行監(jiān)控,以檢測錯誤并進行改進
技術(shù)棧
TypeScript
Lerna
Yarn
WebPack
API
Maestro包含許多包。核心包提供了一個抽象BasePlayer類,它提供了播放器API。它將任務(wù)委派給特定的實現(xiàn),外部通信通過BasePlayer。可以通過player 方法檢索最新狀態(tài),并且在有任何更改時通知用戶。
例如,該play()方法返回Promise可以解析或拒絕。這BasePlayer將告知是縣城何時應該播放或暫停,實現(xiàn)層將告知BasePlayer實際播放的時間。每個播放器實現(xiàn)都與實際play()方法分離。這也意味著isPlaying()可以完全處理方法和相應的更新BasePlayer。另一個例子是getPosition(),除了通知實現(xiàn)層播放時間,除非正在seek,在這種情況下BasePlayer將返回請求的時間點。這意味著時間getPosition()總是有意義的,用戶在seek時可以保證它不會跳轉(zhuǎn),并覆蓋它。
播放器實現(xiàn)包含在單獨的包中,并且它們都擴展BasePlayer。我們目前有以下播放器:
HTML5Player - 這是最簡單的播放器。它采用URL和MIME類型,它們直接傳遞給媒體元素。
HLSMSEPlayer- 這擴展了HTML5Player,它需要一個Playlist對象來供段數(shù)據(jù)。該播放器使用MSE。
ChromecastPlayer - 此播放器是一個控制Chromecast的代理。
ProxyPlayer - 此播放器可以控制另一個播放器以便隨時切換。它還具有一些提供新播放器同步相關(guān)的配置。該播放器的一個好處是,它可以在真正的播放器還沒有的時候同步提供給應用程序。然后,一旦真實播放器可用,其狀態(tài)將被同步以匹配代理。其他一些用例是在Chromecast上播放和本地播放,或切換質(zhì)量。該應用程序只需與一個播放器進行交互,切換可以在幕后進行。
狀態(tài)管理和事件
在Maestro中,有很多播放狀態(tài)需要管理,它們大部分都包含在內(nèi)部BasePlayer。用戶還想知道某些部分的狀態(tài)何時發(fā)生變化,有時會通過執(zhí)行其他播放器操作來對變化作出反應。當我們在單個線程上運行時,這會帶來一些復雜性。有時我們還會以原子方式(跨多個函數(shù))更新狀態(tài)的幾個部分。例如:如果用戶跳轉(zhuǎn)到媒體的結(jié)尾,我們也想要將ended標志更新為true。更新ended標志有關(guān)的邏輯,與代碼中的查找邏輯無關(guān),但跳轉(zhuǎn)狀態(tài)和結(jié)束狀態(tài)的更新應該在API中一起發(fā)生。
為實現(xiàn)這一目標,我們構(gòu)建了一個名為的組件StateManager,它使我們能夠:
在調(diào)用之前更新函數(shù)的多個部分,以通知用戶更改。
在播放器調(diào)用堆棧的末尾通知用戶狀態(tài)更改,以便他們與播放器的任何交互不會因此而在調(diào)用堆棧中交錯。(例如,執(zhí)行工作然后觸發(fā)事件,而不是觸發(fā)事件然后執(zhí)行工作。)
StateManager
StateManager維護一個狀態(tài)對象。對該對象的所有更改都是使用update()方法進行的,并且可以提供回調(diào),然后在update()最后通知回調(diào)發(fā)生的任何狀態(tài)更改。這些調(diào)用可以嵌套:
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;
??});
}
請注意,第一個訂閱回調(diào)將執(zhí)行兩次,第二個訂閱也只執(zhí)行一次,并且只執(zhí)行最新狀態(tài)(即d === 3)。
另請注意,我們不會獲得嵌套調(diào)用堆棧,因為回調(diào)只在工作完成后才會執(zhí)行。
瀏覽器限制
不幸的是,不同的瀏覽器具有不同的編解碼器支持(也可能取決于操作系統(tǒng))和不同的容器需求。
例如,Chrome支持MSE中的原始MP3文件,但Firefox要求MP3位于MP4容器中。這意味著在Firefox中,我們需要將我們下載的MP3打包到瀏覽器中的MP4中。其他編解碼器具有類似的復雜性。
有bug也是不可避免的。為支持在安全的方式下,處理各種媒體的媒體處理管道,并且不破壞Web瀏覽器的向后兼容性,這是一項艱巨的任務(wù)!幸運的是,Maestro有能夠處理不同瀏覽器中各種錯誤的變通方法,其中一些在版本之間有所不同。
瀏覽器之間的自動播放策略也不同,這意味著我們目前必須在播放器之間共享媒體元素。這增加了復雜性,因為當元素的源被更改時,仍然會在之后的短時間內(nèi)為前一個源發(fā)出事件,這意味著我們必須在嘗試使用它之前等待事件“清空”,并且我們必須保持跟蹤同時請求的所有內(nèi)容。Maestro的HTML5Player通過使用provideMediaElement(mediaEl)和revokeMediaElement()讓這變得簡單。這允許您在運行時在播放器之間移動媒體元素。當播放器沒有媒體元素時,播放器就會暫停。
測試
在BasePlayer和播放器的實現(xiàn)是通過單元測試和集成測試覆蓋:我們采用Mocha,Sinon,karma,以及mocha-screencast-reporter。后者非常適合遠程查看測試的運行進度。
確保API的行為正確,該BasePlayer自身目前擁有超過700次測試。例如,測試檢查play()實現(xiàn)是否正在播放時解析了promise。一個測試play()如果在播放請求完成之前播放器被釋放,則另一個測試會被拒絕并返回正確的報錯。還有一些測試可以檢查播放器是否在檢測到不一致時報錯。 例如,一個播放器實現(xiàn)在BasePlayer從未請求過seek操作時,無法完成seek請求。
我們還使用SauceLabs在各種瀏覽器和瀏覽器版本(包括Chrome和Firefox beta)上運行所有測試。這需要幾個小時才能完成,因此我們測試了各主流瀏覽器,我們在發(fā)布之前測試所有內(nèi)容。我們還每周運行所有測試,以確保新瀏覽器版本不會出現(xiàn)任何問題。這樣做,曾有一次高亮顯示了Firefox beta中的Web Audio錯誤,這會導致播放在前幾秒后停止。
漸進式流媒體(使用fetch()API)
我們最近添加了對漸進式流式傳輸?shù)闹С?#xff08;在支持的瀏 這意味著在我們處理它并將其附加到緩沖區(qū)之前不必等待整個段被下載,我們能夠在數(shù)據(jù)到達時處理數(shù)據(jù),這意味著我們能夠在段下載之前開始播放已完成。
這是通過fetch()API(以及moz-chunked-arraybuffer在Firefox中)實現(xiàn)的,它在下載時仍提供小部分數(shù)據(jù):
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');
????}
??}));
});
在我們添加漸進式流式傳輸之前,如果下載失敗,我們只會重試它,這個邏輯非常獨立。使用漸進式流式傳輸更為復雜,因為如果下載部分失敗,整個管道已經(jīng)開始處理數(shù)據(jù)。我們決定在錯誤時重試請求并丟棄我們已經(jīng)看到的所有字節(jié)。如果重試失敗,那么我們就能夠在管道中產(chǎn)生報錯。
這也帶來了更多的復雜性。之前,我們知道每個段包含完整數(shù)量的有效音頻單元,這意味著管道的不同部分可以做出某些響應。現(xiàn)在,每個數(shù)據(jù)部分都可以包含一小部分音頻單元,因此我們需要能夠檢測到何時發(fā)生這種情況,并保留和等待一個完整單元到達的緩沖區(qū)。
下一步是什么?
我們自2017年6月開始運行Maestro,而且對播放問題的不良反饋很少。我們能夠?qū)崟r監(jiān)控性能和錯誤,并且在發(fā)生錯誤的情況下,我們能夠檢索播放日志,這有助于調(diào)試。
我們正在尋找Maestro的下一個目標,那就是你的參與:讓我們知道你將如何使用它,以及你想看到的功能:D
如果您對此帖有任何疑問,或者您發(fā)現(xiàn)soundcloud.com上有任何播放問題;),請與我們聯(lián)系!
姜雨晴的補充資料
MSDN上關(guān)于 AudioContext的一些解釋 https://developer.mozilla.org/zh-CN/docs/Web/API/AudioContext 有關(guān)AudioContext,會稍微吃性能。
漸進式流媒體,就是我們所說的直播流媒體,它會涉及到有時候一個片段并非完整的問題,必須要等待完整片段,之前寫的代碼中有做過處理。https://github.com/xiongmaotv/open-mccree/blob/f8491e33770c59fe6288f1a05daf8375d4f01820/packages/mccree-core-loaderbuffer/src/index.js#L46
文章中反復提到的狀態(tài)處理,是因為媒體播放很多方法是異步,尤其在不同瀏覽器直接也有差異,比如停止下載的cancel在chrome中是Promise,異步的,FireFox中就不是。
LiveVideoStack? 招募
LiveVideoStack正在招募編輯/記者/運營,與全球頂尖多媒及技術(shù)專家和LiveVideoStack年輕的伙伴一起,推動多媒體技術(shù)生態(tài)發(fā)展。了解崗位信息請在BOSS直聘上搜索“LiveVideoStack”,或通過微信“Tony_Bao_”與主編包研交流。
點擊【閱讀原文】,了解更多大會講師及內(nèi)容信息。
總結(jié)
以上是生活随笔為你收集整理的SoundCloud的web播放库Maestro演进之路的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Cisco WebEx:企业协作服务中的
- 下一篇: 赵加雨:追求极致的习惯让我受益匪浅