Estensioni per fonti multimediali per audio

Dale Curtis
Dale Curtis

Introduzione

Le estensioni MSE (Media Source Extensions) offrono controllo esteso del buffering e della riproduzione per gli elementi <audio> e <video> HTML5. Sebbene siano stati sviluppati originariamente per facilitare i video player basati su Dynamic Adaptive Streaming over HTTP (DASH), vedremo di seguito come possono essere utilizzati per l'audio, in particolare per la riproduzione senza interruzioni.

Probabilmente hai ascoltato un album musicale in cui le canzoni ricorrono perfettamente tra le tracce; forse ne stai ascoltando uno proprio in questo momento. Gli artisti creano queste esperienze di riproduzione senza interruzioni sia come scelta artistica che come un manufatto di dischi in vinile e CD in cui l'audio è stato scritto come un unico stream continuo. Sfortunatamente, a causa del funzionamento dei moderni codec audio come MP3 e AAC, spesso questa esperienza sonora senza interruzioni è spesso persa.

Vedremo in dettaglio perché di seguito, ma per ora iniziamo con una dimostrazione. Di seguito sono riportati i primi trenta secondi dell'eccellente Sintel suddiviso in cinque file MP3 separati e riassemblati utilizzando MSE. Le linee rosse indicano delle interruzioni introdotte durante la creazione (codifica) di ogni MP3; in questi punti sentirai degli errori.

Demo

Accidenti! Non è un'esperienza fantastica, possiamo fare meglio. Con un po' più di lavoro, usando gli stessi file MP3 della demo sopra, possiamo usare la funzione MSE per rimuovere queste fastidiose lacune. Le linee verdi nella demo successiva indicano il punto in cui sono stati uniti i file e gli spazi vuoti rimossi. Su Chrome 38 e versioni successive la riproduzione verrà riprodotta senza interruzioni.

Demo

Esistono diversi modi per creare contenuti senza interruzioni. Ai fini di questa demo, ci concentreremo sul tipo di file che un utente normale potrebbe avere in giro. in cui ogni file è stato codificato separatamente, senza tenere conto dei segmenti audio precedenti o successivi.

Configurazione di base

Per prima cosa, facciamo un passo indietro e vediamo in che modo la configurazione di base di un'istanza MediaSource. Le estensioni per origini multimediali, come implica il nome, sono solo estensioni agli elementi multimediali esistenti. Di seguito assegniamo un elemento Object URL, che rappresenta l'istanza MediaSource, all'attributo source di un elemento audio, proprio come faresti con un URL standard.

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);

Una volta che l'oggetto MediaSource è connesso, eseguirà un'inizializzazione e alla fine attiverà un evento sourceopen; a questo punto possiamo creare un SourceBuffer. Nell'esempio precedente, ne creeremo uno audio/mpeg in grado di analizzare e decodificare i nostri segmenti MP3. Ne sono disponibili diversi altri tipi.

Forme d'onda anomale

Torneremo sul codice tra poco, ma ora diamo un'occhiata più da vicino al file che abbiamo appena aggiunto, in particolare alla fine. Di seguito è riportato un grafico degli ultimi 3000 campioni calcolati in media su entrambi i canali dalla traccia sintel_0.mp3. Ogni pixel sulla linea rossa è un esempio con rappresentazione in virgola mobile nell'intervallo di [-1.0, 1.0].

Fine di sintel_0.mp3

Come si fa con quei campioni zero (silenziosi)? In realtà sono dovuti ad artefatti di compressione introdotti durante la codifica. Quasi tutti i codificatori introducono un certo tipo di spaziatura interna. In questo caso LAME ha aggiunto esattamente 576 esempi di spaziatura interna alla fine del file.

Oltre alla spaziatura interna alla fine, all'inizio di ogni file è stata aggiunta anche una spaziatura interna. Se diamo un'occhiata più avanti al canale sintel_1.mp3, vedremo che nella parte anteriore sono presenti altri 576 esempi di spaziatura interna. La quantità di spaziatura interna varia in base al codificatore e ai contenuti, ma conosciamo i valori esatti in base al valore metadata incluso in ogni file.

Inizio di sintel_1.mp3

Inizio di sintel_1.mp3

Le sezioni di silenzio all'inizio e alla fine di ogni file causano glitch tra i segmenti nella demo precedente. Per ottenere una riproduzione senza interruzioni, dobbiamo rimuovere queste parti di silenzio. Per fortuna, l'operazione è semplice con MediaSource. Di seguito, modificheremo il nostro metodo onAudioLoaded() in modo da utilizzare una finestra di aggiunta e un offset di timestamp per rimuovere questo silenziamento.

Codice di esempio

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);
}

Una forma d'onda senza interruzioni

Osserviamo i risultati del nostro nuovo codice esaminando di nuovo la forma d'onda dopo aver applicato le finestre di accodamento. Di seguito puoi vedere che la sezione silenziosa alla fine di sintel_0.mp3 (in rosso) e la sezione silenziosa all'inizio di sintel_1.mp3 (in blu) sono state rimosse, lasciandoci una transizione senza soluzione di continuità tra i segmenti.

Unione di sintel_0.mp3 e sintel_1.mp3

Conclusione

Detto questo, abbiamo unito tutti e cinque i segmenti senza soluzione di continuità in uno unico e abbiamo successivamente raggiunto la fine della nostra demo. Prima di finire, potresti aver notato che il nostro metodo onAudioLoaded() non prende in considerazione container o codec. Ciò significa che tutte queste tecniche funzioneranno indipendentemente dal tipo di container o di codec. Di seguito puoi riprodurre di nuovo l'MP4 frammentato, pronto per DASH, anziché MP3.

Demo

Per saperne di più, consulta le appendici di seguito per un approfondimento sulla creazione senza interruzioni di contenuti e sull'analisi dei metadati. Puoi anche esplorare gapless.js per dare un'occhiata più da vicino al codice alla base di questa demo.

Grazie per l'attenzione.

Appendice A: Creazione di contenuti Gapless

Creare contenuti vuoti può essere difficile da seguire. Di seguito, illustreremo la creazione dei contenuti multimediali Sintel utilizzati in questa demo. Per iniziare avrai bisogno di una copia della colonna sonora FLAC senza perdita di dati per Sintel. Per i posteri, l'algoritmo SHA1 è incluso di seguito. Per gli strumenti, occorre FFmpeg, MP4Box, LAME e un'installazione OSX con afconvert.

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

Innanzitutto, suddivideremo i primi 31,5 secondi della traccia 1-Snow_Fight.flac. Vogliamo anche aggiungere una dissolvenza in uscita di 2,5 secondi a partire da 28 secondi per evitare clic al termine della riproduzione. Utilizzando la riga di comando FFmpeg qui sotto, possiamo fare tutto questo e inserire i risultati in sintel.flac.

ffmpeg -i 1-Snow_Fight.flac -t 31.5 -af "afade=t=out:st=28:d=2.5" sintel.flac

Successivamente, suddivideremo il file in 5 file wave da 6,5 secondi ciascuno; è il più semplice da utilizzare poiché quasi tutti i codificatori ne supportano l'importazione. Anche in questo caso, possiamo farlo con precisione con FFmpeg, dopodiché avremo: sintel_0.wav, sintel_1.wav, sintel_2.wav, sintel_3.wav e 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

Ora creiamo i file MP3. LAME ha diverse opzioni per creare contenuti gapless. Se hai il controllo dei contenuti, puoi considerare l'uso di --nogap con una codifica batch di tutti i file per evitare di occupare del tutto la spaziatura interna tra i segmenti. Ai fini di questa demo, tuttavia, vogliamo utilizzare la spaziatura interna in modo da utilizzare una codifica VBR standard di alta qualità per i file 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

Questo è tutto ciò che serve per creare i file MP3. Ora vediamo come creare i file MP4 frammentati. Seguiremo le indicazioni di Apple per la creazione di contenuti multimediali masterizzati per iTunes. Di seguito convertiremo i file wave in file CAF intermedi, in base alle istruzioni, prima di codificarli come AAC in un container MP4 utilizzando i parametri consigliati.

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

Ora abbiamo diversi file M4A che dobbiamo frammentare in modo appropriato prima di poter essere utilizzati con MediaSource. Per i nostri scopi, utilizzeremo una dimensione del frammento di un secondo. MP4Box scriverà ogni MP4 frammentato come sintel_#_dashinit.mp4 insieme a un manifest MPEG-DASH (sintel_#_dash.mpd) che può essere eliminato.

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

È tutto. Ora abbiamo file MP4 e MP3 frammentati con i metadati corretti necessari per una riproduzione senza interruzioni. Consulta l'Appendice B per maggiori dettagli sull'aspetto dei metadati.

Appendice B: analisi dei metadati senza lacune

Proprio come per la creazione di contenuti gapless, l'analisi di questi metadati può essere difficoltosa in quanto non esiste un metodo standard per l'archiviazione. Di seguito illustreremo in che modo i due codificatori più comuni, LAME e iTunes, memorizzano i propri metadati gapless. Iniziamo impostando alcuni metodi di supporto e una descrizione per ParseGaplessData() utilizzati sopra.

// 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.

Vedremo prima il formato dei metadati di iTunes di Apple, poiché è il più facile da analizzare e spiegare. All'interno dei file MP3 e M4A, iTunes (e afconvert) scrivi una breve sezione in ASCII in questo modo:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

È scritto all'interno di un tag ID3 nel container MP3 e in un atom di metadati all'interno del container MP4. Per i nostri scopi, possiamo ignorare il primo token 0000000. I tre token successivi sono la spaziatura interna anteriore, la spaziatura interna finale e il numero totale di campioni senza spaziatura interna. Dividendo ciascuna di queste risposte per la frequenza di campionamento dell'audio si ottiene la durata di ognuna.

// 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);
}

D'altra parte, la maggior parte dei codificatori MP3 open source memorizzerà i metadati senza interruzioni all'interno di una speciale intestazione Xing posizionata all'interno di un frame MPEG silenzioso (non silenzioso, quindi i decoder che non comprendono l'intestazione Xing riprodurranno semplicemente il silenzio). Purtroppo questo tag non è sempre presente e contiene una serie di campi facoltativi. Ai fini di questa demo, abbiamo il controllo sui contenuti multimediali, ma in pratica saranno necessari alcuni controlli aggiuntivi per sapere quando sono effettivamente disponibili metadati gapless.

Innanzitutto, analizzeremo il conteggio totale dei campioni. Per semplicità, leggiamo questo testo dall'intestazione Xing, ma potrebbe essere creato dalla normale intestazione audio MPEG. Le intestazioni Xing possono essere contrassegnate da un tag Xing o Info. Esattamente 4 byte dopo questo tag, ci sono 32 bit che rappresentano il numero totale di frame nel file; moltiplicando questo valore per il numero di campioni per frame, ottieni gli esempi totali nel file.

// 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.

Ora che abbiamo il numero totale di esempi, possiamo passare alla lettura del numero di esempi di spaziatura interna. A seconda del codificatore, questa informazione può essere scritta sotto un tag LAME o Lavf nidificato nell'intestazione Xing. Esattamente 17 byte dopo questa intestazione, ci sono 3 byte che rappresentano la spaziatura interna del front-end e dell'estremità rispettivamente a 12 bit ciascuno.

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
};
}

Detto questo, abbiamo una funzione completa per l'analisi della stragrande maggioranza dei contenuti senza lacune. Tuttavia, i casi limite abbondano, quindi è consigliabile fare attenzione prima di utilizzare codice simile in produzione.

Appendice C: Informazioni sulla raccolta dei rifiuti

La memoria appartenente a SourceBuffer istanze viene attivamente garbage collection in base al tipo di contenuti, ai limiti specifici della piattaforma e alla posizione di riproduzione attuale. In Chrome, la memoria viene prima recuperata dai buffer già riprodotti. Tuttavia, se l'utilizzo della memoria supera i limiti specifici della piattaforma, verrà rimossa la memoria dai buffer non riprodotti.

Quando la riproduzione raggiunge un divario nella sequenza temporale a causa della memoria recuperata, potrebbe verificarsi un glitch se l'intervallo è abbastanza piccolo o bloccarsi completamente se lo spazio è troppo grande. Neanche l'esperienza utente è ottimale, quindi è importante evitare di aggiungere troppi dati contemporaneamente e rimuovere manualmente dalla sequenza temporale degli intervalli che non sono più necessari.

Gli intervalli possono essere rimossi tramite il metodo remove() su ogni SourceBuffer, che richiede un intervallo di [start, end] in secondi. Analogamente a appendBuffer(), ogni remove() attiverà un evento updateend al termine. Non devono essere eseguite altre operazioni di rimozione o aggiunta fino all'attivazione dell'evento.

Sul Chrome desktop puoi conservare contemporaneamente circa 12 MB di contenuti audio e 150 MB di contenuti video. Non dovresti basarti su questi valori nei vari browser o piattaforme; ad esempio, questi valori non sono certamente rappresentativi dei dispositivi mobili.

La garbage collection influisce solo sui dati aggiunti a SourceBuffers; non sono previsti limiti alla quantità di dati che puoi conservare nel buffer nelle variabili JavaScript. Se necessario, puoi anche riaggiungere gli stessi dati nella stessa posizione.