오디오용 미디어 소스 확장 프로그램

Dale Curtis
Dale Curtis

소개

미디어 소스 확장 프로그램 (MSE)은 HTML5 <audio><video> 요소에 확장 버퍼링과 재생 컨트롤을 제공합니다. 원래는 DASH (Dynamic Adaptive Streaming over HTTP) 기반 동영상 플레이어를 지원하도록 개발되었지만, 아래에서는 이러한 플레이어를 오디오, 특히 끊김 없는 재생에 사용할 수 있는 방법을 살펴봅니다.

노래가 여러 트랙으로 매끄럽게 이어지는 음악 앨범을 들은 적이 있을 것입니다. 지금 바로 듣고 있을 수도 있습니다. 아티스트는 이러한 끊임없는 재생 환경을 예술적인 선택은 물론 오디오가 하나의 연속된 스트림으로 작곡된 비닐 레코드CD의 아티팩트로 만듭니다. 안타깝게도 MP3AAC 같은 최신 오디오 코덱이 작동하는 방식 때문에 이러한 원활한 청각적 경험을 할 수 없는 경우가 많습니다.

아래에서 그 이유를 자세히 살펴보겠습니다. 지금은 데모로 시작하겠습니다. 아래는 우수한 Sintel의 처음 30초를 5개의 개별 MP3 파일로 잘라 MSE를 사용하여 재조립한 것입니다. 빨간색 선은 각 MP3를 생성 (인코딩)하는 동안 발생한 간격을 나타냅니다. 이 지점에서 문제가 발생하는 것을 들을 수 있습니다.

데모

이런! 좋은 경험이 아닙니다. 개선할 수 있습니다. 조금만 더 작업하면 위의 데모에 정확히 동일한 MP3 파일을 사용하여 MSE를 사용하여 이러한 성가신 공백을 제거할 수 있습니다. 다음 데모의 녹색 선은 파일이 조인된 위치와 간격이 제거된 위치를 나타냅니다. Chrome 38 이상에서는 매끄럽게 재생됩니다.

데모

끊김 없는 콘텐츠를 만드는 다양한 방법이 있습니다. 이 데모의 목적상 일반 사용자가 보유하고 있는 파일 유형에 초점을 맞추겠습니다. 각 파일이 재생 전후의 오디오 세그먼트와 상관없이 별도로 인코딩됩니다.

기본 설정

먼저 MediaSource 인스턴스의 기본 설정을 역추적해 살펴보겠습니다. 미디어 소스 확장 프로그램은 이름에서 알 수 있듯이 기존 미디어 요소를 확장한 것입니다. 아래에서는 표준 URL을 설정하는 것처럼 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를 만들 수 있습니다. 위 예에서는 MP3 세그먼트를 파싱하고 디코딩할 수 있는 audio/mpeg를 만듭니다. 이 외에도 여러 유형을 사용할 수 있습니다.

비정상적인 파형

잠시 후에 코드를 다시 살펴보겠지만, 이제 방금 추가한 파일, 특히 파일의 끝 부분을 좀 더 자세히 살펴보겠습니다. 아래는 sintel_0.mp3 트랙의 두 채널에서 평균을 낸 최근 3, 000개 샘플의 그래프입니다. 빨간색 선의 각 픽셀은 [-1.0, 1.0] 범위의 부동 소수점 샘플입니다.

sintel_0.mp3의 끝

그 0도 (무음) 샘플이 뭐죠? 이러한 오류는 실제로 인코딩 중에 발생한 압축 아티팩트로 인해 발생합니다. 거의 모든 인코더에는 일종의 패딩이 있습니다. 이 경우 LAME는 정확히 576개의 패딩 샘플을 파일 끝에 추가했습니다.

끝의 패딩 외에도 각 파일의 시작 부분에 패딩이 추가되었습니다. sintel_1.mp3 트랙을 미리 살펴보면 전면에 또 다른 576개의 패딩 샘플이 있는 것을 볼 수 있습니다. 패딩의 양은 인코더와 콘텐츠에 따라 다르지만 Google에서는 각 파일에 포함된 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의 결합

결론

이렇게 다섯 개의 세그먼트를 모두 하나로 매끄럽게 연결했고 데모가 끝났습니다. 시작하기 전에 onAudioLoaded() 메서드는 컨테이너나 코덱을 고려하지 않습니다. 즉, 이러한 모든 기술은 컨테이너 또는 코덱 유형과 관계없이 작동합니다. 아래에서 MP3 대신 DASH 지원 단편화된 MP4를 원본 데모로 다시 재생할 수 있습니다.

데모

자세한 내용은 아래 부록에서 끊김 없는 콘텐츠 생성 및 메타데이터 파싱에 대해 자세히 알아보세요. gapless.js에서 이 데모의 기반이 되는 코드를 자세히 살펴볼 수도 있습니다.

읽어주셔서 감사합니다.

부록 A: 끊김 없는 콘텐츠 만들기

끊김 없는 콘텐츠를 만드는 것은 쉬운 일이 아닙니다. 아래에서는 이 데모에 사용된 Sintel 미디어를 만드는 과정을 안내합니다. 시작하려면 Sintel용 무손실 FLAC 사운드트랙 사본이 필요합니다. 나중에 사용할 수 있도록 SHA1이 아래에 포함되어 있습니다. 도구의 경우 FFmpeg, MP4Box, LAMEafconvert를 포함한 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초가 되는 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 파일을 만드는 과정을 살펴보겠습니다. 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 변수에 버퍼링할 수 있는 데이터의 양에는 제한이 없습니다. 필요한 경우 동일한 데이터를 동일한 위치에 다시 추가할 수도 있습니다.