簡介
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 片段;此外,還有多種其他類型可供使用。
異常波形
我們稍後會再談程式碼,但現在先仔細看看剛才附加的檔案,特別是檔案結尾。以下是 sintel_0.mp3
音軌中,兩個聲道過去 3000 個樣本的平均值圖表。紅線上的每個像素都是 [-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
開頭的靜音部分 (以藍色標示) 已移除,讓各段之間的轉場更加流暢。

結論
這樣一來,我們就已將所有五個片段無縫接合成一個片段,並且示範已結束。在結束之前,您可能已經注意到,我們的 onAudioLoaded()
方法並未考量容器或編解碼器。也就是說,無論容器或編解碼類型為何,所有這些技巧都會有效。以下是原始 DASH 示範片段,請使用 MP4 格式播放,而非 MP3。
如要進一步瞭解無縫內容建立和中繼資料剖析,請參閱下方的附錄。您也可以探索 gapless.js
,進一步瞭解這個示範的程式碼。
感謝您閱讀本信!
附錄 A:製作無縫銜接的內容
要製作無縫接軌的內容並不容易。以下將逐步說明如何建立本示範中使用的 Sintel 媒體。首先,你需要一份 Sintel 無損 FLAC 配樂副本;為了方便日後使用,我們已在下方提供 SHA1。您需要使用 FFmpeg、MP4Box、LAME 和 OSX 安裝程序,並使用 afconvert。
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
並對所有檔案進行批次編碼,以免在區段之間加上填充資料。不過,為了進行本示範,我們需要填充資料,因此我們會使用標準的 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 寫入簡短的部分,如下所示:
iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00
這會寫入 MP3 容器中的 ID3 標記,以及 MP4 容器中的中繼資料原子。為了達到我們的目的,我們可以忽略第一個 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 編碼器會將無間隙中繼資料儲存在特殊的 Xing 標頭中,並放置在無聲 MPEG 影格中 (無聲,因此不瞭解 Xing 標頭的解碼器只會播放無聲內容)。很遺憾,這個代碼不一定會出現,且含有許多選填欄位。為了進行這個示範,我們會控制媒體,但在實際情況中,我們需要進行一些額外檢查,才能知道何時實際可用無間隙中繼資料。
首先,我們會剖析總樣本數。為簡化操作,我們會從 Xing 標頭讀取這項資訊,但也可以從一般 MPEG 音訊標頭建構這項資訊。Xing 標頭可標示為 Xing
或 Info
標記。這個標記後面有 4 個位元組,代表檔案中的總影格數量。將這個值乘以每個影格中的取樣數量,即可得出檔案中的總取樣數。
// 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 變數中保留的資料量沒有限制。如有需要,您也可以在相同位置重新附加相同的資料。