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

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