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

דייל קרטיס
דייל קרטיס

מבוא

תוספים למקור מדיה (MSE) מספקים אחסון זמני מורחב ובקרת הפעלה לרכיבי HTML5 <audio> ו-<video>. למרות שפותח כדי לתמוך בנגני וידאו שמבוססים על Dynamic Adaptive Streaming באמצעות 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].

mp3 gap, ג&#39;י פי 3

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

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

mp3 gap end, סיום מרווח 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 (בכחול) הוסרו, וכך נשאר לנו מעבר חלק בין הקטעים.

mp3 Mid

סיכום

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

הדגמה

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

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

משוב