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

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. כל פיקסל בקו האדום הוא דוגמה של נקודה צפה בטווח של [-1.0, 1.0].

סוף sintel_0.mp3

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

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

הדגמה

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

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

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

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

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