Le récit de deux horloges

Planifier avec précision des contenus audio pour le Web

Chris Wilson
Chris Wilson

Introduction

La gestion du temps est l'une des plus grandes difficultés que représente la création de logiciels audio et musicaux de qualité à l'aide de la plate-forme Web. Pas comme le "temps d'écrire du code", mais comme l'horloge : l'un des sujets les moins bien compris concernant Web Audio est comment utiliser correctement l'horloge audio. L'objet Web AudioContext a une propriété currentTime qui expose cette horloge audio.

C'est particulièrement important pour les applications musicales de contenus audio Web, pas seulement pour l'écriture de séquenceurs et de synthétiseurs, mais aussi pour toute utilisation rythmique d'événements audio comme des batteries, des jeux et d'autres applications. Il est très important de disposer d'un timing cohérent et précis pour les événements audio. Il est important de prévoir des changements de son (comme la fréquence ou le volume), et pas seulement le démarrage et l'arrêt des sons. Il est parfois souhaitable d'avoir des événements légèrement aléatoires dans le temps, par exemple dans la démo du pistolet dans Developing Game Audio with the Web Audio API, mais nous voulons généralement disposer d'un timing cohérent et précis pour les notes de musique.

Nous vous avons déjà montré comment programmer des notes à l'aide des paramètres de temps des méthodes noteOn et noteOff (désormais renommés start et stop) dans la section Premiers pas avec l'audio Web ainsi que dans l'article Developing Game Audio with the Web Audio API. Pour y voir plus clair, nous avons d'abord besoin d'informations sur les horloges.

Le meilleur du temps : l'horloge audio sur le Web

L'API Web Audio donne accès à l'horloge matérielle du sous-système audio. Cette horloge est exposée sur l'objet AudioContext via sa propriété .currentTime, sous la forme d'un nombre à virgule flottante de secondes depuis la création de l'AudioContext. Ainsi, cette horloge (appelée ci-après "horloge audio") offre une très haute précision. Elle est conçue pour être capable de spécifier un alignement au niveau d'un échantillon audio individuel, même avec un taux d'échantillonnage élevé. Étant donné qu'un "double" comporte une précision d'environ 15 chiffres décimaux, même si l'horloge audio fonctionne depuis plusieurs jours, il doit encore y avoir beaucoup de bits pour pointer vers un échantillon spécifique, même à un taux d'échantillonnage élevé.

L'horloge audio est utilisée pour planifier les paramètres et les événements audio dans l'API Web Audio (pour start() et stop(), bien sûr, mais aussi pour les méthodes set*ValueAtTime() des AudioParams). Cela nous permet de programmer des événements audio à une heure très précise. En fait, il est tentant de se contenter de configurer tous les éléments dans Web Audio en tant qu'heures de début et d'arrêt, mais dans la pratique, cela pose problème.

Prenons l'exemple de cet extrait de code réduit issu de l'introduction Web Audio, qui configure deux barres d'un modèle de chariots en croupe:

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

Ce code fonctionnera parfaitement. Cependant, si vous voulez changer le tempo au milieu de ces deux mesures ou arrêter de jouer avant que les deux mesures ne soient écoulées, vous n'avez pas de chance. (J'ai vu des développeurs insérer un nœud de gain entre les AudioBufferSourceNode préprogrammés et la sortie, pour qu'ils puissent couper leur propre son.)

Pour résumer, comme vous aurez besoin de flexibilité pour modifier le tempo ou des paramètres tels que la fréquence ou l'augmentation (ou arrêter complètement la programmation), vous ne devez pas placer trop d'événements audio dans la file d'attente. (ou, plus précisément, ne pas aller trop loin dans le temps, car vous voudrez peut-être modifier complètement cette planification).

Le pire des temps - l'horloge JavaScript

Nous proposons également nos horloges JavaScript très appréciées et très mal alignées, représentées par Date.now() et setTimeout(). L'avantage de l'horloge JavaScript, c'est qu'elle dispose de deux méthodes de rappel plus tard très utiles, window.setTimeout() et window.setInterval(), qui permettent au système de rappeler notre code à des moments précis.

Le mauvais côté de l'horloge JavaScript est qu'elle n'est pas très précise. Pour les déclencheurs, Date.now() renvoie une valeur en millisecondes - un nombre entier de millisecondes - donc la meilleure précision que vous pouvez espérer est d'une milliseconde. Ce n'est pas très grave dans certains contextes musicaux (si votre note a commencé une milliseconde en avance ou en retard, vous ne le remarquerez peut-être même pas), mais même avec un débit matériel audio relativement faible de 44,1 kHz, le rythme est environ 44,1 fois trop lent pour être utilisé comme horloge à programmation audio. N'oubliez pas que la suppression d'échantillons peut provoquer des problèmes audio. Par conséquent, si nous enchaînons des échantillons, il se peut qu'ils soient séquentiels avec précision.

La spécification en matière de temps haute résolution, qui est en plein essor, nous offre en fait une bien meilleure précision de l'heure actuelle grâce à window.performance.now(). Elle est même implémentée (bien qu'elle soit précédée d'un préfixe) dans de nombreux navigateurs actuels. Cela peut être utile dans certaines situations, même si ce n'est pas vraiment pertinent pour le pire des API de temporisation JavaScript.

Le pire des API de timing JavaScript est que, bien que la précision en millisecondes de Date.now() ne soit pas trop mauvaise pour être vécue, le rappel réel d'événements de minuteur en JavaScript (via window.setTimeout() ou window.setInterval) peut facilement être faussé par des dizaines de millisecondes ou plus par la mise en page, le rendu, la récupération de mémoire, XMLHTTPRequest et d'autres rappels, quel que soit le nombre d'éléments principaux. Vous vous souvenez que j'ai mentionné des "événements audio" que nous pourrions programmer à l'aide de l'API Web Audio ? Elles sont toutes traitées sur un thread distinct. Ainsi, même si le thread principal est temporairement bloqué lors d'une mise en page complexe ou d'une autre tâche longue, le son se produira exactement au moment où ils ont été annoncés. En fait, même si vous êtes arrêté à un point d'arrêt dans le débogueur, le thread audio continuera à lire les événements planifiés.

Utiliser la fonction JavaScript setTimeout() dans les applications audio

Étant donné que le thread principal peut facilement se bloquer pendant plusieurs millisecondes à la fois, il est déconseillé d'utiliser la méthode setTimeout de JavaScript pour lancer directement la lecture des événements audio. Au mieux, vos notes se déclencheront en l'espace d'une milliseconde environ au moment où elles le devraient, et au pire, elles seront retardées encore plus longtemps. Pire encore, pour ce qui devrait être des séquences rythmiques, elles ne se déclencheront pas à des intervalles précis, car la durée sera sensible à d'autres éléments qui se produisent sur le thread JavaScript principal.

Pour illustrer cela, j'ai écrit un exemple d'application de métronome "mauvaise", c'est-à-dire une application qui utilise setTimeout directement pour planifier des notes, et effectue également de nombreuses mises en page. Ouvrez cette application, cliquez sur « lecture », puis redimensionnez rapidement la fenêtre pendant la lecture ; vous remarquerez que la durée est sensiblement saccadée (vous pouvez entendre que le rythme ne reste pas cohérent). « Mais c'est un faux !", dis-tu ? Eh bien, bien sûr, mais cela ne signifie pas que cela n'arrive pas non plus dans le monde réel. Même les interfaces utilisateur relativement statiques présentent des problèmes de synchronisation dans setTimeout en raison des remises en page. Par exemple, j'ai remarqué que si l'on redimensionne rapidement la fenêtre, le timing de l'excellent WebkitSynth est sensiblement saccadé. Imaginez maintenant ce qui se passera si vous essayez de faire défiler une partition complète en même temps que votre piste audio. Vous pouvez facilement imaginer comment cela affecterait les applications musicales complexes du monde réel.

L'une des questions les plus fréquentes que j'entends est la suivante : "Pourquoi ne puis-je pas recevoir de rappels à partir d'événements audio ?". Bien que ces types de rappels puissent être utilisés, ils ne résoudraient pas le problème en question. Il est important de comprendre que ces événements seraient déclenchés dans le thread JavaScript principal. Ils seraient donc soumis aux mêmes délais potentiels que setTimeout ; en d'autres termes, ils pourraient être traités pendant une durée inconnue et variable en millisecondes.

Que pouvons-nous faire ? Le meilleur moyen de gérer les codes temporels est de mettre en place une collaboration entre les minuteurs JavaScript (setTimeout(), setInterval() ou requestAnimationFrame() (nous y reviendrons plus tard)) et la planification du matériel audio.

Suivre un rythme solide en regardant vers l'avenir

Revenons à la démonstration des métronomes. En fait, j'ai écrit correctement la première version de cette démo simple pour illustrer cette technique de planification collaborative. (Le code est également disponible sur GitHub. Cette démonstration diffuse des bips (générés par un oscillateur) avec une haute précision à chaque seizième, huitième ou quart-de-vin, en modifiant la tonalité en fonction du rythme. Il vous permet également de modifier le tempo et l'intervalle entre les notes pendant la lecture, ou d'arrêter la lecture à tout moment. C'est une fonctionnalité essentielle pour tout séquenceur rythmique réel. Il serait assez facile d'ajouter du code pour modifier les sons que ce métronome utilise à la volée.

C'est une collaboration qui permet de contrôler la température tout en conservant un timing parfait: un minuteur setTimeout qui se déclenche de temps en temps et qui configure un calendrier de diffusion Web audio pour chaque note. Le minuteur setTimeout vérifie simplement si des notes doivent être programmées "bientôt" en fonction du tempo actuel, puis les planifie comme suit:

setTimeout() et interaction avec les événements audio.
setTimeout() et interaction avec des événements audio.

En pratique, les appels setTimeout() peuvent être retardés. Par conséquent, la durée des appels de planification peut varier (et s'écarter, selon la façon dont vous utilisez setTimeout) au fil du temps. Bien que les événements de cet exemple se déclenchent environ 50 ms les uns des autres, ils sont souvent légèrement plus nombreux (voire beaucoup plus). Cependant, lors de chaque appel, nous planifions des événements Web Audio non seulement pour les notes qui doivent être lues maintenant (par exemple, la toute première note), mais aussi pour celles qui doivent être lues entre maintenant et l'intervalle suivant.

En fait, nous ne voulons pas nous contenter d'anticiper avec précision l'intervalle entre les appels setTimeout(). Nous avons également besoin d'un chevauchement de planification entre cet appel de minuteur et le suivant, afin de nous adapter au pire des comportements de thread principal (c'est-à-dire le pire des cas de récupération de mémoire, de mise en page, d'affichage ou d'autres codes qui retardent notre prochain appel de minuteur). Nous devons également tenir compte du temps de planification des blocs audio, c'est-à-dire de la quantité de contenu audio conservée par le système d'exploitation dans son tampon de traitement. Elle varie selon les systèmes d'exploitation et le matériel, allant d'un seul chiffre à environ 50 ms. Chaque appel setTimeout() indiqué ci-dessus est associé à un intervalle bleu indiquant la période complète pendant laquelle il va tenter de planifier des événements. Par exemple, le quatrième événement audio Web planifié dans le diagramme ci-dessus peut avoir été lu "en retard" si nous avions attendu la lecture jusqu'au prochain appel setTimeout, si cet appel setTimeout() n'était que quelques millisecondes plus tard. Dans la vie réelle, la gigue peut être encore plus extrême dans ces périodes. Ce chevauchement devient d'autant plus important à mesure que votre application devient plus complexe.

La latence d'aperçu globale affecte le degré de serrage du contrôle du tempo (et des autres commandes en temps réel). L'intervalle entre les appels de planification est un compromis entre la latence minimale et la fréquence d'impact de votre code sur le processeur. Le chevauchement de l'aperçu avec l'heure de début de l'intervalle suivant détermine la résilience de votre application sur différentes machines, et au fur et à mesure qu'elle devient plus complexe (et que la mise en page et la récupération de mémoire peuvent prendre plus de temps). En règle générale, pour être résilient aux machines et systèmes d'exploitation plus lents, il est préférable d'avoir une avance globale importante et un intervalle relativement court. Vous pouvez ajuster le paramètre pour définir des chevauchements plus courts et des intervalles plus longs afin de traiter moins de rappels. Toutefois, à un moment donné, vous pourriez commencer à entendre qu'une latence importante entraînera des changements de tempo, etc., qui ne s'appliqueront pas immédiatement. À l'inverse, si vous avez trop atténué la temporisation, vous risquez de commencer à entendre des fluctuations (car un appel de programmation devra peut-être "rattraper" des événements qui auraient dû se produire auparavant).

Le diagramme de temps suivant illustre le fonctionnement réel du code de démonstration du métronome. Il présente un intervalle setTimeout de 25 ms, mais le chevauchement est beaucoup plus résilient : chaque appel sera programmé pour les 100 ms suivantes. L'inconvénient de cette longue anticipation est que les changements de tempo, etc., prennent un dixième de seconde. Cependant, nous sommes beaucoup plus résistants aux interruptions:

Planification avec de longs chevauchements
Planification avec de longs chevauchements

En fait, vous pouvez voir dans cet exemple une interruption setTimeout au milieu. Nous aurions dû recevoir un rappel setTimeout à environ 270 ms, mais il a été retardé pour une raison quelconque jusqu'à environ 320 ms, soit 50 ms plus tard qu'il ne l'aurait prévu. Cependant, la grande latence de anticipation a permis de maintenir le tempo sans problème et nous n'avons pas manqué de battement, même si nous avons augmenté le tempo juste avant pour jouer des seizièmes à 240 bpm (en plus des tempos drum and bass hardcore !)

Il est également possible que chaque appel du programmeur finisse par programmer plusieurs notes. Voyons ce qui se passe si nous utilisons un intervalle de planification plus long (250 ms d'avance, avec un intervalle de 200 ms) et une augmentation du tempo au milieu:

setTimeout() avec de longs intervalles d&#39;affichage et de longs intervalles.
setTimeout() avec anticipation et de longs intervalles

Ce cas montre que chaque appel setTimeout() peut entraîner la planification de plusieurs événements audio. En fait, ce métronome est une simple application « une note à la fois », mais vous pouvez facilement voir comment cette approche fonctionne pour une boîte à rythmes (où il y a souvent plusieurs notes simultanées) ou un séquenceur (qui peut souvent avoir des intervalles non réguliers entre les notes).

En pratique, vous devez ajuster l'intervalle de planification et la prévision pour voir l'impact sur la mise en page, la récupération de mémoire et d'autres éléments qui se produisent dans le thread d'exécution JavaScript principal, ainsi que pour ajuster la précision du contrôle sur le tempo, etc. Si votre mise en page est très complexe qui se produit fréquemment, par exemple, vous souhaiterez probablement agrandir l'aperçu. L'important, c'est que le temps de programmation à l'avance doit être suffisamment important pour éviter les retards, mais pas assez important pour créer un décalage notable lorsque vous ajustez le contrôle du tempo. Même le cas ci-dessus présente un très faible chevauchement. Il ne sera donc pas très résistant sur une machine lente dotée d'une application Web complexe. Vous pouvez commencer par effectuer une analyse anticipée de 100 ms, avec des intervalles de 25 ms. Cela peut toujours poser des problèmes dans les applications complexes exécutées sur des machines avec une latence élevée du système audio. Dans ce cas, vous devez augmenter la durée d'anticipation. Si vous avez besoin d'un contrôle plus strict avec la perte de résilience, utilisez un aperçu plus court.

Le code central du processus de planification se trouve dans la fonction scheduler(),

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

Cette fonction obtient simplement l'heure du matériel audio actuel et la compare à l'heure de la prochaine note de la séquence. La plupart du temps*, dans ce scénario précis, cela n'a aucun effet (car il n'y a pas de "notes" de métronome en attente de programmation, mais lorsqu'elle réussit, elle programme cette note à l'aide de l'API Web Audio et passe à la note suivante).

La fonction ScheduleNote() est chargée de programmer la lecture de la prochaine "note" audio Web. Dans le cas présent, j'ai utilisé des oscillateurs pour émettre des bips à différentes fréquences. Vous pourriez tout aussi facilement créer des nœuds AudioBufferSource et définir leurs tampons sur des sons de batterie ou tout autre son de votre choix.

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

Une fois que ces oscillateurs sont programmés et connectés, le code peut les oublier complètement. Ils démarrent, puis s'arrêtent, puis sont récupérés automatiquement.

La méthode nextNote() permet de passer à la double note, c'est-à-dire de définir les variables "nextNoteTime" et "current16thNote" sur la note suivante:

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

C'est assez simple, même s'il est important de comprendre que dans cet exemple de planification, je ne garde pas la trace de l'heure de la séquence, c'est-à-dire du temps écoulé depuis le début du métronome. Il nous suffit de nous souvenir du moment où nous avons joué la dernière note et de déterminer la date et l'heure programmées pour la lecture de la note suivante. De cette façon, nous pouvons changer le tempo (ou arrêter la lecture) très facilement.

Cette technique de planification est utilisée par un certain nombre d'autres applications audio sur le Web, comme la Web Audio Drum Machine, le très amusant jeu Acid Defender et des exemples audio plus détaillés comme la démo de Granular Effects.

Encore un autre système de gestion de temps

Comme le sait tout bon musicien, toutes les applications audio ont besoin de plus de cloches et de minuteurs. Il convient de noter que la meilleure façon d'effectuer un affichage visuel est d'utiliser un TROISI système de chronométrage !

Pourquoi, pourquoi avons-nous besoin d'un autre système de chronométrage ? Celle-ci est synchronisée avec l'affichage visuel (c'est-à-dire la fréquence d'actualisation des graphiques) via l'API requestAnimationFrame. Dans notre exemple de métronome, le dessin de zones n'est pas très important, mais à mesure que vos graphiques deviennent de plus en plus complexes, il devient de plus en plus essentiel d'utiliser requestAnimationFrame() pour les synchroniser avec la fréquence d'actualisation visuelle. En fait, il est tout aussi facile à utiliser dès le départ que d'utiliser setTimeout() ! Avec des graphiques synchronisés très complexes (par exemple, l'affichage précis des notes de musique denses à mesure qu'elles sont lues dans une notation musicale).

Nous avons gardé une trace des beats dans la file d'attente dans le planificateur:

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

L'interaction avec l'heure actuelle du métronome se trouve dans la méthode draw(), qui est appelée (à l'aide de requestAnimationFrame) dès que le système graphique est prêt pour une mise à jour:

var currentTime = audioContext.currentTime;

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

Là encore, vous remarquerez que nous vérifions l'horloge du système audio, car c'est celle avec laquelle nous voulons effectuer la synchronisation, puisqu'elle lira les notes, pour voir si nous devons dessiner une nouvelle case ou non. En fait, nous n'utilisons pas du tout les horodatages requestAnimationFrame, car nous utilisons l'horloge du système audio pour déterminer la position dans le temps.

Bien sûr, j'aurais pu simplement passer l'utilisation d'un rappel setTimeout() et placer mon planificateur de notes dans le rappel requestAnimationFrame. Nous revenions alors à deux minuteurs. Ce n'est pas un problème, mais il est important de comprendre que requestAnimationFrame n'est qu'un substitut de setTimeout() dans ce cas. Vous souhaitez tout de même disposer de la précision de planification du timing Web Audio pour les notes réelles.

Conclusion

J'espère que ce tutoriel vous a aidé à comprendre les horloges et les minuteurs, et à créer des codes temporels de qualité dans les applications audio Web. Ces mêmes techniques peuvent être extrapolées facilement pour construire des lecteurs de séquence, des boîtes à rythmes, etc. À bientôt...