用 JavaScript 編寫 MPEG1 解碼器

類別: IT

幾年前,我開始從事於完全用JavaScript編寫的MPEG1視訊解碼器上。現在,我終於找到了清理該庫的時間,改善其效能、使其具有更高的錯誤恢復能力和模組化能力,並新增MP2音訊解碼器和MPEG-TS解析器。這使得該庫不僅僅是一個MPEG解碼器,而是一個完整的視訊播放器。

在本篇博文中,我想談一談我在開發這個庫時遇到的挑戰和各種有趣的事情。你將在官方網站上找到demo、原始碼和文件以及為什麼要使用JSMpeg:

重構

最近,我需要為一位客戶在JSMpeg中實現音訊流傳輸,然後我才意識到該庫處於一種多麼可憐的狀態。從其首次釋出以來,它已經有很多發展了。在過去的幾年裡,WebGL渲染器、WebSocket客戶端、漸進式載入、基準測試裝置等等已被加入。但所有這些都儲存在一個單一的、龐大的類中,條件判斷隨處可見。

我決定首先通過分離它的邏輯元件來梳理清楚其中的混亂。我還總結了完成實現需要哪些:解複用器、MP2解碼器和音訊輸出:

  • 原始碼(Sources): AJAX, 漸進式AJAX和WebSocket

  • 解複用器(Demuxer): MPEG-TS (Transport Stream)

  • 解碼器(Decoder): MPEG1視訊& MP2音訊

  • 渲染器(Render): Canvas2D & WebGL

  • 音訊輸出:WebAudio

加上一些輔助類:

  • 一個位快取(Bit Buffer),用於管理原始資料

  • 一個播放器(Player),整合其他元件

每個元件(除了Sources之外)都有一個.write(buffer)方法來為其提供資料。這些元件可以“連線”到接收處理結果的目標元件上。流經該庫的完整流程如下所示:

/ -> MPEG1 Video Decoder -> RendererSource -> Demuxer                   \ -> MP2 Audio Decoder -> Audio Output

JSMpeg目前有3種不同的Source實現(AJAX\AJAX漸進式和WebSocket),還有2種不同的渲染器(Canvas2D和WebGL)。該庫的其他部分對這此並不瞭解 - 即視訊解碼器不關心渲染器內部邏輯。採用這種方法可以輕鬆新增新的元件:更多的Source,解複用器,解碼器或輸出。

我對這些連線在庫中的工作方式並不完全滿意。每個元件只能有一個目標元件(除了多路解複用器,每個流有都有一個目標元件)。這是一個折衷。最後,我覺得:其他部分會因為沒有充分的理由而過度工程設計並使得庫過於複雜化。

WebGL渲染

MPEG1解碼器中計算密集度最高的任務之一是將MPEG內部的YUV格式(準確地說是Y'Cr'Cb)轉換為RGBA,以便瀏覽器可以顯示它。簡而言之,這個轉換看起來像這樣:

for (var i = 0; i < pixels.length; i+=4 ) {    var y, cb, cr = /* fetch this from the YUV buffers */;    pixels[i + 0 /* R */] = y + (cb + ((cb * 103) >> 8)) - 179;    pixels[i + 1 /* G */] = y - ((cr * 88) >> 8) - 44 + ((cb * 183) >> 8) - 91;    pixels[i + 2 /* B */] = y + (cr + ((cr * 198) >> 8)) - 227;    pixels[i + 4 /* A */] = 255;}

對於單個1280x720視訊幀,該迴圈必須執行921600次以將所有畫素從YUV轉換為RGBA。每個畫素需要對目標RGB陣列寫入3次(我們可以預先填充alpha元件,因為它始終是255)。這是每幀270萬次寫入操作,每次需要5-8次加、減、乘和位移運算。對於一個60fps的視訊,我們每秒鐘完成10億次以上的操作。再加上JavaScript的開銷。JavaScript可以做到這一點,計算機可以做到這一點,這一事實仍然讓我大開眼界。

使用 WebGL ,這種顏色轉換(以及隨後在螢幕上顯示)可以大大加快。逐畫素的少量操作對 GPU 而言是小菜一碟。GPU 可以並行處理多個畫素,因為它們是獨立於任何其他畫素的。執行在 GPU 上的 WebGL 著色器(shader)甚至不需要這些煩人的位移 - GPU 喜歡浮點數:

void main() {    float y = texture2D(textureY, texCoord).r;    float cb = texture2D(textureCb, texCoord).r - 0.5;    float cr = texture2D(textureCr, texCoord).r - 0.5;    gl_FragColor = vec4(        y + 1.4 * cb,        y + -0.343 * cr - 0.711 * cb,        y + 1.765 * cr,        1.0    );}

使用 WebGL,顏色轉換所需的時間從 JS 總時間的 50% 下降到僅需 YUV 紋理上傳時間的約 1% 。

我遇到了一個與 WebGL 渲染器偶然相關的小問題。JSMpeg 的視訊解碼器不會為每個顏色平面生成三個 Uint8Arrays ,而是一個 Uint8ClampedArrays 。它是這樣做的,因為 MPEG1 標準規定解碼的顏色值必須是緊湊的,而不是分散的。讓瀏覽器通過 ClampedArray 進行交織比在 JavaScript 中執行更快。

依然存在於某些瀏覽器(Chrome和Safari)中的缺陷會阻止WebGL直接使用Uint8ClampedArray。因此,對於這些瀏覽器,我們必須為每個幀的每個陣列建立一個Uint8Array檢視。這個操作非常快,因為沒有需要真實複製的事情,但我仍然希望不使用它。

JSMpeg會檢測到這個錯誤,並僅在需要時使用該解決方法。我們只是嘗試上傳一個固定陣列並捕獲此錯誤。令人遺憾的是,這種檢測會觸發控制檯中的一個非靜默的警告,但這總比沒有好吧。

WebGLRenderer.prototype.allowsClampedTextureData = function() {    var gl = this.gl;    var texture = gl.createTexture();    gl.bindTexture(gl.TEXTURE_2D, texture);    gl.texImage2D(        gl.TEXTURE_2D, 0, gl.LUMINANCE, 1, 1, 0,        gl.LUMINANCE, gl.UNSIGNED_BYTE, new Uint8ClampedArray([0])    );    return (gl.getError() === 0);};

對直播流媒體的WebAudio

很長一段時間裡,我假設為了向WebAudio提供原始PCM樣本資料而沒有太多延遲或爆破音,你需要使用ScriptProcessorNode。只要你從指令碼處理器獲得回撥,你就可以及時複製解碼後的取樣資料。這確實有效。我試過這個方法。它需要相當多的程式碼才能正常工作,當然這是計算密集型和不優雅的作法。

幸運的是,我最初的假設是錯誤的。

WebAudio上下文維護自己的計時器,它有別於JavaScript的Date.now()或performance.now()。 此外,你可以根據上下文的時間指導你的WebAudio源在未來的準確時間呼叫start()。有了這個,你可以將非常短的PCM緩衝器串在一起,而不會有任何瑕疵。

你只需計算下一個緩衝區的開始時間,就可以連續新增所有之前的緩衝區的時間。總是使用 WebAudio Context 自己的時間來做這件事是很重要的。

var currentStartTime = 0;function playBuffer(buffer) {    var source = context.createBufferSource();    /* load buffer, set destination etc. */    var now = context.currentTime;    if (currentStartTime < now) {        currentStartTime = now;    }    source.start(currentStartTime);    currentStartTime += buffer.duration;}

不過需要注意的是:我需要獲得佇列音訊的精確剩餘時間。我只是簡單地將它作為當前時間和下一個啟動時間的區別來實現:

// Don't do that!var enqueuedTime = (currentStartTime - context.currentTime);

我花了一段時間才弄明白,這行不通。你可以看到,上下文的 currentTime 只是每隔一段時間才更新一次。它不是一個精確的實時值。

var t1 = context.currentTime;doSomethingForAWhile();var t2 = context.currentTime;t1 === t2; // true

因此,如果需要精確的音訊播放位置(或者基於它的任何內容),你必須恢復到 JavaScript 的  performance.now() 方法。

iOS 上的音訊解鎖

你將要愛上蘋果時不時扔到 Web 開發人員臉上的麻煩。其中之一就是在播放任何內容之前都需要在頁面上解鎖音訊。總的來說,音訊播放只能作為對使用者操作的響應而啟動。你點選了一個按鈕,音訊則播放了。

這是有道理的。我不反駁它。當你訪問某個網頁時,你不希望在未經通知的情況下發出聲音。

是什麼讓它變得糟糕透頂呢?是因為蘋果公司既沒有提供一種利索的解鎖音訊的方法,也沒有提供一種方法來查詢 WebAudio Context 是否已經解鎖。你所要做的就是播放一個音訊源並不斷檢查是否正在順序播放。儘管如此,在播放之後你還不能馬上檢查。是的,你必須等一會!

WebAudioOut.prototype.unlock = function(callback) {    // This needs to be called in an onclick or ontouchstart handler!    this.unlockCallback = callback;        // Create empty buffer and play it    var buffer = this.context.createBuffer(1, 1, 22050);    var source = this.context.createBufferSource();    source.buffer = buffer;    source.connect(this.destination);    source.start(0);    setTimeout(this.checkIfUnlocked.bind(this, source, 0), 0);};WebAudioOut.prototype.checkIfUnlocked = function(source, attempt) {    if (        source.playbackState === source.PLAYING_STATE ||         source.playbackState === source.FINISHED_STATE    ) {        this.unlocked = true;        this.unlockCallback();    }    else if (attempt < 10) {        // Jeez, what a shit show. Thanks iOS!        setTimeout(this.checkIfUnlocked.bind(this, source, attempt+1), 100);    }};
用 JavaScript 編寫 MPEG1 解碼器原文請看這裡