مقدمة
توفّر إضافات مصدر الوسائط (MSE) سعة تخزين موسّعة والتحكُّم في التشغيل لعنصرَي HTML5 <audio>
و<video>
. على الرغم من أنّه تم تطويرها في الأساس لتسهيل استخدام مشغّلات الفيديو المستندة إلى البث التكيُّفي الديناميكي عبر HTTP (DASH)، سنطّلع أدناه على طرق استخدامها لتشغيل الصوت. خصيصًا للتشغيل بسلاسة
من المحتمل أنّك استمعت إلى ألبوم موسيقي تدفّقت فيه الأغاني بسلاسة على مختلف المقاطع الصوتية. قد تكون تستمع إلى إحداها الآن. يوفّر الفنانون تجارب التشغيل بدون انقطاع هذه كخيار فني بالإضافة إلى أداة من سجلات الفينيل والأقراص المدمجة التي تمت كتابة الصوت فيها كبث واحد متواصل. وبسبب الطريقة التي تعمل بها برامج ترميز الصوت الحديثة، مثل MP3 وAAC، غالبًا ما تفقد هذه التجربة الصوتية السلسة.
سوف ندخل في تفاصيل السبب أدناه، ولكن في الوقت الحالي لنبدأ بعرض توضيحي. في ما يلي أول ثلاثين ثانية من مقطع Sintel الممتاز الذي تم تقطيعه إلى خمسة ملفات MP3 منفصلة وإعادة تجميعه باستخدام MSE. تشير الخطوط الحمراء إلى الثغرات التي حدثت أثناء إنشاء (ترميز) كل ملف MP3. ستسمع أخطاء في هذه النقاط.
يا للهول! هذه ليست تجربة رائعة؛ فيمكننا القيام بعمل أفضل. بعد بذل جهد إضافي واستخدام ملفات MP3 نفسها في العرض التوضيحي أعلاه، يمكننا استخدام الخطأ التربيعي المتوسط لإزالة هذه الثغرات المزعجة. تشير الخطوط الخضراء في العرض التوضيحي التالي إلى مكان ضم الملفات وإزالة الفجوات. ويمكنك تشغيل هذه الفيديوهات بسلاسة على الإصدار 38 من Chrome والإصدارات الأحدث.
تتوفّر مجموعة متنوعة من الطرق لإنشاء محتوى خالٍ من الثغرات. لأغراض هذا العرض التوضيحي، سنركز على نوع الملفات التي قد يكون لدى المستخدم العادي. تشير هذه السمة إلى أنّه تم ترميز كل ملف بشكل منفصل بدون مراعاة المقاطع الصوتية السابقة له أو بعدها.
الإعداد الأساسي
أولاً، لنرجع ونتحدّث عن عملية الإعداد الأساسية لمثيل 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()
لا تأخذ في الاعتبار الحاويات أو برامج الترميز. ويعني هذا أنّ جميع هذه الأساليب ستعمل بغض النظر عن الحاوية أو نوع برنامج الترميز. يمكنك أدناه إعادة تشغيل الإصدار التجريبي الأصلي المُجزّأ بتنسيق 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 ملفات موجة تبلغ مدة كل ملف منها 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
. لأغراضنا، سنستخدم حجم جزء يبلغ ثانية واحدة. ستكتب 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.
سنتناول تنسيق البيانات الوصفية لـ iTunes من 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
أو Info
على عناوين Xing. بعد هذه العلامة بالضبط هناك 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. يمكنك أيضًا إعادة إلحاق البيانات نفسها في الموضع نفسه إذا لزم الأمر.