소개
미디어 소스 확장 프로그램 (MSE)은 HTML5 <audio>
및 <video>
요소에 확장된 버퍼링 및 재생 컨트롤을 제공합니다. 원래 Dynamic Adaptive Streaming over HTTP (DASH) 기반 동영상 플레이어를 용이하게 하기 위해 개발되었지만 아래에서 이러한 동영상을 오디오에 사용하는 방법을 살펴보겠습니다. 특히 끊김 없는 재생에 적합합니다.
여러 트랙이 매끄럽게 이어지는 음악 앨범을 감상한 적이 있을 것입니다. 지금 듣고 있을 수도 있습니다 아티스트는 끊김 없는 재생 경험을 예술적 선택으로 삼을 뿐만 아니라 오디오가 하나의 연속 스트림으로 작곡된 비닐 레코드와 CD의 아티팩트로도 만듭니다. 안타깝게도 MP3 및 AAC와 같은 최신 오디오 코덱이 작동하는 방식으로 인해 이러한 매끄러운 시청 경험을 놓치는 경우가 많습니다.
아래에서 그 이유를 자세히 알아보겠지만 지금은 시연부터 시작하겠습니다. 다음은 뛰어난 Sintel의 처음 30초를 다섯 개의 개별 MP3 파일로 잘라 MSE를 사용하여 다시 조립한 것입니다. 빨간색 선은 각 MP3를 생성 (인코딩)하는 동안 발생한 격차를 나타냅니다. 이 지점에서 결함이 있는 것을 듣게 됩니다.
이런! 좋은 경험이 아닙니다. 도움이 됩니다. 위의 데모와 정확히 동일한 MP3 파일을 사용하여 조금만 더 작업하면 MSE를 사용하여 이 귀찮은 격차를 제거할 수 있습니다. 다음 데모의 녹색 선은 파일이 조인된 위치와 삭제된 부분을 나타냅니다. Chrome 38 이상에서는 끊김 없이 재생됩니다.
다양한 방법으로 끊김 없는 콘텐츠를 만들 수 있습니다. 이 데모에서는 일반 사용자가 보유하고 있을 수 있는 파일 형식을 중점적으로 살펴보겠습니다. 각 파일이 파일 전후의 오디오 세그먼트를 고려하지 않고 별도로 인코딩된 경우입니다.
기본 설정
먼저 MediaSource
인스턴스의 기본 설정을 되돌아보고 살펴보겠습니다. Media Source Extensions는 이름에서 알 수 있듯이 기존 미디어 요소의 확장에 불과합니다. 아래에서는 MediaSource
인스턴스를 나타내는 Object URL
를 오디오 요소의 소스 속성에 할당합니다. 표준 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
를 만들 수 있습니다. 위의 예에서는 MP3 세그먼트를 파싱하고 디코딩할 수 있는 audio/mpeg
세그먼트를 만듭니다. 여러 다른 유형도 사용할 수 있습니다.
이상 파형
잠시 후에 코드로 다시 돌아올 것이지만 이제 방금 추가한 파일, 특히 파일의 끝부분에 있는 파일을 자세히 살펴보겠습니다. 다음은 sintel_0.mp3
트랙에서 두 채널의 평균을 낸 최근 3, 000개의 샘플에 대한 그래프입니다. 빨간색 선의 각 픽셀은 [-1.0, 1.0]
범위의 부동 소수점 샘플입니다.
저 조용한 샘플이 없는 건 뭐죠? 실제로 인코딩 중에 발생한 압축 아티팩트 때문입니다. 거의 모든 인코더에는 일종의 패딩이 도입됩니다. 이 경우 LAME은 정확히 576개의 패딩 샘플을 파일 끝에 추가했습니다.
끝부분의 패딩뿐만 아니라 각 파일의 시작 부분에도 패딩이 추가되어 있었습니다. sintel_1.mp3
트랙을 미리 보면 앞쪽에 패딩의 576개 샘플이 있는 것을 확인할 수 있습니다. 패딩의 양은 인코더와 콘텐츠에 따라 다르지만, Google에서는 각 파일에 포함된 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()
메서드가 컨테이너나 코덱을 고려하지 않는다는 것을 알 수 있습니다. 즉, 이러한 기법은 모두 컨테이너나 코덱 유형과 관계없이 작동합니다. 아래에서 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
먼저 처음 31.5초를 1-Snow_Fight.flac
트랙을 분할합니다. 또한 재생이 끝나면 클릭이 발생하지 않도록 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초인 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
이제 여러 M4A 파일이 있으며 이를 MediaSource
와 함께 사용하기 전에 적절하게 프래그먼트해야 합니다. 여기서는 1초의 프래그먼트 크기를 사용하겠습니다. MP4Box는 삭제할 수 있는 MPEG-DASH 매니페스트 (sintel_#_dash.mpd
)와 함께 조각화된 각 MP4를 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 컨테이너 내부의 메타데이터 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 헤더는 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 변수에서 버퍼링된 데이터의 양에는 제한이 없습니다. 필요한 경우 동일한 위치에 동일한 데이터를 다시 추가할 수도 있습니다.