音訊媒體來源擴充功能

Dale Curtis
Dale Curtis

簡介

媒體來源擴充功能 (MSE) 可為 HTML5 <audio><video> 元素提供延伸的緩衝處理和播放控制項。以下將最初說明如何透過 HTTP (DASH) 影片播放器進行動態自動調整串流,以下將說明這類播放器如何在音訊上運用。針對無縫播放過程而設計。

你或許曾聽過音樂專輯,歌曲之間順暢無阻地加入歌曲;你甚至可能在聽這首歌藝人可打造無縫播放體驗的藝術選擇,以及黑膠唱片CD 的構件,其中音訊是以連續的串流方式寫入。然而,由於 MP3AAC 等現代音訊轉碼器的運作方式,現在通常已經失去流暢的影音體驗。

我們會在下面詳細解釋原因,現在我們先來示範。下方為傑出 Sintel 的前 30 秒,可將內容切割成 5 個不同的 MP3 檔案,並使用 MSE 重新組合。紅線表示每個 MP3 建立 (編碼) 期間出現缺口;就會聽到故障情形

示範模式

真噁心!這樣的使用體驗不好;讓我們可以做得更好只要多花一點工夫,我們就能依照上述示範中的相同 MP3 檔案,使用 MSE 消除這些惱人的資料缺口。下一個示範中的綠色線條代表檔案已彙整到何處,以及遺漏的部分。在 Chrome 38 以上版本中,影片會自動播放!

示範模式

你可以透過多種方式製作無間斷的內容。為了方便示範,我們將著重介紹一般使用者可能駐足的檔案類型。每個檔案都經過單獨編碼,完全沒有考量檔案前後的音訊區段。

基本設定

首先,讓我們回顧一下,涵蓋 MediaSource 執行個體的基本設定。顧名思義,Media Source Extensions 是現有媒體元素的擴充功能。下方,我們將指派代表 MediaSource 例項的 Object URL 指派給音訊元素的來源屬性。就像設定標準網址一樣

var audio = document.createElement('audio');
var mediaSource = new MediaSource();
var SEGMENTS = 5;

mediaSource.addEventListener('sourceopen', function() {
    var sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');

    function onAudioLoaded(data, index) {
    // Append the ArrayBuffer data into our new SourceBuffer.
    sourceBuffer.appendBuffer(data);
    }

    // Retrieve an audio segment via XHR.  For simplicity, we're retrieving the
    // entire segment at once, but we could also retrieve it in chunks and append
    // each chunk separately.  MSE will take care of assembling the pieces.
    GET('sintel/sintel_0.mp3', function(data) { onAudioLoaded(data, 0); } );
});

audio.src = URL.createObjectURL(mediaSource);

連接 MediaSource 物件後,它會執行一些初始化作業,最終觸發 sourceopen 事件。這樣就能建立 SourceBuffer在上述範例中,我們要建立 audio/mpeg 應用程式,用來剖析及解碼 MP3 片段;此外,還有幾種其他類型可供使用。

異常波形

我們稍後會再回到程式碼,但現在我們要深入查看剛才附加的檔案,特別是結尾的檔案。下圖顯示 sintel_0.mp3 測試群組中,在兩個頻道上最後的 3000 個樣本平均值。紅線上的每個像素都是在 [-1.0, 1.0] 範圍內的浮點範例

sintel_0.mp3 結束

這些零 (無訊息) 樣本就是什麼?這是因為編碼期間引入的壓縮成果所致。幾乎每個編碼器都會推出幾種類型的邊框間距。在此案例中,LAME 也在檔案結尾處加入了剛好 576 個邊框間距樣本。

除了最後的邊框間距外,每個檔案的開頭都有邊框間距。如果我們先來看 sintel_1.mp3 軌跡,也會在前端看到另一個 576 個邊框間距範例。邊框間距大小因編碼器和內容而異,但確切的值取決於每個檔案內含的 metadata

sintel_1.mp3 的開頭

sintel_1.mp3 的開頭

每個檔案開頭和結尾的無聲部分是上一個示範中片段之間出現異常的原因。如要達成不間斷的播放程序,我們必須移除這些靜音部分。幸好,只要使用 MediaSource 就能輕鬆做到。下方,我們會修改 onAudioLoaded() 方法,以使用附加視窗時間戳記偏移移除這個無聲狀態。

範例程式碼

function onAudioLoaded(data, index) {
    // Parsing gapless metadata is unfortunately non trivial and a bit messy, so
    // we'll glaze over it here; see the appendix for details.
    // ParseGaplessData() will return a dictionary with two elements:
    //
    //    audioDuration: Duration in seconds of all non-padding audio.
    //    frontPaddingDuration: Duration in seconds of the front padding.
    //
    var gaplessMetadata = ParseGaplessData(data);

    // Each appended segment must be appended relative to the next.  To avoid any
    // overlaps, we'll use the end timestamp of the last append as the starting
    // point for our next append or zero if we haven't appended anything yet.
    var appendTime = index > 0 ? sourceBuffer.buffered.end(0) : 0;

    // Simply put, an append window allows you to trim off audio (or video) frames
    // which fall outside of a specified time range.  Here, we'll use the end of
    // our last append as the start of our append window and the end of the real
    // audio data for this segment as the end of our append window.
    sourceBuffer.appendWindowStart = appendTime;
    sourceBuffer.appendWindowEnd = appendTime + gaplessMetadata.audioDuration;

    // The timestampOffset field essentially tells MediaSource where in the media
    // timeline the data given to appendBuffer() should be placed.  I.e., if the
    // timestampOffset is 1 second, the appended data will start 1 second into
    // playback.
    //
    // MediaSource requires that the media timeline starts from time zero, so we
    // need to ensure that the data left after filtering by the append window
    // starts at time zero.  We'll do this by shifting all of the padding we want
    // to discard before our append time (and thus, before our append window).
    sourceBuffer.timestampOffset =
        appendTime - gaplessMetadata.frontPaddingDuration;

    // When appendBuffer() completes, it will fire an updateend event signaling
    // that it's okay to append another segment of media.  Here, we'll chain the
    // append for the next segment to the completion of our current append.
    if (index == 0) {
    sourceBuffer.addEventListener('updateend', function() {
        if (++index < SEGMENTS) {
        GET('sintel/sintel_' + index + '.mp3',
            function(data) { onAudioLoaded(data, index); });
        } else {
        // We've loaded all available segments, so tell MediaSource there are no
        // more buffers which will be appended.
        mediaSource.endOfStream();
        URL.revokeObjectURL(audio.src);
        }
    });
    }

    // appendBuffer() will now use the timestamp offset and append window settings
    // to filter and timestamp the data we're appending.
    //
    // Note: While this demo uses very little memory, more complex use cases need
    // to be careful about memory usage or garbage collection may remove ranges of
    // media in unexpected places.
    sourceBuffer.appendBuffer(data);
}

順暢的波形

我們套用附加視窗後,再仔細看看波形,看看全新的程式碼如何完成工作。下方可以看到 sintel_0.mp3 結尾的靜音部分 (紅色) 和 sintel_1.mp3 開頭的靜音部分 (藍色) 已移除;可以順暢地切換區隔

加入 sintel_0.mp3 和 sintel_1.mp3

結論

因此,我們目前已將 5 個區隔完美拼接成一個,隨後就能觀看示範影片。離開之前,您可能已註意到,onAudioLoaded() 方法並未考慮容器或轉碼器。換言之,無論容器或轉碼器類型為何,這些技術都能正常運作。在下方,您可以重播原始示範 DASH 已準備好的片段 MP4,而不是 MP3。

示範模式

如要瞭解詳情,請參閱下方附錄,進一步瞭解如何製作及剖析無缺的內容。您也可以探索 gapless.js,進一步瞭解這項示範所使用的程式碼。

感謝您閱讀本信!

附錄 A:製作無遺漏的內容

製作內容豐富的內容並非易事。以下逐步說明如何建立此示範中所用的 Sintel 媒體。如要開始,請先備妥「Sintel」的無損 FLAC 配樂;下方提供了 SHA1。工具:您必須安裝 FFmpegMP4BoxLAME,以及含有 afconvert 的 OSX 安裝項目。

unzip Jan_Morgenstern-Sintel-FLAC.zip
sha1sum 1-Snow_Fight.flac
# 0535ca207ccba70d538f7324916a3f1a3d550194  1-Snow_Fight.flac

首先,我們來拆分 1-Snow_Fight.flac 音軌的前 31.5 秒。此外,我們也想加入從 28 秒開始的淡出,避免發生任何點擊。使用下方的 FFmpeg 指令列即可完成所有操作,並將結果放入 sintel.flac 中。

ffmpeg -i 1-Snow_Fight.flac -t 31.5 -af "afade=t=out:st=28:d=2.5" sintel.flac

接著,我們會將檔案分割成 5 個 6.5 秒的批次檔案;使用波紋是最簡單的方式,因為幾乎每一種編碼器都支援內容擷取功能。我們同樣可以使用 FFmpeg 來精確進行這項作業,之後則有 sintel_0.wavsintel_1.wavsintel_2.wavsintel_3.wavsintel_4.wav

ffmpeg -i sintel.flac -acodec pcm_f32le -map 0 -f segment \
        -segment_list out.list -segment_time 6.5 sintel_%d.wav

接下來,我們要建立 MP3 檔案。LAME 提供幾種製作無差距內容的選項。如果使用內容的控制方式,可以考慮對內容使用 --nogap 搭配所有檔案的批次編碼,避免所有區段之間出現邊框間距。不過在本次示範中,我們希望加上邊框間距,因此使用 Wave 檔案的高品質標準 VBR 編碼。

lame -V=2 sintel_0.wav sintel_0.mp3
lame -V=2 sintel_1.wav sintel_1.mp3
lame -V=2 sintel_2.wav sintel_2.mp3
lame -V=2 sintel_3.wav sintel_3.mp3
lame -V=2 sintel_4.wav sintel_4.mp3

這就是建立 MP3 檔案所需的一切。現在,我們將介紹建立切割 MP4 檔案的程序。我們將按照 Apple 的指示,製作 iTunes 的主要媒體。我們會在下方按照指示,將 Wave 檔案轉換為中繼 CAF 檔案,再使用建議的參數,將檔案編碼為 MP4 容器中的 AAC

afconvert sintel_0.wav sintel_0_intermediate.caf -d 0 -f caff \
            --soundcheck-generate
afconvert sintel_1.wav sintel_1_intermediate.caf -d 0 -f caff \
            --soundcheck-generate
afconvert sintel_2.wav sintel_2_intermediate.caf -d 0 -f caff \
            --soundcheck-generate
afconvert sintel_3.wav sintel_3_intermediate.caf -d 0 -f caff \
            --soundcheck-generate
afconvert sintel_4.wav sintel_4_intermediate.caf -d 0 -f caff \
            --soundcheck-generate
afconvert sintel_0_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
            -b 256000 -q 127 -s 2 sintel_0.m4a
afconvert sintel_1_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
            -b 256000 -q 127 -s 2 sintel_1.m4a
afconvert sintel_2_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
            -b 256000 -q 127 -s 2 sintel_2.m4a
afconvert sintel_3_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
            -b 256000 -q 127 -s 2 sintel_3.m4a
afconvert sintel_4_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
            -b 256000 -q 127 -s 2 sintel_4.m4a

現在,我們有多個 M4A 檔案,我們需要妥善片段,才能與 MediaSource 搭配使用。為了方便起見,我們將使用 1 秒的片段大小。MP4Box 會將每個切割 MP4 寫成 sintel_#_dashinit.mp4,以及可捨棄的 MPEG-DASH 資訊清單 (sintel_#_dash.mpd)。

MP4Box -dash 1000 sintel_0.m4a && mv sintel_0_dashinit.mp4 sintel_0.mp4
MP4Box -dash 1000 sintel_1.m4a && mv sintel_1_dashinit.mp4 sintel_1.mp4
MP4Box -dash 1000 sintel_2.m4a && mv sintel_2_dashinit.mp4 sintel_2.mp4
MP4Box -dash 1000 sintel_3.m4a && mv sintel_3_dashinit.mp4 sintel_3.mp4
MP4Box -dash 1000 sintel_4.m4a && mv sintel_4_dashinit.mp4 sintel_4.mp4
rm sintel_{0,1,2,3,4}_dash.mpd

大功告成!現在,我們已將 MP4 和 MP3 檔案切成零碎片段,提供順暢播放時所需的正確中繼資料。如要進一步瞭解中繼資料的內容,請參閱附錄 B。

附錄 B:剖析無 Gapless 中繼資料

就如同製作無遺漏的內容一樣,剖析無缺資料的中繼資料也十分棘手,因為資料儲存方式沒有標準。以下將介紹 LAME 和 iTunes 這兩種最常用編碼器,儲存它們無縫接軌的中繼資料。首先,請為上述使用的 ParseGaplessData() 設定一些輔助方法和大綱。

// Since most MP3 encoders store the gapless metadata in binary, we'll need a
// method for turning bytes into integers.  Note: This doesn't work for values
// larger than 2^30 since we'll overflow the signed integer type when shifting.
function ReadInt(buffer) {
    var result = buffer.charCodeAt(0);
    for (var i = 1; i < buffer.length; ++i) {
    result <<../= 8;
    result += buffer.charCodeAt(i);
    }
    return result;
}

function ParseGaplessData(arrayBuffer) {
    // Gapless data is generally within the first 512 bytes, so limit parsing.
    var byteStr = new TextDecoder().decode(arrayBuffer.slice(0, 512));

    var frontPadding = 0, endPadding = 0, realSamples = 0;

    // ... we'll fill this in as we go below.

我們會先介紹 Apple 的 iTunes 中繼資料格式,因為這是最容易剖析和說明的格式。在 MP3 與 M4A 檔案中,iTunes (及 afconvert) 會以 ASCII 格式編寫一個簡短區段,如下所示:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

內容寫入 MP3 容器內的 ID3 標記內,以及 MP4 容器內的中繼資料 Atom。基於我們的目的,我們可以忽略第一個 0000000 權杖。接下來的三個符記為前端邊框間距、結束邊框間距和非邊框間距樣本計數。將各個音訊除以音訊的取樣率,即可瞭解每段音訊的時間長度。

// iTunes encodes the gapless data as hex strings like so:
//
//    'iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00'
//    'iTunSMPB[ 26 bytes ]####### frontpad  endpad    real samples'
//
// The approach here elides the complexity of actually parsing MP4 atoms. It
// may not work for all files without some tweaks.
var iTunesDataIndex = byteStr.indexOf('iTunSMPB');
if (iTunesDataIndex != -1) {
    var frontPaddingIndex = iTunesDataIndex + 34;
    frontPadding = parseInt(byteStr.substr(frontPaddingIndex, 8), 16);

    var endPaddingIndex = frontPaddingIndex + 9;
    endPadding = parseInt(byteStr.substr(endPaddingIndex, 8), 16);

    var sampleCountIndex = endPaddingIndex + 9;
    realSamples = parseInt(byteStr.substr(sampleCountIndex, 16), 16);
}

另一方面,大多數開放原始碼 MP3 編碼器會將無縫接軌的中繼資料儲存在靜音 MPEG 頁框內的特殊 Xing 標頭中 (這種標頭不會經過靜音,因此不瞭解 Xing 標頭的解碼器只會播放靜音)。遺憾的是,這個標記不一定會出現,而且有許多選填欄位。在本示範中,我們控制了媒體,但實際上仍須檢查一些額外檢查,才能得知實際上是否有無缺漏的中繼資料。

首先,我們要剖析樣本總數。為求簡單起見,我們會從 Xing 標頭讀取該名稱,但也可透過一般 MPEG 音訊標頭加以建構。Xing 標頭可以用 XingInfo 標記標示。緊接在這個標記後的 4 個位元組內,會有 32 位元代表檔案中的影格總數。將這個數值乘以每影格的樣本數,即可得出檔案中的樣本總數。

// Xing padding is encoded as 24bits within the header.  Note: This code will
// only work for Layer3 Version 1 and Layer2 MP3 files with XING frame counts
// and gapless information.  See the following document for more details:
// http://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header
var xingDataIndex = byteStr.indexOf('Xing');
if (xingDataIndex == -1) xingDataIndex = byteStr.indexOf('Info');
if (xingDataIndex != -1) {
    // See section 2.3.1 in the link above for the specifics on parsing the Xing
    // frame count.
    var frameCountIndex = xingDataIndex + 8;
    var frameCount = ReadInt(byteStr.substr(frameCountIndex, 4));

    // For Layer3 Version 1 and Layer2 there are 1152 samples per frame.  See
    // section 2.1.5 in the link above for more details.
    var paddedSamples = frameCount * 1152;

    // ... we'll cover this below.

現在,我們有了完整的樣本總數,可以開始瞭解填充樣本的數量。視編碼器而定,這個程式碼可能會寫入 Xing 標頭中的 LAME 或 Lavf 標記下方。確切來說,這個標頭之後有 17 個位元組,分別以 12 位元為單位代表前端和結束邊框間距。

xingDataIndex = byteStr.indexOf('LAME');
if (xingDataIndex == -1) xingDataIndex = byteStr.indexOf('Lavf');
if (xingDataIndex != -1) {
    // See http://gabriel.mp3-tech.org/mp3infotag.html#delays for details of
    // how this information is encoded and parsed.
    var gaplessDataIndex = xingDataIndex + 21;
    var gaplessBits = ReadInt(byteStr.substr(gaplessDataIndex, 3));

    // Upper 12 bits are the front padding, lower are the end padding.
    frontPadding = gaplessBits >> 12;
    endPadding = gaplessBits & 0xFFF;
}

realSamples = paddedSamples - (frontPadding + endPadding);
}

return {
audioDuration: realSamples * SECONDS_PER_SAMPLE,
frontPaddingDuration: frontPadding * SECONDS_PER_SAMPLE
};
}

我們有完整函式可剖析絕大多數的無間隔內容。不過,邊緣情況還是有一定的規模,因此建議您在實際工作環境中使用類似的程式碼前,請務必謹慎。

附錄 C:垃圾收集

屬於 SourceBuffer 執行個體的記憶體會根據內容類型、平台專屬限制,以及目前的播放位置,主動收集垃圾資料。在 Chrome 中,系統會先從已播放的緩衝區收回記憶體。不過,如果記憶體用量超過平台特定限制,系統就會從未播放的緩衝區中移除記憶體。

如果播放作業因為收回記憶體而在時間軸上的缺口,如果間隔不足,可能會發生故障,或者如果間距過大,則可能會完全停滯。這兩種方式都不是良好的使用者體驗,因此請務必避免一次附加過多資料,並且在媒體時間軸中手動移除不再需要的範圍。

您可以透過每個 SourceBufferremove() 方法移除範圍;需要 [start, end] 範圍 (以秒為單位)與 appendBuffer() 類似,每個 remove() 都會在完成時觸發 updateend 事件。其他移除或附加內容則在事件觸發之前不應發出。

在電腦版 Chrome 中,您可以一次保存約 12 MB 的音訊內容和 150 MB 的影片內容。您不應在所有瀏覽器或平台上仰賴這些值;比方說,大部分都不代表行動裝置。

垃圾收集只會影響新增至 SourceBuffers 的資料;JavaScript 變數中可保留的資料量則無限制。必要時,您也可以在相同位置重新附加相同的資料。