Programmer des contenus audio Web avec précision
Introduction
L'un des plus grands défis de la création de logiciels audio et musicaux de qualité à l'aide de la plate-forme Web est la gestion du temps. Il ne s'agit pas de "temps d'écrire du code", mais de l'heure de la montre. L'un des sujets les moins bien compris de Web Audio est la façon de bien utiliser l'horloge audio. L'objet AudioContext de Web Audio possède une propriété currentTime qui expose cette horloge audio.
En particulier pour les applications musicales de l'audio Web (non seulement l'écriture de séquenceurs et de synthétiseurs, mais aussi toute utilisation rythmique d'événements audio tels que les machines à rythme, les jeux et les autres applications), il est très important de définir un timing cohérent et précis pour les événements audio. Il ne s'agit pas seulement de démarrer et d'arrêter les sons, mais aussi de planifier des modifications du son (comme la fréquence ou le volume). Il est parfois souhaitable d'avoir des événements légèrement aléatoires dans le temps (par exemple, dans la démonstration de mitrailleuse de Développer l'audio de jeu avec l'API Web Audio). Toutefois, en général, nous souhaitons que le timing des notes musicales soit cohérent et précis.
Nous vous avons déjà expliqué comment planifier des notes à l'aide du paramètre de temps des méthodes Web Audio noteOn et noteOff (désormais renommées start et stop) dans Premiers pas avec Web Audio et Développer l'audio de jeu avec l'API Web Audio. Toutefois, nous n'avons pas exploré en profondeur les scénarios plus complexes, tels que la lecture de longues séquences musicales ou de rythmes. Pour en savoir plus, nous devons d'abord avoir quelques notions sur les horloges.
The Best of Times - the Web Audio Clock
L'API Web Audio expose l'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 écoulées depuis la création de l'AudioContext. Cela permet à cette horloge (ci-après appelée "horloge audio") d'être très précise. Elle est conçue pour pouvoir spécifier l'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 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 des paramètres et des événements audio dans l'API Web Audio, pour start() et stop(), bien sûr, mais aussi pour les méthodes set*ValueAtTime() sur AudioParams. Cela nous permet de configurer à l'avance des événements audio très précis. En fait, il peut être tentant de tout configurer dans le contenu audio pour le Web en tant qu'heures de début et d'arrêt, mais dans la pratique, cela pose problème.
Par exemple, examinez cet extrait de code réduit de notre introduction à l'audio Web, qui configure deux mesures d'un motif de cymbale fermée en croches :
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 fonctionne parfaitement. Toutefois, si vous souhaitez modifier le tempo au milieu de ces deux mesures ou arrêter de jouer avant qu'elles ne soient terminées, vous n'avez pas de chance. (J'ai vu des développeurs insérer un nœud de gain entre leurs AudioBufferSourceNodes préprogrammés et la sortie, juste pour pouvoir couper le son de leurs propres sons !)
En résumé, comme vous aurez besoin de la flexibilité nécessaire pour modifier le tempo ou des paramètres tels que la fréquence ou le gain (ou arrêter complètement la planification), vous ne devez pas insérer trop d'événements audio dans la file d'attente. Plus précisément, vous ne devez pas trop anticiper, car vous pourriez vouloir modifier complètement cette planification.
The Worst of Times - the JavaScript Clock
Nous avons également notre horloge JavaScript très appréciée et très mal alignée, représentée par Date.now() et setTimeout(). Le bon côté de l'horloge JavaScript est qu'elle comporte deux méthodes très utiles de rappel window.setTimeout() et window.setInterval(), qui nous permettent de demander au système de rappeler notre code à des moments spécifiques.
L'inconvénient de l'horloge JavaScript est qu'elle n'est pas très précise. Pour commencer, Date.now() renvoie une valeur en millisecondes (un nombre entier de millisecondes). La meilleure précision que vous puissiez espérer est donc d'une milliseconde. Ce n'est pas si grave dans certains contextes musicaux. Si votre note a commencé une milliseconde trop tôt ou trop tard, vous ne le remarquerez peut-être même pas. Mais même à une fréquence matérielle audio relativement basse de 44,1 kHz, elle est environ 44,1 fois trop lente pour être utilisée comme horloge de planification audio. N'oubliez pas que le fait de supprimer des échantillons peut entraîner des glitchs audio. Par conséquent, si nous enchaînons des échantillons, nous devrons peut-être les organiser de manière séquentielle.
La spécification de l'heure haute résolution à venir nous donne en fait une heure actuelle beaucoup plus précise via window.performance.now(); elle est même implémentée (bien que préfixée) dans de nombreux navigateurs actuels. Cela peut être utile dans certaines situations, même si cela n'est pas vraiment pertinent pour le pire des aspects des API JavaScript Timing.
Le pire des API de temporisation JavaScript est que, bien que la précision de milliseconde de Date.now() ne semble pas trop difficile à gérer, le rappel réel des événements de minuteur en JavaScript (via window.setTimeout() ou window.setInterval) peut facilement être faussé de dizaines de millisecondes ou plus par la mise en page, le rendu, le nettoyage de la mémoire, XMLHTTPRequest et d'autres rappels, en bref, par un certain nombre de choses qui se produisent sur le thread d'exécution principal. Vous vous souvenez que j'ai mentionné les "événements audio" que nous pouvons planifier à l'aide de l'API Web Audio ? Ces éléments sont tous traités sur un thread distinct. Par conséquent, même si le thread principal est temporairement bloqué par une mise en page complexe ou une autre tâche longue, l'audio se produit toujours exactement au moment où il a été programmé. En fait, même si vous êtes arrêté à un point d'arrêt dans le débogueur, le thread audio continue de lire les événements planifiés.
Utiliser JavaScript setTimeout() dans les applications audio
Étant donné que le thread principal peut facilement être bloqué pendant plusieurs millisecondes à la fois, il est déconseillé d'utiliser setTimeout de JavaScript pour commencer directement à lire des événements audio. Dans le meilleur des cas, vos notes se déclencheront dans les millisecondes qui suivent le moment où elles devraient vraiment se déclencher, et dans le pire des cas, elles seront retardées encore plus longtemps. Pire encore, les séquences devraient être rythmées : elles ne se déclencheront pas à intervalles précis, car le timing sera sensible à d'autres événements survenant dans 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 une grande quantité de mise en page. Ouvrez cette application, cliquez sur "Lecture", puis redimensionnez rapidement la fenêtre pendant la lecture. Vous remarquerez que le timing est très instable (vous pouvez entendre que le rythme n'est pas constant). "Mais c'est truqué !", dites-vous ? Bien sûr, mais cela ne signifie pas que cela ne se produit pas dans le monde réel. Même une interface utilisateur relativement statique présente des problèmes de synchronisation dans setTimeout dus aux remises en page. Par exemple, j'ai remarqué que le redimensionnement rapide de la fenêtre entraîne des saccades notables pour le WebkitSynth, qui est par ailleurs excellent. Imaginez maintenant ce qui se passe lorsque vous essayez de faire défiler une partition musicale complète en douceur avec votre audio. Vous pouvez facilement imaginer l'impact de ce phénomène sur les applications musicales complexes dans le monde réel.
L'une des questions les plus fréquentes que j'entends est "Pourquoi ne puis-je pas obtenir de rappels à partir d'événements audio ?". Bien que ces types de rappels puissent être utiles, 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 retards potentiels que setTimeout. Autrement dit, ils pourraient être retardés d'un nombre inconnu et variable de millisecondes à partir de l'heure exacte à laquelle ils ont été planifiés avant d'être réellement traités.
Que pouvons-nous faire ? Le meilleur moyen de gérer la synchronisation consiste à configurer une collaboration entre les minuteurs JavaScript (setTimeout(), setInterval() ou requestAnimationFrame() – nous y reviendrons plus tard) et la planification matérielle audio.
Obtention d'un timing solide en anticipant
Revenons à la démonstration du métronome. En fait, j'ai correctement écrit la première version de cette simple démonstration de métronome pour illustrer cette technique de planification collaborative. (Le code est également disponible sur GitHub) Cette démo diffuse des bips (générés par un oscillateur) avec une haute précision sur toutes les sixième, huitième ou quarts, en modifiant la hauteur en fonction du rythme. Vous pouvez également modifier le tempo et l'intervalle des notes pendant la lecture, ou arrêter la lecture à tout moment, ce qui est une fonctionnalité clé pour tout séquenceur rythmique du monde réel. Il serait assez facile d'ajouter du code pour modifier les sons utilisés par ce métronome instantanément.
La façon dont il parvient à permettre le contrôle de la température tout en maintenant un timing précis est une collaboration : un minuteur setTimeout qui se déclenche de temps en temps et configure la planification Web Audio à l'avenir pour des notes individuelles. Le minuteur setTimeout sert simplement à vérifier si des notes doivent être programmées "bientôt" en fonction du tempo actuel, puis les programme comme suit:
En pratique, les appels setTimeout() peuvent être retardés. Par conséquent, le calendrier des appels de planification peut fluctuer (et être déformé, 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 d'intervalle, ils sont souvent légèrement plus espacés (et parfois beaucoup plus). Toutefois, à chaque appel, nous planifions des événements Web Audio non seulement pour les notes qui doivent être jouées maintenant (par exemple, la toute première note), mais aussi pour les notes qui doivent être jouées entre maintenant et l'intervalle suivant.
En fait, nous ne voulons pas simplement regarder en avant avec l'intervalle exact entre les appels setTimeout() : nous avons également besoin d'un chevauchement de planification entre cet appel de minuteur et le suivant, afin de tenir compte du pire comportement du thread principal, c'est-à-dire du pire cas de récupération de mémoire, de mise en page, de rendu ou d'autre code sur le thread principal qui retarde notre prochain appel de minuteur. Nous devons également tenir compte de la durée de programmation des blocs audio, c'est-à-dire de la quantité de données audio que le système d'exploitation conserve dans son tampon de traitement. Cette durée varie selon les systèmes d'exploitation et le matériel, allant d'une valeur de 0 à 50 ms à environ 50 ms. Chaque appel setTimeout() présenté ci-dessus est associé à un intervalle bleu indiquant l'ensemble des périodes au cours desquelles il tente 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 le prochain appel setTimeout, à moins que cet appel setTimeout ne se produise que quelques millisecondes. Dans la pratique, le jitter à ces périodes peut être encore plus extrême, et ce chevauchement devient encore plus important à mesure que votre application devient plus complexe.
La latence de prévisualisation globale affecte la précision de la commande du tempo (et d'autres commandes en temps réel). L'intervalle entre les appels de planification est un compromis entre la latence minimale et la fréquence à laquelle votre code affecte le processeur. Le degré de chevauchement de l'anticipation avec l'heure de début de l'intervalle suivant détermine la résilience de votre application sur différentes machines et à mesure qu'elle devient plus complexe (la mise en page et le garbage collection peuvent prendre plus de temps). En règle générale, pour être résilient face aux machines et systèmes d'exploitation plus lents, il est préférable d'avoir un lookahead global important et un intervalle raisonnablement court. Vous pouvez ajuster les chevauchements et les intervalles pour avoir moins de rappels à traiter, mais à un moment donné, vous constaterez peut-être que les changements de tempo, etc., ne prennent pas immédiatement effet en raison d'une latence élevée. À l'inverse, si vous avez trop réduit l'anticipation, vous risquez d'entendre des à-coups (car un appel de planification peut être amené à "créer" des événements qui auraient dû se produire dans le passé).
Le diagramme temporel suivant montre ce que fait réellement le code de démonstration du métronome : il a un intervalle setTimeout de 25 ms, mais une superposition beaucoup plus résiliente : chaque appel est planifié pour les 100 ms suivants. L'inconvénient de cette longue anticipation est que les changements de tempo, etc., mettront un dixième de seconde à prendre effet. Toutefois, nous sommes beaucoup plus résilients aux interruptions :
En fait, vous pouvez constater dans cet exemple qu'une interruption setTimeout s'est produite au milieu. Nous aurions dû avoir un rappel setTimeout à environ 270 ms, mais il a été retardé pour une raison quelconque jusqu'à environ 320 ms, soit 50 ms plus tard que prévu. Cependant, la grande latence de prévisualisation a permis de maintenir le timing sans problème, et nous n'avons pas manqué un seul battement, même si nous avons augmenté le tempo juste avant pour jouer des croches à 240 BPM (au-delà même des tempos de batterie et de basse hardcore !).
Il est également possible que chaque appel de planificateur finisse par planifier plusieurs notes. Voyons ce qui se passe si nous utilisons un intervalle de planification plus long (250 ms d'avance, espacés de 200 ms) et une augmentation du tempo au milieu :
Cet exemple montre que chaque appel setTimeout() peut finir par planifier plusieurs événements audio. En fait, ce métronome est une application simple qui joue 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 avoir des intervalles non réguliers entre les notes).
Dans la pratique, vous devez ajuster l'intervalle de planification et l'analyse anticipée pour voir à quel point ils sont affectés par la mise en page, la récupération de mémoire et d'autres éléments du thread d'exécution JavaScript principal, et pour ajuster la précision du contrôle du tempo, etc. Si vous utilisez une mise en page très complexe qui se produit fréquemment, par exemple, vous voudrez probablement agrandir l'apparence. L'idée est que la quantité de "planification à l'avance" que nous effectuons soit suffisamment importante pour éviter tout retard, mais pas trop pour créer un retard perceptible 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ésilient sur une machine lente avec une application Web complexe. Un bon point de départ est probablement une durée d'anticipation de 100 ms, avec des intervalles définis sur 25 ms. Cela peut toutefois poser problème dans les applications complexes sur des machines présentant une latence système audio importante. Dans ce cas, vous devez augmenter la durée d'anticipation. Si vous avez besoin d'un contrôle plus strict avec une perte de résilience, utilisez une durée d'anticipation plus courte.
Le code principal du processus de planification se trouve dans la fonction scheduler() :
while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
scheduleNote( current16thNote, nextNoteTime );
nextNote();
}
Cette fonction ne récupère que l'heure actuelle du matériel audio et la compare à l'heure de la prochaine note de la séquence. Dans la plupart des cas*, dans ce scénario précis, elle ne fait rien (car il n'y a pas de "notes" de métronome en attente d'être planifiées), mais si elle réussit, elle planifiera cette note à l'aide de l'API Web Audio et passera à la note suivante.
La fonction scheduleNote() est chargée de planifier la prochaine "note" Web Audio à lire. Dans ce cas, j'ai utilisé des oscillateurs pour créer des bips à différentes fréquences. Vous pouvez 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 ces oscillateurs programmés et connectés, ce code peut les oublier complètement. Ils démarrent, s'arrêtent, puis sont automatiquement collectés.
La méthode nextNote() est chargée de passer à la prochaine croche, 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, mais il est important de comprendre que dans cet exemple de planification, je ne tiens pas compte du "temps de séquence", c'est-à-dire du temps écoulé depuis le début du métronome. Tout ce que nous avons à faire, c'est de se souvenir de la date à laquelle nous avons joué la dernière note et de déterminer quand la note suivante doit être jouée. Cela nous permet de modifier le tempo (ou d'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 boîte à rythmes Web Audio, le très amusant jeu Acid Defender et des exemples audio plus détaillés comme la démonstration Granular Effects.
Yet Another Timing System
Comme tout bon musicien le sait, chaque application audio a besoin de plus de cowbell, euh, de plus de minuteurs. Il est important de noter que la meilleure façon d'afficher des images consiste à utiliser un TIERS système de chronométrage !
Pourquoi, pourquoi, pourquoi avons-nous besoin d'un autre système de chronométrage ? Celui-ci est synchronisé avec l'affichage visuel (c'est-à-dire la fréquence d'actualisation des graphiques) via l'API requestAnimationFrame. Pour dessiner des rectangles dans notre exemple de métronome, cela peut ne pas sembler 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 se synchroniser avec la fréquence d'actualisation visuelle. De plus, cette méthode est tout aussi simple à utiliser dès le début que setTimeout(). Avec des graphiques synchronisés très complexes (par exemple, l'affichage précis de notes musicales denses lorsqu'elles sont jouées dans un package de notation musicale), requestAnimationFrame() vous offrira la synchronisation graphique et audio la plus fluide et la plus précise.
Nous avons suivi les beats dans la file d'attente du planificateur:
notesInQueue.push( { note: beatNumber, time: time } );
L'interaction avec l'heure actuelle de notre métronome se trouve dans la méthode draw(), qui est appelée (à l'aide de requestAnimationFrame) chaque fois que le système graphique est prêt à être mis à jour :
var currentTime = audioContext.currentTime;
while (notesInQueue.length && notesInQueue[0].time < currentTime) {
currentNote = notesInQueue[0].note;
notesInQueue.splice(0,1); // remove note from queue
}
Encore une fois, vous remarquerez que nous vérifions l'horloge du système audio (car c'est celle avec laquelle nous voulons nous synchroniser, car elle jouera les notes) pour voir si nous devons dessiner une nouvelle boîte ou non. En fait, nous n'utilisons pas vraiment les codes temporels requestAnimationFrame, car nous utilisons l'horloge du système audio pour déterminer où nous nous trouvons dans le temps.
Bien sûr, j'aurais pu simplement ne pas utiliser de rappel setTimeout() et placer mon planificateur de notes dans le rappel requestAnimationFrame. Nous serions alors de nouveau limités à deux minuteurs. Vous pouvez également le faire, mais il est important de comprendre que requestAnimationFrame n'est qu'un substitut de setTimeout() dans ce cas. Vous aurez toujours besoin de la précision de planification du timing Web Audio pour les notes réelles.
Conclusion
J'espère que ce tutoriel vous a permis de comprendre les horloges et les minuteurs, et de créer un excellent timing dans les applications audio Web. Ces mêmes techniques peuvent être extrapolées facilement pour créer des lecteurs de séquences, des boîtes à rythmes, etc. À bientôt…