用 JavaScript 編寫 MPEG1 解碼器

chenjs001 6年前發布 | 37K 次閱讀 JavaScript開發 JavaScript

幾年前,我開始從事于完全用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 -> Renderer
Source -> 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);
    }
};

 

來自:https://www.oschina.net/translate/decode-it-like-its-1999

 

 本文由用戶 chenjs001 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!