La storia di due orologi

Pianificare l'audio web con precisione

Chris Wilson
Chris Wilson

Introduzione

Una delle maggiori sfide per creare software audio e musicale di alta qualità utilizzando la piattaforma web è la gestione del tempo. Non come "è ora di scrivere codice", ma come ora del sistema. Uno degli argomenti meno compresi di Web Audio è come utilizzare correttamente l'orologio audio. L'oggetto AudioContext di Web Audio ha una proprietà currentTime che espone questo orologio audio.

In particolare per le applicazioni musicali dell'audio web, non solo per la scrittura di sequencer e sintetizzatori, ma per qualsiasi utilizzo ritmico di eventi audio come drum machine, giochi e altre applicazioni, è molto importante avere una temporizzazione coerente e precisa degli eventi audio; non solo per avviare e interrompere i suoni, ma anche per pianificare le modifiche al suono (ad esempio la modifica della frequenza o del volume). A volte è preferibile avere eventi leggermente casuali nel tempo, ad esempio nella demo della mitragliatrice in Sviluppare l'audio di gioco con l'API Web Audio, ma in genere è preferibile avere una tempistica coerente e precisa per le note musicali.

Abbiamo già spiegato come pianificare le note utilizzando il parametro time dei metodi noteOn e noteOff (ora rinominati start e stop) di Web Audio nell'articolo Introduzione a Web Audio e anche in Sviluppare l'audio di gioco con l'API Web Audio. Tuttavia, non abbiamo esplorato in modo approfondito scenari più complessi, come la riproduzione di sequenze musicali o ritmi lunghi. Per approfondire, dobbiamo prima fare un breve riepilogo sugli orologi.

The Best of Times - the Web Audio Clock

La Web Audio API espone l'accesso all'orologio hardware del sottosistema audio. Questo quadrante orologio è esposto sull'oggetto AudioContext tramite la relativa proprietà .currentTime, come numero in virgola mobile di secondi dalla creazione dell'AudioContext. Ciò consente a questo orologio (di seguito denominato "orologio audio") di avere una precisione molto elevata; è progettato per poter specificare l'allineamento a livello di singolo campione audio, anche con una frequenza di campionamento elevata. Poiché in un "double" sono presenti circa 15 cifre decimali di precisione, anche se l'orologio audio è in funzione da giorni, dovrebbero essere ancora disponibili molti bit per puntare a un campione specifico anche a una frequenza di campionamento elevata.

L'orologio audio viene utilizzato per programmare parametri ed eventi audio nell'API Web Audio, ovviamente per start() e stop(), ma anche per i metodi set*ValueAtTime() su AudioParams. In questo modo possiamo configurare in anticipo eventi audio a tempo molto preciso. In effetti, è allettante configurare tutto in Web Audio come orari di inizio/arresto, ma in pratica c'è un problema.

Ad esempio, dai un'occhiata a questo snippet di codice ridotto tratto dalla nostra introduzione all'audio sul web, che imposta due barre di un pattern hi-hat di crome:

for (var bar = 0; bar < 2; bar++) {
  var time = startTime + bar * 8 * eighthNoteTime;

  // Play the hi-hat every eighth note.
  for (var i = 0; i < 8; ++i) {
    playSound(hihat, time + i * eighthNoteTime);
  }

Questo codice funzionerà benissimo. Tuttavia, se vuoi cambiare il tempo nel mezzo di queste due misure o interrompere la riproduzione prima che le due misure siano trascorse, non hai fortuna. (Ho visto sviluppatori fare cose come inserire un nodo di guadagno tra l'AudioBufferSourceNodes pre-pianificato e l'output, solo per disattivare l'audio dei propri suoni).

In breve, poiché avrai bisogno della flessibilità di modificare il tempo o parametri come la frequenza o il guadagno (o di interrompere del tutto la programmazione), non vuoi inserire troppi eventi audio nella coda o, più precisamente, non vuoi guardare troppo avanti nel tempo, perché potresti voler modificare completamente la programmazione.

The Worst of Times - the JavaScript Clock

Abbiamo anche il nostro orologio JavaScript tanto amato e tanto denigrato, rappresentato da Date.now() e setTimeout(). Il lato positivo dell'orologio JavaScript è che dispone di un paio di metodi molto utili come window.setTimeout() e window.setInterval(), che ci consentono di far richiamare il nostro codice dal sistema in momenti specifici.

Lo svantaggio dell'orologio JavaScript è che non è molto preciso. Per i principianti, Date.now() restituisce un valore in millisecondi, ovvero un numero intero di millisecondi, quindi la precisione migliore che potresti sperare è di un millisecondo. Questo non è un problema in alcuni contesti musicali: se la nota è iniziata un millisecondo prima o dopo, potresti non accorgertene nemmeno. Tuttavia, anche a una frequenza hardware audio relativamente bassa di 44,1 kHz, è circa 44,1 volte troppo lento per essere utilizzato come orologio di pianificazione audio. Ricorda che l'eliminazione di qualsiasi sample può causare glitch audio, quindi se li colleghi in sequenza, è necessario che siano esattamente sequenziali.

L'imminente specifica del tempo di risoluzione per l'alta risoluzione in realtà ci offre una precisione migliore del tempo attuale attraverso window.performance.now() ; è persino implementata (anche se con prefisso) in molti browser attuali. Questo può essere utile in alcune situazioni, anche se non è molto pertinente per la parte peggiore delle API di temporizzazione di JavaScript.

Il problema peggiore delle API di temporizzazione di JavaScript è che, anche se la precisione in millisecondi di Date.now() non sembra male, il callback effettivo degli eventi timer in JavaScript (tramite window.setTimeout() o window.setInterval) può essere facilmente alterato di decine di millisecondi o più da layout, rendering, garbage collection, XMLHTTPRequest e altri callback, in breve da qualsiasi numero di eventi che si verificano nel thread di esecuzione principale. Ricordi che ho parlato di "eventi audio" che possiamo pianificare utilizzando l'API Web Audio? Bene, vengono tutti elaborati in un thread separato, quindi anche se il thread principale è temporaneamente bloccato durante l'esecuzione di un layout complesso o di un'altra attività lunga, l'audio verrà riprodotto esattamente nei momenti in cui è stato programmato. Infatti, anche se il debugger si è fermato in un punto di interruzione, il thread audio continuerà a riprodurre gli eventi pianificati.

Utilizzare il metodo JavaScript setTimeout() nelle app audio

Poiché il thread principale può facilmente bloccarsi per più millisecondi alla volta, è sconsigliabile utilizzare setTimeout di JavaScript per avviare direttamente la riproduzione degli eventi audio, perché al meglio le note verranno attivate entro un millisecondo circa da quando dovrebbero essere attivate e, al peggio, verranno ritardate per un periodo ancora più lungo. Peggio ancora, per quanto dovrebbero essere sequenze ritmiche, non verranno attivate a intervalli precisi perché la temporizzazione sarà sensibile ad altri eventi che si verificano nel thread JavaScript principale.

Per dimostrarlo, ho scritto un'applicazione di metronomo "sbagliata" di esempio, ovvero un'applicazione che utilizza direttamente setTimeout per pianificare le note e che esegue anche molto layout. Apri questa applicazione, fai clic su "Riproduci" e ridimensiona rapidamente la finestra durante la riproduzione. Noterai che la sincronizzazione è notevolmente irregolare (puoi sentire che il ritmo non rimane costante). "Ma è tutto studiato!", dici? Certo, ma questo non significa che non accada anche nel mondo reale. Anche un'interfaccia utente relativamente statica avrà problemi di temporizzazione in setTimeout a causa dei re-routing. Ad esempio, ho notato che la modifica rapida delle dimensioni della finestra causa notevoli balzi nel timing dell'eccellente WebkitSynth. Ora immagina cosa succede quando provi a scorrere senza interruzioni una partitura musicale completa insieme all'audio e puoi facilmente immaginare in che modo questo influirebbe sulle app musicali complesse nel mondo reale.

Una delle domande più frequenti che mi vengono poste è: "Perché non riesco a ricevere i callback dagli eventi audio?" Sebbene questi tipi di callback possano essere utili, non risolvono il problema specifico in questione. È importante capire che questi eventi verrebbero attivati nel thread JavaScript principale, quindi sarebbero soggetti agli stessi potenziali ritardi di setTimeout; ovvero, potrebbero essere ritardati per un numero sconosciuto e variabile di millisecondi dal momento esatto in cui sono stati pianificati prima di essere effettivamente elaborati.

Cosa possiamo fare? Il modo migliore per gestire la temporizzazione è impostare una collaborazione tra i timer JavaScript (setTimeout(), setInterval() o requestAnimationFrame() - di seguito verranno fornite maggiori informazioni) e la pianificazione dell'hardware audio.

Ottenere tempismo solido guardando nel futuro

Torniamo alla demo del metronomo. In realtà, ho scritto correttamente la prima versione di questa semplice demo del metronomo per dimostrare questa tecnica di pianificazione collaborativa. Il codice è disponibile anche su GitHub. Questa demo riproduce dei beep (generati da un oscillatore) con un'elevata precisione su ogni sedicesima, ottava o nota di un quarto, modificando il tono a seconda del beat. Inoltre, ti consente di modificare il tempo e l'intervallo di note durante la riproduzione o di interrompere la riproduzione in qualsiasi momento, una funzionalità fondamentale per qualsiasi sequenziatore ritmico reale. Sarebbe abbastanza facile aggiungere codice per modificare anche i suoni utilizzati da questo metronomo.

Il modo in cui riesce a consentire il controllo della velocità mantenendo un timing preciso è una collaborazione: un timer setTimeout che si attiva di tanto in tanto e imposta la pianificazione di Web Audio in futuro per le singole note. Il timer setTimeout sostanzialmente controlla se delle note dovranno essere programmate "a breve" in base al tempo corrente, quindi le pianifica in questo modo:

Interazione tra setTimeout() e evento audio.
Interazione tra setTimeout() e evento audio.

In pratica, le chiamate setTimeout() potrebbero subire ritardi, pertanto la tempistica delle chiamate di pianificazione potrebbe variare (e essere distorta, a seconda di come utilizzi setTimeout) nel tempo. Sebbene gli eventi in questo esempio vengano attivati a circa 50 ms di distanza, spesso sono leggermente più lunghi (e a volte molto di più). Tuttavia, durante ogni chiamata, pianifichiamo gli eventi Web Audio non solo per le note che devono essere riprodotte adesso (ad esempio la prima nota), ma anche per le note che devono essere suonate da adesso fino all'intervallo successivo.

In realtà, non vogliamo solo guardare avanti con l'intervallo esatto tra le chiamate di setTimeout(), ma abbiamo bisogno anche di una sovrapposizione di pianificazione tra questa chiamata del timer e la successiva, per adattarci al comportamento peggiore del thread principale, ovvero il caso peggiore di raccolta dei rifiuti, layout, rendering o altro codice che si verifica nel thread principale ritardando la nostra successiva chiamata del timer. Inoltre, dobbiamo tenere conto del tempo di pianificazione dei blocchi audio, ovvero la quantità di audio che il sistema operativo mantiene nel buffer di elaborazione, che varia in base al sistema operativo e all'hardware, da una singola cifra bassa di millisecondi a circa 50 ms. Ogni chiamata a setTimeout() mostrata sopra ha un intervallo blu che mostra l'intero intervallo di tempo durante il quale tenterà di pianificare gli eventi. Ad esempio, il quarto evento audio web pianificato nel diagramma sopra potrebbe essere stato riprodotto "in ritardo" se avessimo aspettato di riprodurlo fino alla successiva chiamata a setTimeout, se questa chiamata a setTimeout fosse avvenuta solo pochi millisecondi dopo. Nella vita reale, il jitter in questi casi può essere ancora più estremo e questa sovrapposizione diventa ancora più importante man mano che l'app diventa più complessa.

La latenza di look-ahead complessiva influisce sulla rigidità del controllo del tempo (e di altri controlli in tempo reale); l'intervallo tra le chiamate di pianificazione è un compromesso tra la latenza minima e la frequenza con cui il codice influisce sul processore. L'entità dell'anticipazione con l'ora di inizio dell'intervallo successivo determina la resilienza della tua app su macchine diverse e la sua complessità (il layout e la raccolta dei rifiuti potrebbero richiedere più tempo). In generale, per essere resiliente a macchine e sistemi operativi più lenti, è meglio avere un'idea generale ampia e un intervallo ragionevolmente breve. Puoi regolarti in modo da avere sovrapposizioni più brevi e intervalli più lunghi, in modo da elaborare meno callback, ma a un certo punto potresti iniziare a sentire che una grande latenza causa cambiamenti di tempo e così via, non ha effetto immediato; al contrario, se hai ridotto troppo il lookahead, potresti iniziare a sentire qualche tremolio (poiché una chiamata di pianificazione potrebbe dover "compensare" eventi che avrebbero dovuto verificarsi in passato).

Il seguente diagramma di temporizzazione mostra cosa fa effettivamente il codice demo del metronomo: ha un intervallo setTimeout di 25 ms, ma una sovrapposizione molto più resiliente: ogni chiamata verrà pianificata per i 100 ms successivi. Lo svantaggio di questo lungo periodo di previsione è che le modifiche al tempo e così via avranno bisogno di un decimo di secondo per essere applicate; tuttavia, siamo molto più resilienti alle interruzioni:

Programmazione con sovrapposizioni lunghe.
programmazione con sovrapposizioni lunghe

In effetti, in questo esempio abbiamo avuto un'interruzione di setTimeout nel mezzo: avremmo dovuto avere un callback di setTimeout dopo circa 270 ms, ma per qualche motivo è stato ritardato fino a circa 320 ms, ovvero 50 ms più tardi di quanto avrebbe dovuto. Tuttavia, la grande latenza di look-ahead ha mantenuto il tempo senza problemi e non abbiamo perso un colpo, anche se abbiamo aumentato il tempo poco prima per riprodurre sedicesimi a 240 bpm (oltre i ritmi drum & bass più estremi).

È anche possibile che ogni chiamata allo scheduler finisca per pianificare più note. Vediamo cosa succede se utilizziamo un intervallo di pianificazione più lungo (lookahead di 250 ms, con un intervallo di 200 ms) e un aumento del tempo nel mezzo:

setTimeout() con un intervallo di tempo di visualizzazione anticipato lungo e intervalli lunghi.
setTimeout() con un'anticipazione lunga e intervalli lunghi

Questo caso dimostra che ogni chiamata a setTimeout() può finire per pianificare più eventi audio. In effetti, questo metronomo è una semplice applicazione che suona una nota alla volta, ma puoi facilmente capire come funziona questo approccio per una drum machine (dove spesso sono presenti più note simultanee) o un sequencer (che spesso può avere intervalli non regolari tra le note).

In pratica, ti consigliamo di ottimizzare l'intervallo di pianificazione e l'anticipo per vedere in che misura sono interessati dal layout, dalla raccolta dei rifiuti e da altri elementi nel thread di esecuzione principale di JavaScript, nonché per ottimizzare la granularità del controllo del tempo e così via. Ad esempio, se hai un layout molto complesso che si verifica di frequente, probabilmente ti consigliamo di aumentare l'anticipo. Il punto principale è che vogliamo che la quantità di "pianificazione in anticipo" che stiamo effettuando sia sufficientemente grande da evitare ritardi, ma non così grande da creare un ritardo evidente quando modifichiamo il controllo del tempo. Anche il caso riportato sopra presenta una sovrapposizione molto piccola, quindi non sarà molto resiliente su una macchina lenta con un'applicazione web complessa. Un buon punto di partenza sono probabilmente 100 ms di tempo "lookahead", con intervalli impostati su 25 ms. Questo potrebbe comunque avere problemi in applicazioni complesse su macchine con molta latenza del sistema audio, nel qual caso dovresti ridurre il tempo di attesa oppure, se hai bisogno di un controllo più stretto con la perdita di resilienza, utilizza una prospettiva più breve.

Il codice di base del processo di pianificazione si trova nella funzione scheduler():

while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
  scheduleNote( current16thNote, nextNoteTime );
  nextNote();
}

Questa funzione ottiene solo l'ora corrente dell'hardware audio e la confronta con l'ora della nota successiva della sequenza: la maggior parte delle volte* in questo scenario preciso non fa nulla (poiché non ci sono "note" di metronomo in attesa di essere pianificate, ma quando riesce la pianificazione della nota utilizzando l'API Web Audio e passa alla nota successiva.

La funzione scheduleNote() è responsabile della programmazione della successiva "nota" dell'Audio web da riprodurre. In questo caso, ho usato gli oscillatori per emettere suoni bip a diverse frequenze; è possibile creare nodi AudioBufferSource e impostare i buffer sui suoni di batteria o su qualsiasi altro suono.

currentNoteStartTime = time;

// create an oscillator
var osc = audioContext.createOscillator();
osc.connect( audioContext.destination );

if (! (beatNumber % 16) )         // beat 0 == low pitch
  osc.frequency.value = 220.0;
else if (beatNumber % 4)          // quarter notes = medium pitch
  osc.frequency.value = 440.0;
else                              // other 16th notes = high pitch
  osc.frequency.value = 880.0;
osc.start( time );
osc.stop( time + noteLength );

Una volta programmati e collegati, questi oscillatori possono essere dimenticati completamente da questo codice. Verranno avviati, poi interrotti e poi sottoposti a garbage collection automaticamente.

Il metodo nextNote() è responsabile dell'avanzamento alla sedicesima nota successiva, ovvero dell'impostazione delle variabili nextNoteTime e current16thNote sulla nota successiva:

function nextNote() {
  // Advance current note and time by a 16th note...
  var secondsPerBeat = 60.0 / tempo;    // picks up the CURRENT tempo value!
  nextNoteTime += 0.25 * secondsPerBeat;    // Add 1/4 of quarter-note beat length to time

  current16thNote++;    // Advance the beat number, wrap to zero
  if (current16thNote == 16) {
    current16thNote = 0;
  }
}

La procedura è piuttosto semplice, anche se è importante capire che in questo esempio di programmazione non sto tenendo traccia del "tempo della sequenza", ovvero il tempo trascorso dall'inizio del metronomo. Dobbiamo solo ricordare quando abbiamo suonato l'ultima nota e capire quando è pianificata la nota successiva. In questo modo possiamo cambiare il tempo (o interrompere la riproduzione) molto facilmente.

Questa tecnica di pianificazione viene utilizzata da diverse altre applicazioni audio sul web, ad esempio la Web Audio Drum Machine, il divertente gioco Acid Defender ed esempi audio ancora più approfonditi come la demo di effetti granulari.

Un altro sistema di sincronizzazione

Ora, come sa bene qualsiasi buon musicista, ogni applicazione audio ha bisogno di più campanacci, ehm, più timer. Vale la pena ricordare che il modo corretto per visualizzare i contenuti è utilizzare un TERZO sistema di temporizzazione.

Perché, perché, oh mio Dio, perché abbiamo bisogno di un altro sistema di cronometraggio? Questo viene sincronizzato con la visualizzazione, ovvero la frequenza di aggiornamento della grafica, tramite l'API requestAnimationFrame. Per disegnare caselle nel nostro esempio di metronomo, questo potrebbe non sembrare un grosso problema, ma man mano che la grafica diventa sempre più complessa, diventa sempre più fondamentale utilizzare requestAnimationFrame() per sincronizzarsi con la frequenza di aggiornamento visiva ed è altrettanto facile da usare fin dall'inizio quanto l'uso di setTimeout()! Con una grafica sincronizzata molto complessa (ad es. un'accurata visualizzazione del pacchetto denso di fotogrammi musicali, le note musicali dell'animazione più precise richiedono la sincronizzazione audio più precisa).

Abbiamo monitorato i beat in coda nell'organizzatore:

notesInQueue.push( { note: beatNumber, time: time } );

L'interazione con l'ora corrente del nostro metronomo si trova nel metodo draw(), che viene chiamato (utilizzando requestAnimationFrame) ogni volta che il sistema grafico è pronto per un aggiornamento:

var currentTime = audioContext.currentTime;

while (notesInQueue.length && notesInQueue[0].time < currentTime) {
  currentNote = notesInQueue[0].note;
  notesInQueue.splice(0,1);   // remove note from queue
}

Ancora una volta, noterai che stiamo controllando l'orologio dell'impianto audio, perché è quello con cui vogliamo sincronizzarci, dato che riprodurrà effettivamente le note, per vedere se dobbiamo disegnare una nuova casella o meno. In realtà, non utilizziamo affatto i timestamp di requestAnimationFrame, poiché utilizziamo l'orologio di sistema audio per capire dove ci troviamo nel tempo.

Ovviamente, avrei semplicemente saltato l'uso di un callback setTimeout() e avessi inserito il mio scheduler di note nel callback requestAnimationFrame. In questo modo, torneremmo di nuovo a due timer. Anche questo è accettabile, ma è importante capire che in questo caso requestAnimationFrame è solo un sostituto di setTimeout(), quindi per le note effettive ti consigliamo di utilizzare la precisione di pianificazione del timing di Web Audio.

Conclusione

Spero che questo tutorial ti sia stato utile per comprendere orologi, timer e come creare un ottimo tempismo nelle applicazioni audio web. Queste stesse tecniche possono essere estrapolate facilmente per creare lettori di sequenze, drum machine e altro ancora. A presto…