簡介
Media Source Extensions (MSE) 提供 HTML5 <audio>
和 <video>
元素的延伸緩衝處理和播放控制項。雖然最初是為了透過 HTTP 影片播放器而開發動態自動調整串流 (DASH),但以下則是將這類播放器用於音訊的方式;尤其是不間斷播放。
你可能已經聽過一張音樂專輯,歌曲之間有順暢多首歌。或許你現在還聽了一段音樂。這些藝人不僅是打造不間斷的播放體驗,也能做為藝術選擇權,加上黑膠唱片和 CD 的成果,其中音訊是以連續直播編寫而成。可惜的是,由於 MP3 和 AAC 等現代音訊轉碼器的運作方式,今天往往無法享受流暢的影音體驗。
以下將詳細說明原因,但現在我們先從示範開始說明。以下是傑出的 Sintel 前 30 秒,精心整理出五個不同的 MP3 檔案,並使用 MSE 重新組合。紅線表示每個 MP3 在建立 (編碼) 時存在的缺口,您會在這些時間點聽到問題。
糟糕!這實在太棒了,我們可以做得更好。只要多完成一些工作,就能使用在上述示範中完全相同的 MP3 檔案,進而利用 MSE 消除這些惱人的缺口。下一個示範中的綠線代表檔案已加入的位置,以及遺漏的缺口。如果是 Chrome 38 以上版本,都能流暢播放音訊!
您可以運用多種方式製作無縫接軌的內容。為了方便示範,我們會著重說明一般使用者可能會操作的檔案類型。每個檔案經過單獨編碼後,就沒有前後音訊片段的影響。
基本設定
首先,我們來回顧及介紹 MediaSource
執行個體的基本設定。顧名思義,媒體來源擴充功能只是現有媒體元素的擴充功能。以下我們會指派代表 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 區隔;還有幾種其他類型可用。
異常波形
我們稍後會回顧程式碼,而現在我們來進一步瞭解剛剛附加的檔案,特別是在程式碼的結尾。下方是過去 3000 個樣本在 sintel_0.mp3
測試群組中的平均樣本圖表。紅線中的每個像素都是一個位於 [-1.0, 1.0]
範圍的浮點樣本。
這些零樣本全都是怎麼回事?這其實是編碼期間發生的壓縮構件所導致。幾乎每個編碼器都會導入部分邊框間距。在本例中,LAME 在檔案結尾剛加入 576 個邊框間距範例。
除了結尾處的邊框間距外,每個檔案開頭也都有邊框間距。如果我們提前在 sintel_1.mp3
音軌前跳,就會看到另外 576 個邊框間距樣本。邊框間距量會因編碼器和內容而異,但我們知道每個檔案中包含的 metadata
確切值。
在上一個示範模式中,每個檔案開頭和結尾處的靜音部分,就是導致多個片段之間「故障」的原因。為達到流暢播放體驗,我們必須移除這些無聲部分。幸好,使用 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
開頭的靜音部分 (藍色) 已移除,讓我們能夠流暢地切換區段。
結論
因此,我們順利地將 5 個區隔拼接為一個片段,之後也抵達示範已結束。開始之前,您可能已註意到我們的 onAudioLoaded()
方法不考慮容器或轉碼器。這表示無論容器或轉碼器類型為何,這些技術都能有效運作。下方是重新播放原始示範 DASH 的 MP4 片段,而非 MP3。
如想瞭解更多資訊,歡迎參閱下方附錄,進一步瞭解無縫整合的內容建立和中繼資料剖析。你也可以探索 gapless.js
,進一步瞭解這個示範所使用的程式碼。
感謝您閱讀本信!
附錄 A:製作無間隙內容
製作無間隙內容可能並不容易。以下逐步說明如何建立此試用版中使用的 Sintel 媒體。首先,你必須備妥 Sintel 的無損 FLAC 配樂副本;如果是海報,請參考下方的 SHA1 檔案。工具需要 FFmpeg、MP4Box 和 LAME,並透過 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 秒的時間點開始加上 2.5 秒的淡出效果,以免在播放結束後點擊。使用下方的 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.wav
、sintel_1.wav
、sintel_2.wav
、sintel_3.wav
和 sintel_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
搭配使用。基於我們的目的,我們會使用片段大小為一秒。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:剖析無間隙中繼資料
如同建立無間內容,剖析無間斷的中繼資料可能並不容易,因為儲存空間沒有標準方法。以下將介紹兩種常見的編碼器,也就是 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 中編寫一個以 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 音訊標頭建構。X 標頭可以使用 Xing
或 Info
標記標示。在這個標記後,恰好有 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 個位元組,則各有 3 個位元組,分別以 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 中,系統會先從已播放的緩衝區回收記憶體。不過,如果記憶體用量超過平台專屬限制,就會從未播放的緩衝區中移除記憶體。
成功收回記憶體後,如果時間軸中的播放內容斷斷續續,則如果間隔夠小,可能就會出現故障;如果間隔過大,則可能完全停滯。這兩項功能都無法帶來優異的使用者體驗。因此,請務必避免一次附加過多資料,並手動移除不再需要的範圍。
您可以透過每個 SourceBuffer
方法的remove()
方法移除範圍,需要幾秒鐘[start, end]
範圍。與 appendBuffer()
類似,每個 remove()
都會在完成後觸發 updateend
事件。其他移除或附加內容不應等到事件觸發時才發出。
在電腦版 Chrome 中,您可以一次在記憶體中保存約 12 MB 的音訊內容與 150 MB 的影片內容。請不要在不同瀏覽器或平台上使用這些值;舉例來說,這些值大部分都不是代表行動裝置。
垃圾收集只會影響新增至 SourceBuffers
的資料,在 JavaScript 變數中可保留的資料量沒有限制。必要時,您也可以在相同位置重新添加相同的資料。