소개
미디어 소스 확장 프로그램 (MSE)은 HTML5 <audio>
및 <video>
요소에 확장된 버퍼링 및 재생 제어를 제공합니다. 원래 HTTP 동적 적응형 스트리밍 (DASH) 기반 동영상 플레이어를 지원하기 위해 개발되었지만 아래에서는 오디오, 특히 시점 간격 없는 재생에 이를 사용하는 방법을 살펴봅니다.
노래가 트랙 간에 원활하게 이어지는 음악 앨범을 들어본 적이 있을 것입니다. 지금도 그런 앨범을 듣고 계실 수도 있습니다. 아티스트는 예술적 선택으로서, 그리고 오디오가 하나의 연속 스트림으로 쓰여진 LP 및 CD의 아티팩트로서 이러한 갭리스 재생 환경을 만듭니다. 안타깝게도 MP3 및 AAC와 같은 최신 오디오 코덱의 작동 방식으로 인해 이러한 원활한 청각적 경험이 사라지는 경우가 많습니다.
그 이유에 대한 자세한 내용은 아래에서 다루겠지만, 지금은 데모로 시작해 보겠습니다. 아래는 멋진 Sintel의 처음 30초를 5개의 개별 MP3 파일로 잘라 MSE를 사용하여 다시 조합한 것입니다. 빨간색 선은 각 MP3를 만들 때 (인코딩) 발생한 갭을 나타냅니다. 이 지점에서 글리치가 발생합니다.
웩! 불편을 끼쳐 드려 죄송합니다. 더 나은 서비스를 제공해 드리겠습니다. 위의 데모에서와 동일한 MP3 파일을 사용하여 약간의 작업을 더하면 MSE를 사용하여 이러한 불편한 간격을 삭제할 수 있습니다. 다음 데모의 녹색 선은 파일이 결합되고 간격이 삭제된 위치를 나타냅니다. Chrome 38 이상에서는 원활하게 재생됩니다.
시청 중단 없는 콘텐츠를 만드는 방법은 다양합니다. 이 데모에서는 일반 사용자가 보관하고 있을 수 있는 파일 유형에 중점을 둘 것입니다. 각 파일이 앞뒤의 오디오 세그먼트를 고려하지 않고 개별적으로 인코딩된 경우
기본 설정
먼저 한 걸음 물러나서 MediaSource
인스턴스의 기본 설정을 살펴보겠습니다. 미디어 소스 확장 프로그램은 이름에서 알 수 있듯이 기존 미디어 요소의 확장 프로그램일 뿐입니다. 아래에서는 표준 URL을 설정하는 것처럼 MediaSource
인스턴스를 나타내는 Object URL
를 오디오 요소의 source 속성에 할당합니다.
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
를 만들 수 있습니다. 위 예에서는 MP3 세그먼트를 파싱하고 디코딩할 수 있는 audio/mpeg
를 만들고 있습니다. 사용 가능한 다른 유형이 여러 개 있습니다.
비정상 파형
코드는 잠시 후에 다시 살펴보겠습니다. 이제 방금 추가한 파일, 특히 파일 끝부분을 자세히 살펴보겠습니다. 다음은 sintel_0.mp3
트랙의 두 채널에서 평균을 낸 마지막 3, 000개 샘플의 그래프입니다. 빨간색 선의 각 픽셀은 [-1.0, 1.0]
범위의 부동 소수점 샘플입니다.

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()
메서드가 컨테이너나 코덱을 고려하지 않는다는 점에 유의하세요. 즉, 이러한 모든 기법은 컨테이너 또는 코덱 유형과 관계없이 작동합니다. 아래에서 MP3 대신 원래 데모 DASH 지원 단편화된 MP4를 재생할 수 있습니다.
자세한 내용은 아래의 부록에서 갭리스 콘텐츠 제작 및 메타데이터 파싱에 대해 자세히 알아보세요. 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
그런 다음 파일을 각각 6.5초 길이의 wave 파일 5개로 분할합니다. 거의 모든 인코더에서 처리를 지원하므로 wave를 사용하는 것이 가장 쉽습니다. 다시 말하지만 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 파일의 생성을 살펴보겠습니다. iTunes용으로 마스터링된 미디어를 만들기 위해 Apple의 안내를 따릅니다. 아래에서는 안내에 따라 웨이브 파일을 중간 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
이제 MediaSource
에서 사용할 수 있기 전에 적절하게 프래그먼트해야 하는 M4A 파일이 여러 개 있습니다. 여기서는 프래그먼트 크기를 1초로 사용합니다. MP4Box는 각 단편화된 MP4를 버릴 수 있는 MPEG-DASH 매니페스트 (sintel_#_dash.mpd
)와 함께 sintel_#_dashinit.mp4
로 씁니다.
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 인코더는 무음 MPEG 프레임 내에 배치된 특수 Xing 헤더 내에 갭리스 메타데이터를 저장합니다. 무음 상태이므로 Xing 헤더를 이해하지 못하는 디코더는 무음을 재생합니다. 안타깝게도 이 태그는 항상 표시되는 것은 아니며 여러 선택적 입력란이 있습니다. 이 데모에서는 미디어를 제어할 수 있지만 실제로 갭리스 메타데이터를 사용할 수 있는 시점을 확인하려면 추가 검사가 필요합니다.
먼저 총 샘플 수를 파싱합니다. 편의상 Xing 헤더에서 읽겠지만 일반 MPEG 오디오 헤더에서 구성할 수도 있습니다. Xing 헤더는 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바이트 뒤에는 각각 12비트의 앞뒤 패딩을 나타내는 3바이트가 있습니다.
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에서는 한 번에 약 12MB의 오디오 콘텐츠와 150MB의 동영상 콘텐츠를 메모리에 보관할 수 있습니다. 브라우저 또는 플랫폼 전반에서 이러한 값을 사용해서는 안 됩니다. 예를 들어 이러한 값은 휴대기기를 대표하지 않습니다.
가비지 컬렉션은 SourceBuffers
에 추가된 데이터에만 영향을 미칩니다. JavaScript 변수에 버퍼링할 수 있는 데이터의 양에는 제한이 없습니다. 필요한 경우 동일한 위치에 동일한 데이터를 다시 추가할 수도 있습니다.