La storia di due orologi

Pianificare con precisione l'audio sul web

Andrea Rossi
Chris Wilson

Introduzione

Gestire il tempo è una delle principali sfide nella creazione di un ottimo software audio e musicale tramite la piattaforma web. Non come nel "tempo di scrittura del codice", ma come nell'ora dell'orologio, uno degli argomenti meno chiari di Web Audio è come lavorare correttamente con l'orologio audio. L'oggetto Web Audio AudioContext ha una proprietà currentTime che espone questo orologio audio.

In particolare per le applicazioni musicali dell'audio web, non solo per scrivere sequenziatori e sintetizzatori, ma anche per qualsiasi uso ritmico di eventi audio come drum machine, giochi e altre applicazioni, è molto importante avere una tempistica coerente e precisa degli eventi audio; non solo l'avvio e l'interruzione dei suoni, ma anche la programmazione delle modifiche al suono (come la modifica della frequenza o del volume). A volte è preferibile avere eventi leggermente casuali, ad esempio nella demo della mitragliatrice nello Sviluppo dell'audio del gioco con l'API Web Audio, ma in genere vogliamo avere una tempistica coerente e precisa per le note musicali.

Ti abbiamo già mostrato come programmare le note utilizzando il parametro temporale dei metodi Web Audio noteOn e noteOff (ora ribattezzato start e stop) in Getting Started with Web Audio e anche in Developing Game Audio with the Web Audio API. Tuttavia, non abbiamo approfondito scenari più complessi, come la riproduzione di sequenze musicali lunghe o ritmi. Per immergerci, abbiamo bisogno innanzitutto di qualche informazione di base sugli orologi.

The Best of Times - L'orologio audio sul web

L'API Web Audio espone l'accesso all'orologio hardware del sottosistema audio. Questo quadrante orologio è esposto nell'oggetto AudioContext tramite la relativa proprietà .currentTime, come numero in virgola mobile di secondi dalla creazione di AudioContext. Ciò consente a questo orologio (di seguito chiamato "orologio audio") di essere ad alta precisione; è progettato per essere in grado di specificare l'allineamento a un singolo livello di campione sonoro, anche con una frequenza di campionamento elevata. Poiché ci sono circa 15 cifre decimali di precisione in un "doppio", anche se l'orologio audio è in funzione da giorni, dovrebbero comunque avere molti bit per puntare a un campione specifico, anche con una frequenza di campionamento elevata.

L'orologio audio viene utilizzato per programmare parametri ed eventi audio in tutta l'API Web Audio, ovviamente per start() e stop(), ma anche per i metodi set*ValueAtTime() su AudioParams. Questo ci consente di impostare in anticipo eventi audio con orari molto precisi. Di fatto, si è tentati di configurare tutto in Web Audio come orari di avvio e di interruzione, anche se in pratica c'è un problema.

Ad esempio, guarda questo snippet di codice ridotto della nostra introduzione audio web, che imposta due barre di un pattern hi-hat da ottava:

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à alla perfezione. Tuttavia, se vuoi cambiare il tempo nel mezzo di queste due battute o smettere di suonare prima che le due battute siano alzate, sei sfortunato. (Ho visto sviluppatori fare cose come inserire un nodo di guadagno tra i loro AudioBufferSourceNodes preprogrammati e l'output, solo così possono disattivare i propri suoni.)

In breve, poiché ti servirà la flessibilità di cambiare tempo o parametri come la frequenza o il guadagno (o di interrompere del tutto la programmazione), non vuoi spingere troppi eventi audio nella coda o, più precisamente, non vuoi andare troppo avanti nel tempo, perché potresti voler cambiare completamente questa programmazione.

The Worst of Times: l'orologio JavaScript

Abbiamo anche il nostro amato e molto disapprovato orologio JavaScript, rappresentato da Date.now() e setTimeout(). Il lato positivo dell'orologio JavaScript è che ha un paio di metodi call-me-back-later window.setTimeout() e window.setInterval() molto utili, che permettono al sistema di richiamare il nostro codice in momenti specifici.

L'aspetto negativo del quadrante orologio JavaScript è che non è molto preciso. Per iniziare, Date.now() restituisce un valore in millisecondi, ovvero un numero intero di millisecondi, quindi la precisione migliore che puoi aspettarti è un millisecondo. Questo non è incredibilmente negativo in alcuni contesti musicali - se la tua nota è iniziata un millisecondo prima o dopo, potresti anche non notarlo - ma anche con una frequenza hardware relativamente bassa di 44,1 kHz, è circa 44,1 volte troppo lenta per essere usata come orologio di programmazione audio. Ricorda che l'eliminazione di un qualsiasi campione può causare problemi di qualità audio, quindi se concateniamo i campioni, potrebbero essere necessari che siano sequenziali.

L'emergente specifica del tempo di alta risoluzione in realtà ci offre un tempo attuale di precisione molto migliore grazie a window.performance.now(). È persino implementata (anche se con prefisso) in molti browser attuali. Ciò può essere d'aiuto in alcune situazioni, anche se non è pertinente alla parte peggiore delle API di temporizzazione JavaScript.

La parte peggiore delle API di temporizzazione JavaScript è che, sebbene la precisione in millisecondi di Date.now() non suona troppo male per poter funzionare, l'effettivo callback degli eventi timer in JavaScript (tramite window.setTimeout() o window.setInterval) può essere facilmente deviato di decine di millisecondi o più per layout, rendering, garbage collection, XMLHTTPRequest e altri callback, in breve, a causa dell'esecuzione di thread principali. Ricordi come ho parlato di "eventi audio" che potremmo programmare usando l'API Web Audio? Bene, questi vengono elaborati tutti su un thread separato. Quindi, anche se il thread principale si blocca temporaneamente a causa di un layout complesso o altre attività lunghe, l'audio continuerà a funzionare esattamente nel momento in cui era stato richiesto. Infatti, anche se ti fermi in un punto di interruzione nel debugger, il thread audio continuerà a riprodurre eventi programmati.

Utilizzo del metodo JavaScript setTimeout() nelle app audio

Poiché il thread principale può facilmente bloccarsi per diversi millisecondi alla volta, è una cattiva idea usare setTimeout di JavaScript per avviare direttamente la riproduzione degli eventi audio, perché nella migliore delle ipotesi le tue note si attiveranno entro un millisecondo circa rispetto a quando dovrebbero e nel peggiore dei casi subiranno ritardi ancora più lunghi. Peggio ancora, per quelle che dovrebbero essere sequenze ritmiche, non si attivano a intervalli precisi, in quanto il tempo sarà sensibile ad altri eventi nel thread JavaScript principale.

Per dimostrarlo, ho scritto un'applicazione metronomo "di scarsa qualità" di esempio, che utilizza setTimeout direttamente per pianificare gli appunti, oltre a fare molto layout. Apri l'applicazione, fai clic su "Riproduci" e ridimensiona rapidamente la finestra durante la riproduzione; potrai notare che il tempo è notevolmente tremolio (puoi sentire che il ritmo non è costante). "Ma è inventato!", dici? Certo, ma questo non significa che non succeda anche nel mondo reale. Anche un'interfaccia utente relativamente statica presenterà problemi di temporizzazione in setTimeout dovuti al relayout. Ad esempio, ho notato che il ridimensionamento della finestra causa un peggioramento notevole della tempistica di WebkitSynth, altrimenti eccellente. Immaginate cosa succederà quando si cerca di far scorrere una partitura musicale integrale insieme all'audio e si può facilmente immaginare come ciò potrebbe ripercuotersi su complesse app musicali nel mondo reale.

Una delle domande che mi vengono poste più di frequente è: "Perché non posso ricevere callback dagli eventi audio?". Sebbene possano essere possibili utilizzi per questi tipi di callback, questi non risolvono il problema specifico in questione. È importante capire che quegli eventi verrebbero attivati nel thread JavaScript principale, quindi sarebbero soggetti agli stessi potenziali ritardi di setTimeout; in altre parole, potrebbero essere ritardati per un numero di millisecondi sconosciuto e variabile.

Cosa possiamo fare? Bene, il modo migliore per gestire il tempo è impostare una collaborazione tra i timer JavaScript (setTimeout(), setInterval() o requestAnimationFrame() (altro ne parleremo più avanti) e la pianificazione dell'hardware audio.

Ottenere un tempismo solido guardando avanti

Torniamo a quella demo del metronomo: in effetti ho scritto correttamente la prima versione di questa semplice demo del metronomo per dimostrare questa tecnica di programmazione collaborativa. (Il codice è disponibile anche su GitHub). Questa demo riproduce dei beep (generati da un Oscillator) con elevata precisione su ogni sedicesima, ottava o quarto di nota, alterando l'intonazione in base al beat. Ti permette anche di cambiare il tempo e l'intervallo di note durante la riproduzione o di interromperla in qualsiasi momento, il che è una caratteristica fondamentale per qualsiasi sequenziatore ritmico del mondo reale. Sarebbe piuttosto facile aggiungere codice per cambiare all'istante anche i suoni utilizzati da questo metronomo.

Il modo in cui riesce a consentire il controllo della temperatura mantenendo un tempo di esecuzione solido è una collaborazione: un timer setTimeout che si attiva una volta di tanto in tanto e configura la pianificazione futura dell'audio web per le singole note. Il timer setTimeout fondamentalmente controlla solo se le note devono essere programmate "a breve" in base al tempo corrente, quindi le programma in questo modo:

setTimeout() e interazione dell&#39;evento audio.
setTimeout() e interazione con l'evento audio.

In pratica, le chiamate a setTimeout() potrebbero subire ritardi, quindi la tempistica delle chiamate di pianificazione può tremolio (e distorsione, a seconda di come utilizzi setTimeout) nel tempo. Sebbene gli eventi in questo esempio si attivino a circa 50 ms di distanza, spesso sono leggermente superiori (e a volte molto di più). Tuttavia, durante ogni chiamata, programmiamo gli eventi Web Audio non solo per le note che devono essere suonate adesso (ad esempio, la prima nota), ma anche per tutte le note che devono essere suonate da adesso all'intervallo successivo.

Infatti, non vogliamo pensare solo all'intervallo tra le chiamate setTimeout(): abbiamo anche bisogno di una sovrapposizione di pianificazione tra questa chiamata timer e la successiva, per soddisfare il comportamento del thread principale del caso peggiore, ovvero il caso peggiore di garbage collection, layout, rendering o altro codice che si verifica nel thread principale, ritardando la prossima chiamata al timer. Dobbiamo anche tenere conto del tempo di pianificazione del blocco dell'audio, ovvero della quantità di audio che il sistema operativo conserva nel buffer di elaborazione, che varia a seconda del sistema operativo e dell'hardware, da una cifra di millisecondi di pochi millisecondi a circa 50 ms. Ogni chiamata a setTimeout() mostrata sopra ha un intervallo blu che mostra l'intero intervallo di volte in cui tenterà di pianificare gli eventi; ad esempio, il quarto evento audio web programmato nello schema sopra potrebbe essere stato riprodotto "late" se avessimo aspettato di riprodurlo fino alla successiva chiamata setTimeout, se la chiamata a setTimeout è avvenuta solo pochi millisecondi dopo. Nella vita reale, il tremolio in questi tempi può essere ancora più estremo e questa sovrapposizione diventa ancora più importante man mano che l'app diventa più complessa.

La latenza di look generale influisce sulla limitazione 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 tuo codice influisce sul processore. L'entità della sovrapposizione del lookahead con l'ora di inizio dell'intervallo successivo determina la resilienza della tua app su computer diversi e, man mano che diventa più complessa, e layout e garbage collection potrebbero richiedere più tempo. In generale, per essere resilienti a macchine e sistemi operativi più lenti, è meglio avere un aspetto generale ampio e un intervallo ragionevolmente breve. Puoi regolare le sovrapposizioni 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 ritmo e così via, in modo che non abbiano effetto immediato; al contrario, se hai ridotto troppo il lookahead, potresti iniziare a sentire qualche tremolio (poiché una chiamata di programmazione potrebbe dover "recuperare" eventi che avrebbero dovuto verificarsi in passato).

Il seguente diagramma di temporizzazione mostra ciò che 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 successivi 100 ms. Lo svantaggio di questa lunga anteprima è che i cambi di tempo e così via richiedono un decimo di secondo per diventare effettivi; tuttavia, siamo molto più resistenti alle interruzioni:

Pianificazione con sovrapposizioni lunghe.
pianificazione con sovrapposizioni lunghe

Infatti, in questo esempio abbiamo avuto un'interruzione setTimeout centrale: avremmo dovuto avere un callback setTimeout di circa 270 ms, ma per qualche motivo è stato ritardato fino a circa 320 ms, ovvero 50 ms più tardi del previsto. Tuttavia, la grande latenza di lookahead faceva funzionare il tempo senza problemi e non abbiamo perso neanche un secondo, anche se abbiamo aumentato il tempo poco prima per suonare una sedicesima nota a 240 b/m (oltre ai tempi hardcore di drum & bass!)

È anche possibile che ogni chiamata dello scheduler finisca per programmare più note; vediamo cosa succede se utilizziamo un intervallo di programmazione più lungo (250 ms lookahead, a 200 ms di distanza) e un aumento del tempo al centro:

setTimeout() con lookahead e intervalli lunghi.
setTimeout() con lookahead lunghi e intervalli lunghi

Questo caso dimostra che ogni chiamata a setTimeout() può finire per programmare più eventi audio. In effetti, questo metronomo è una semplice applicazione che consiste in una singola nota alla volta, ma si può facilmente vedere come funziona questo approccio per una drum machine (dove ci sono spesso più note simultanee) o un sequenziatore (che può spesso avere intervalli non regolari tra le note).

In pratica, ti consigliamo di ottimizzare l'intervallo di pianificazione e il lookahead per vedere quanto sono interessati dal layout, dalla garbage collection e da altri aspetti nel thread di esecuzione JavaScript principale, nonché per ottimizzare la granularità del controllo sul tempo e così via. Se hai un layout molto complesso che si verifica spesso, ad esempio, dovresti ingrandire il lookahead. Il punto principale è che la quantità di "pianificazione in anticipo" che stiamo facendo sia abbastanza grande da evitare ritardi, ma non così grande da creare un ritardo evidente quando modifichiamo il controllo del tempo. Anche il caso sopra riportato ha una sovrapposizione molto ridotta, quindi non sarà molto resiliente su un computer lento con un'applicazione web complessa. Un buon punto di partenza sono probabilmente 100 ms di tempo per il "lookahead", con intervalli impostati su 25 ms. Ciò potrebbe avere ancora problemi in applicazioni complesse su computer con molta latenza del sistema audio, nel qual caso dovresti aumentare il tempo di lookahead; oppure, se hai bisogno di un controllo maggiore con la perdita di resilienza, utilizza un lookahead più breve.

Il codice di base del processo di pianificazione è nella funzione scheduler() -

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

Questa funzione recupera l'ora attuale dell'hardware audio e la confronta con l'ora della nota successiva nella sequenza; il più delle volte* in questo preciso scenario questo non farà nulla (poiché non ci sono "note" del metronomo in attesa di programmazione, ma una volta riuscito pianifica la nota utilizzando l'API Web Audio e passa alla nota successiva.

La funzione scheduleNote() è responsabile della pianificazione della successiva "nota" di Web Audio da riprodurre. In questo caso, ho usato gli oscillatori per emettere dei beep a frequenze diverse; potresti creare nodi AudioBufferSource e impostare i loro buffer sui suoni di batteria o su qualsiasi altro suono tu voglia.

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 che gli oscillatori sono programmati e connessi, questo codice può dimenticarsi completamente di questi oscillatori: si avviano, si arrestano e vengono raccolti automaticamente.

Il metodo nextNote() è responsabile dell'avanzamento alla sedicesima nota successiva, ovvero l'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;
  }
}

È piuttosto semplice, anche se è importante capire che in questo esempio di programmazione non tengo traccia del "tempo della sequenza", ovvero del tempo trascorso dall'inizio dell'inizio del metronomo. Tutto quello che dobbiamo fare è ricordare quando abbiamo suonato l'ultima nota e capire quando è prevista l'esecuzione della prossima nota. In questo modo, possiamo cambiare il tempo (o smettere di suonare) molto facilmente.

Questa tecnica di programmazione viene utilizzata da numerose altre applicazioni audio sul web, ad esempio la Web Audio Drum Machine, il divertente gioco Acid Defender e altri esempi audio più approfonditi come la demo di Effetti granulari.

Un altro sistema di tempistiche

Ora, come ogni buon musicista sa, ciò di cui ha bisogno ogni applicazione audio è un campanello più attivo: più timer. Vale la pena ricordare che il modo giusto per realizzare una visualizzazione visiva è utilizzare un sistema di sincronizzazione TERZO.

Perché, cari cieli, perché abbiamo bisogno di un altro sistema di sincronizzazione? Bene, questo viene sincronizzato sul display visivo, ovvero la frequenza di aggiornamento della grafica, tramite l'API requestAnimationFrame. Per quanto riguarda le scatole da disegno del 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 visivo. In realtà è facile da usare fin dall'inizio quanto l'uso di setTimeout(). Con una grafica sincronizzata molto complessa (ad esempio, la visualizzazione precisa di note musicali dense e la sincronizzazione grafica più precisa che richiedono la sincronizzazione musicale

Abbiamo tenuto traccia dei beat in coda nello scheduler:

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

L'interazione con l'ora corrente del nostro metronomo può essere trovata nel metodo disegno(), 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
}

Come già saprai, stiamo controllando l'orologio del sistema audio, perché è esattamente quello con cui vogliamo sincronizzarci, dato che riproduce effettivamente le note, per vedere se dobbiamo disegnare o meno una nuova casella. Infatti, non utilizziamo affatto i timestamp requestAnimationFrame, dal momento che utilizziamo l'orologio del sistema audio per capire dove ci troviamo nel tempo.

Ovviamente, avrei potuto semplicemente ignorare il callback setTimeout() e inserire il mio programma di pianificazione delle note nel callback requestAnimationFrame. Dopodiché saremmo tornati di nuovo a due timer. Va bene anche questo, ma è importante capire che requestAnimationFrame è solo un sostituto per setTimeout() in questo caso; vorrai comunque ottenere la precisione della pianificazione della temporizzazione dell'audio web per le note effettive.

Conclusione

Spero che questo tutorial sia stato utile per spiegare orologi, timer e come creare tempi eccezionali nelle applicazioni audio web. Queste stesse tecniche possono essere estrapolate facilmente per creare suonatori in sequenza, drum machine e altro ancora. Alla prossima...