תוספים של מקור מדיה לאודיו

Dale Curtis
Dale Curtis

מבוא

תוספים למקורות מדיה (MSE) מספקים אגירת נתונים מורחבת ובקרת הפעלה לרכיבים של HTML5 <audio> ו-<video>. אמנם במקור פותח כדי לאפשר נגני וידאו המבוססים על סטרימינג דינמי דינמי באמצעות HTTP (DASH), אך בהמשך נראה כיצד ניתן להשתמש בהם לאודיו; במיוחד להשמעה ללא פערים.

סביר להניח שהאזנתם לאלבום מוזיקה שבו השירים זרמו בצורה חלקה בין טראקים. שאולי אתם אפילו מקשיבים לו ממש עכשיו. אומנים יוצרים את החוויות האלה של הפעלה ללא הפסקות גם בתור בחירה אומנותית, וגם בתור ארטיפקט של תקליטי ויניל ותקליטורים שבהם האודיו נכתב כשידור רציף אחד. לצערי, קודק האודיו המודרני כמו MP3 ו-AAC פועל, ולכן בדרך כלל חוויית ההאזנה החלקית הזו אבדה היום.

בהמשך נפרט את הסיבות לכך, אבל בינתיים נתחיל בהדגמה. בהמשך מוצגות 30 השניות הראשונות של קובץ Sintel המעולה, שנחתך לחמישה קובצי MP3 נפרדים והורכב מחדש באמצעות MSE. הקווים האדומים מציינים פערים שנוספו במהלך היצירה (הקידוד) של כל קובץ MP3. תשמעו תקלות בנקודות האלה.

הדגמה

אוי! זו לא חוויה טובה, אנחנו יכולים להשתפר. עם קצת יותר עבודה, בעזרת אותם קובצי MP3 בהדגמה שלמעלה, נוכל להשתמש ב-MSE כדי להסיר את הפערים המרגיעים האלה. הקווים הירוקים בהדגמה הבאה מציינים את המיקום שבו הקבצים צורפו והפערים הוסרו. ב-Chrome בגרסה 38 ומעלה, אפשרות זו תופעל בצורה חלקה!

הדגמה

יש מגוון דרכים ליצור תוכן ללא פערים. לצורך ההדגמה הזו נתמקד בסוג הקבצים שסביר להניח שמשתמש רגיל נמצא בהם. כל קובץ קודד בנפרד, בלי קשר לקטעי האודיו שלפניו או אחריו.

הגדרה בסיסית

קודם נסתכל שוב על ההגדרה הבסיסית של מכונה של MediaSource. תוספי מקור מדיה, כפי שמרמז השם, הם רק תוספים לרכיבי המדיה הקיימים. בהמשך אנחנו מקצים Object URL, שמייצג את המופע של MediaSource, למאפיין המקור של רכיב אודיו; בדיוק כמו שמגדירים כתובת 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 שלנו. יש כמה סוגים אחרים זמינים.

צורות גל חריגות

נחזור רגע לקוד, אבל עכשיו נבחן מקרוב את הקובץ שצירפנו, בייחוד בסוף. למטה מופיע תרשים של 3,000 הדגימות האחרונות, בממוצע בשני הערוצים מהטראק sintel_0.mp3. כל פיקסל בקו האדום הוא דגימת נקודה צפה (floating-point) בטווח של [-1.0, 1.0].

סוף sintel_0.mp3

מה הקטע של אפס הדגימות (השקט) האלה!? הסיבה לכך היא למעשה ארטיפקטים של דחיסה שנוספו במהלך הקידוד. כמעט כל מקודד כולל סוג מסוים של מרווח פנימי. במקרה הזה, נוספו על ידי LAME בדיוק 576 דוגמאות מרווח פנימי לסוף הקובץ.

בנוסף למרווח הפנימי בסוף, בתחילת כל קובץ נוסף גם מרווח פנימי. אם נציץ קדימה בטראק sintel_1.mp3, רואים עוד 576 דוגמאות של מרווח פנימי בחזית. מידת המרווח הפנימי משתנה בהתאם למקודד ולתוכן, אבל אנחנו יודעים מהם הערכים המדויקים שמבוססים על 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() שלנו אין התחשבות בקונטיינרים או בקודקים. כלומר, כל הטכניקות האלה יפעלו בלי קשר לסוג הקונטיינר או הקודק. בהמשך ניתן להפעיל שוב את ההדגמה המקורית של MP4 מקוטע ומוכן ל-DASH במקום בפורמט MP3.

הדגמה

בנספחים שבהמשך תמצאו מידע נוסף על יצירת תוכן וניתוח מטא-נתונים ללא פערים. אפשר גם לעבור אל gapless.js כדי לראות מקרוב את הקוד שמפעיל את ההדגמה הזו.

תודה על שקראת מידע זה!

נספח א': יצירת תוכן ללא הפרעות

לפעמים קשה ליצור תוכן ללא פערים. בהמשך נסביר איך ליצור את המדיה Sintel שבה נעשה שימוש בהדגמה הזו. כדי להתחיל, צריך עותק של פסקול FLAC מסוג Losless FLAC של Sintel. לדורות הבאים, ה-SHA1 כלול בהמשך. לכלים צריך להתקין FFmpeg, MP4Box, LAME והתקנת OSX עם afconvert.

unzip Jan_Morgenstern-Sintel-FLAC.zip
sha1sum 1-Snow_Fight.flac
# 0535ca207ccba70d538f7324916a3f1a3d550194  1-Snow_Fight.flac

קודם נפצל את 31.5 השניות הראשונות של הטראק של 1-Snow_Fight.flac. אנחנו גם רוצים להוסיף יציאה הדרגתית של 2.5 שניות החל מ-28 שניות כדי למנוע קליקים ברגע שההפעלה תסתיים. באמצעות שורת הפקודה 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 רגיל באיכות גבוהה של קובצי ה-Wave.

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 ביניים, לפי ההוראות, לפני קידודם כ-AAC במאגר MP4 באמצעות הפרמטרים המומלצים.

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 מקוטעים עם המטא-נתונים הנכונים שדרושים להפעלה ללא פערים. אפשר לעיין בנספח ב' לפרטים נוספים על המראה של המטא-נתונים האלו.

נספח ב': ניתוח מטא-נתונים ללא פערים

בדיוק כמו ליצור תוכן ללא פערים, הניתוח של מטא-נתונים ללא פערים יכול להיות מסובך, כי אין שיטה סטנדרטית לאחסון. בהמשך נסביר איך שני המקודדים הנפוצים ביותר, 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

הוא נכתב בתוך תג ID3 בתוך מאגר ה-MP3 ובתוך Atom של מטא-נתונים בתוך מאגר ה-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 בייטים בדיוק אחרי התג הזה הם 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.

עכשיו, אחרי שיש לנו את מספר הדגימות הכולל, אפשר לעבור להקראה של מספר הדגימות הפנימיות. בהתאם למקודד שלכם, יכול להיות שהטקסט הזה נכתב מתחת לתג LAME או לתג Lavf שמוצב בכותרת Xing. בדיוק 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
};
}

לכן יש לנו פונקציה מלאה לניתוח הרוב המכריע של תוכן ללא פערים. עם זאת, יש כמות גדולה של מקרי קצה, ולכן מומלץ להיות זהירים לפני שמשתמשים בקוד דומה בסביבת הייצור.

נספח ג': בנושא איסוף אשפה

הזיכרון ששייך למכונות של SourceBuffer מתבצע באופן פעיל לאיסוף אשפה בהתאם לסוג התוכן, למגבלות הספציפיות לפלטפורמה ולמיקום הנוכחי של ההפעלה. ב-Chrome, המערכת תתעלם קודם מהזיכרון ממאגרי נתונים שכבר הופעלו. עם זאת, אם השימוש בזיכרון חורג מהמגבלות הספציפיות לפלטפורמה, הזיכרון יוסר ממאגרי הנתונים הזמניים שלא הופעלו.

כשההפעלה מגיעה לפער בציר הזמן בגלל זיכרון ששוחזר, יכול להיות שהפער הזה לא יהיה מספיק קטן או שהוא ייתקע לגמרי אם הפער גדול מדי. גם חוויית המשתמש לא מעולה, לכן חשוב להימנע מצירוף יותר מדי נתונים בבת אחת ולהסיר באופן ידני טווחים מציר הזמן של המדיה שכבר לא נחוצים.

אפשר להסיר טווחים באמצעות השיטה remove() בכל SourceBuffer. לוקח טווח [start, end] בשניות. בדומה ל-appendBuffer(), כל remove() יפעיל אירוע updateend ברגע שהוא יסתיים. אין לשלוח הסרות או תוספות אחרות עד שהאירוע יופעל.

ב-Chrome למחשב אפשר לשמור בו-זמנית תוכן אודיו בנפח של כ-12 מגה-בייט של תוכן וידאו בנפח של כ-150 מגה-בייט. לא כדאי להסתמך על הערכים האלה בדפדפנים או בפלטפורמות שונות. למשל, הם בהחלט לא מייצגים מכשירים ניידים.

איסוף אשפה משפיע רק על נתונים שנוספו אל SourceBuffers. אין מגבלות על כמות הנתונים שאפשר לשמור במאגר נתונים זמני במשתני JavaScript. ניתן גם לצרף מחדש את אותם נתונים באותו מיקום במקרה הצורך.