Eine Geschichte über zwei Uhren

Web-Audio mit Präzision planen

Chris Wilson
Chris Wilson

Einleitung

Eine der größten Herausforderungen beim Entwickeln großartiger Audio- und Musiksoftware mithilfe der Webplattform ist das Zeitmanagement. Nicht wie in der Zeit, um Code zu schreiben, sondern wie bei der Uhrzeit. Eines der am wenigsten bekannten Themen von Web Audio ist die korrekte Funktionsweise der Audiouhr. Das Web Audio AudioContext-Objekt hat eine currentTime-Eigenschaft, die diese Audiouhr bereitstellt.

Das gilt insbesondere für musikalische Anwendungen von Web-Audio – nicht nur für das Schreiben von Sequencern und Synthesizern, sondern auch für die rhythmische Verwendung von Audioereignissen wie Drumcomputern, Spielen und anderen Anwendungen. Dabei ist es sehr wichtig, dass die Audioereignisse konsistent und genau terminiert sind. Es ist nicht nur wichtig, dass die Töne gestartet und beendet werden, sondern auch Änderungen am Ton (z. B. Änderung der Frequenz oder Lautstärke) planen. Manchmal ist es wünschenswert, leicht zeitzufällige Ereignisse zu verwenden, wie z. B. in der Maschinengewehr-Demo in Entwicklung von Spiele-Audio mit der Web Audio API. In der Regel möchten wir aber ein einheitliches und präzises Timing für Musiknoten haben.

Wir haben Ihnen bereits unter Erste Schritte mit Web Audio und unter Entwicklung von Spiele-Audio mit der Web Audio API gezeigt, wie Sie Noten mit den Zeitparametern der Web Audio-Methoden „notOn“ und „noteOff“ (jetzt „Start“ und „Stopp“ umbenannt) planen. Komplexere Szenarien wie das Abspielen langer Musiksequenzen oder Rhythmen haben wir jedoch nicht weiter erörtert. Zunächst benötigen wir ein paar Hintergrundinformationen zu den Uhren.

Die besten Zeiten – die Audio-Uhr im Web

Die Web Audio API macht Zugriff auf die Hardwareuhr des Audiosubsystems. Diese Uhr wird im AudioContext-Objekt über seine .currentTime-Eigenschaft als Gleitkommazahl in Sekunden seit Erstellung des AudioContext angezeigt. Dadurch ist dieser Takt (im Folgenden als „Audiouhr“ bezeichnet) sehr hochpräzis. Er ist darauf ausgelegt, die Ausrichtung auf individuelle Klang-Sampleebene selbst bei einer hohen Abtastrate festzulegen. Da in einer „Doppelten“ eine Genauigkeit von etwa 15 Dezimalstellen besteht, sollten auch dann noch viele Bits übrig bleiben, die auf ein bestimmtes Sample verweisen, selbst bei einer hohen Abtastrate.

Die Audiouhr wird zur Planung von Parametern und Audioereignissen in der gesamten Web Audio API verwendet – natürlich für start() und stop(), aber auch für set*ValueAtTime()-Methoden in AudioParams. So können Audioereignisse mit sehr präziser Zeit im Voraus eingerichtet werden. Es ist sogar verlockend, in Web Audio einfach alles als Start- und Stoppzeiten einzurichten. In der Praxis gibt es jedoch ein Problem damit.

Sehen Sie sich zum Beispiel dieses reduzierte Code-Snippet aus unserer Web Audio Intro an, das zwei Takte eines Hi-Hat-Musters mit Achtelnoten einsetzt:

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

Dieser Code wird funktionieren. Wenn du jedoch das Tempo in der Mitte der beiden Takte ändern oder die Wiedergabe stoppen möchtest, bevor die beiden Takte aufsteigen, hast du Pech gehabt. (Ich habe gesehen, wie Entwickler einen Verstärkungsknoten zwischen ihren vorab geplanten AudioBufferSourceNodes und dem Ausgang einfügen, um so ihre eigenen Töne stummzuschalten.)

Kurz gesagt: Du brauchst die Flexibilität, Tempo oder Parameter wie Frequenz oder Verstärkung zu ändern (oder die Planung komplett zu beenden). Du solltest nicht zu viele Audioereignisse in die Wiedergabeliste verschieben oder, noch genauer, nicht zu weit vorausschauen, da du diese Planung vielleicht komplett ändern möchtest.

Die schlechtesten Zeiten – die JavaScript-Uhr

Wir haben auch unsere beliebte und viel gebräuchliche JavaScript-Uhr, dargestellt durch Date.now() und setTimeout(). Die gute Seite der JavaScript-Uhr ist, dass sie einige sehr nützliche „call-me-back-later window.setTimeout()“- und window.setInterval()-Methoden hat, mit denen das System unseren Code zu bestimmten Zeiten zurückrufen kann.

Die schlechte Seite der JavaScript-Uhr ist, dass sie nicht sehr genau ist. Zunächst gibt Date.now() einen Wert in Millisekunden zurück (eine Ganzzahl in Millisekunden). Die höchste Genauigkeit, die Sie erwarten können, beträgt also eine Millisekunde. In manchen musikalischen Kontexten ist das gar nicht schlecht – wenn deine Note eine Millisekunde früh oder spät beginnt, wirst du es vielleicht gar nicht bemerken. Aber selbst bei einer relativ niedrigen Audiohardwarerate von 44,1 kHz ist sie etwa 44,1-mal zu langsam, um sie als Audioplanungsuhr zu verwenden. Wenn überhaupt Samples fallen, kann es zu Audiofehlern kommen. Wenn wir also Samples verketten, müssen sie unter Umständen genau aufeinanderfolgend sein.

Die neue Spezifikation für High Resolution Time liefert uns tatsächlich eine viel bessere Präzision für die aktuelle Uhrzeit über window.performance.now(). Sie ist sogar in vielen aktuellen Browsern implementiert (wenn auch mit Präfix). Das kann in einigen Situationen hilfreich sein, ist aber für den schlechtesten Teil der JavaScript-Timing-APIs nicht wirklich relevant.

Das Schlimmste bei den JavaScript-Timing-APIs ist, dass der tatsächliche Callback von Timer-Ereignissen in JavaScript (über window.setTimeout() oder window.setInterval) leicht durch Layout, Rendering, automatische Speicherbereinigung, XMLHTTPRequest und andere Callbacks durch beliebige Hauptthreads verzerrt werden kann. Erinnern Sie sich, wie ich „Audioereignisse“ erwähnt habe, die wir mit der Web Audio API planen könnten? Diese werden alle in einem separaten Thread verarbeitet. Selbst wenn der Hauptthread aufgrund eines komplexen Layouts oder einer anderen langen Aufgabe vorübergehend nicht reagiert, wird die Audioausgabe immer noch genau zu dem Zeitpunkt abgespielt, an dem sie ausgelöst wurden. Tatsächlich werden geplante Ereignisse auch dann weiter abgespielt, wenn Sie an einem Haltepunkt im Debugger angehalten werden.

Die JavaScript-Funktion „setTimeout()“ in Audio-Apps verwenden

Da der Hauptthread leicht für mehrere Millisekunden auf einmal angehalten werden kann, ist es nicht empfehlenswert, die JavaScript-Funktion „setTimeout“ zu verwenden, um die Wiedergabe von Audioereignissen direkt zu starten. Im Idealfall werden Ihre Noten innerhalb von etwa einer Millisekunde ausgelöst, wenn sie eigentlich sollten, im schlimmsten Fall sogar noch länger. Und am schlimmsten ist, dass sie bei rhythmischen Sequenzen nicht in genauen Intervallen ausgelöst werden, da das Timing empfindlich auf andere Dinge im JavaScript-Hauptthread reagiert.

Um dies zu demonstrieren, habe ich eine „schlechte“ Metronomanwendung geschrieben – eine, die mit „setTimeout“ direkt Notizen einplant – und außerdem umfangreiches Layout bietet. Öffne diese App, klicke auf „Wiedergabe“ und passe die Größe des Fensters während der Wiedergabe schnell an. Du wirst feststellen, dass das Timing spürbar zittert (der Rhythmus bleibt nicht gleichmäßig). „Aber das ist erfunden“, sagen Sie? Nun, natürlich – aber das heißt aber nicht, dass das nicht auch in der realen Welt passiert. Selbst bei einer relativ statischen Benutzeroberfläche treten aufgrund von Layout-Layouts Zeitprobleme bei „setTimeout“ auf. Beispielsweise habe ich festgestellt, dass eine schnelle Größenanpassung des Fensters dazu führt, dass das Timing des ansonsten hervorragenden WebkitSynth merklich stottert. Stellen Sie sich jetzt vor, was passiert, wenn Sie versuchen, die gesamte Partitur zusammen mit den Audioinhalten anzugleichen, und Sie können sich leicht vorstellen, wie sich dies auf komplexe Musik-Apps in der realen Welt auswirken würde.

Eine der am häufigsten gestellten Fragen lautet: „Warum kann ich keine Rückrufe von Audioereignissen erhalten?“ Obwohl diese Arten von Callbacks Verwendungszwecke haben, können sie das vorliegende Problem nicht lösen – es ist wichtig zu verstehen, dass diese Ereignisse im Haupt-JavaScript-Thread ausgelöst werden, sodass sie denselben potenziellen Verzögerungen wie bei der festgelegten Anzahl von Millisekunden entsprechen würden.

Was können wir also tun? Die beste Methode für den Umgang mit dem Timing besteht darin, eine Zusammenarbeit zwischen JavaScript-Timern (setTimeout(), setInterval() oder requestAnimationFrame() – mehr dazu später) und der Audiohardware-Planung einzurichten.

Mit Blick auf die Zukunft ein solides Timing erreichen

Kehren wir zu dieser Metronom-Demo zurück. Tatsächlich habe ich die erste Version dieser einfachen Metronom-Demo korrekt geschrieben, um diese kollaborative Planungstechnik zu demonstrieren. Der Code ist auch auf GitHub verfügbar. In dieser Demo werden von einem Oszillator erzeugte Pieptöne mit hoher Präzision bei jeder Sechzehntel, Achtel oder Viertelnote abgespielt, wobei die Tonhöhe je nach Takt verändert wird. Du kannst auch während der Wiedergabe das Tempo und das Notenintervall ändern oder die Wiedergabe jederzeit anhalten – eine wichtige Funktion für echte Sequenzer. Es wäre ziemlich einfach, Code hinzuzufügen, um die Töne dieses Metronoms auch spontan zu ändern.

Die Methode, die es ermöglicht, die Temperaturregelung zu ermöglichen und gleichzeitig ein konstantes Timing beizubehalten, ist eine Zusammenarbeit: ein setTimeout-Timer, der einmalig ausgelöst wird, und die spätere Planung einzelner Notizen über Web Audio ermöglicht. Der Timer „setTimeout“ prüft im Grunde, ob er auf Grundlage des aktuellen Tempos „bald“ eingeplant werden muss, und plant diese dann wie folgt:

„setTimeout()“ und die Interaktivität des Audioereignisses.
setTimeout() und Interaktion des Audioereignisses.

In der Praxis können setTimeout()-Aufrufe mit der Zeit verzögert werden. Das Timing der Planungsaufrufe kann sich also abhängig von der Verwendung von „setTimeout“ verzerren (und verzerrt sein). Auch wenn die Ereignisse in diesem Beispiel im Abstand von etwa 50 ms ausgelöst werden, sind sie häufig etwas größer (und manchmal auch sehr viel). Für jeden Anruf planen wir Web Audio-Ereignisse nicht nur für Noten, die jetzt abgespielt werden sollen (z.B. die erste Note), sondern auch für Noten, die bis zum nächsten Intervall gespielt werden müssen.

Wir möchten nicht einfach nur das genaue Intervall zwischen den „setTimeout()“-Aufrufen vorausschauen. Wir benötigen auch einige Planungsüberschneidungen zwischen diesem und dem nächsten Timer-Aufruf, um das schlechteste Verhalten des Hauptthreads zu berücksichtigen – d. h. der schlimmste Fall einer automatischen Speicherbereinigung, des Layouts, des Renderings oder anderer Codes im Hauptthread, die unseren nächsten Timer-Aufruf verzögern. Wir müssen auch die Planungszeit für Audioblocks berücksichtigen, d. h. wie viel Audio im Verarbeitungspuffer des Betriebssystems aufbewahrt wird. Dieser Wert variiert je nach Betriebssystem und Hardware, von niedrigen einstelligen Millisekundenbereich bis hin zu etwa 50 ms. Jeder oben gezeigte „setTimeout()“-Aufruf hat ein blaues Intervall, das den gesamten Zeitraum zeigt, in dem Ereignisse geplant werden sollen. So könnte beispielsweise das vierte Web-Audio-Ereignis im obigen Diagramm „verspätet“ abgespielt worden sein, wenn wir bis zum nächsten „setTimeout“-Aufruf gewartet hätten, wenn dieser „setTimeout“-Aufruf nur wenige Millisekunden später erfolgte. In der Praxis kann der Jitter in diesen Zeiten noch größer sein. Diese Überschneidung wird umso wichtiger, je komplexer Ihre App wird.

Die allgemeine Lookahead-Latenz beeinflusst, wie eng die Temposteuerung (und andere Echtzeitsteuerelemente) sein können. Das Intervall zwischen der Planung von Aufrufen ist ein Kompromiss zwischen der Mindestlatenz und der Häufigkeit, mit der Ihr Code den Prozessor beeinflusst. Wie stark sich der Lookahead mit der Startzeit des nächsten Intervalls überschneidet, bestimmt, wie stabil Ihre App auf verschiedenen Computern ist. Außerdem wird die Komplexität der App mit zunehmender Komplexität berücksichtigt (und das Layout und die automatische Speicherbereinigung können länger dauern). Im Allgemeinen ist es am besten, einen umfassenden Überblick und ein relativ kurzes Intervall zu haben, um auch auf langsameren Computern und Betriebssystemen robust zu sein. Sie können sich auch auf kürzere Überlappungen und längere Intervalle einstellen, um weniger Callbacks zu verarbeiten. Irgendwann hören Sie jedoch möglicherweise, dass eine hohe Latenz z. B. Geschwindigkeitsänderungen usw. verursacht, aber nicht sofort wirksam werden. Umgekehrt kann es zu Flimmern kommen, wenn ein Planungsanruf in der Vergangenheit zu „Make-up“-Ereignissen kommt.

Das folgende Zeitdiagramm zeigt, was der Metronom-Democode tatsächlich tut: Er hat ein setTimeout-Intervall von 25 ms, aber eine viel stabilere Überschneidung: Jeder Aufruf wird für die nächsten 100 ms geplant. Der Nachteil dieses langen Vorblicks ist, dass es bei Geschwindigkeitsänderungen usw. eine Zehntelsekunde dauert, bis sie wirksam werden. Unterbrechungen sind jedoch wesentlich widerstandsfähiger:

Planung mit langen Überlappungen.
Planung mit langen Überschneidungen

Sie sehen in diesem Beispiel, dass in der Mitte eine „setTimeout“-Unterbrechung aufgetreten ist: Wir hätten einen „setTimeout“-Callback bei etwa 270 ms haben sollen, aber dieser wurde aus irgendeinem Grund bis etwa 320 ms bis 50 ms später als erwartet verzögert. Die große Lookahead-Latenz hielt das Timing jedoch ohne Probleme aufrecht und wir haben keinen Takt verpasst, obwohl wir das Tempo kurz davor auf Sechzehntel mit 240 Schlägen pro Minute erhöht hatten (über die Hardcore-Drum-&-Bass-Tempi hinaus!)

Es ist auch möglich, dass für jeden Aufruf des Planers mehrere Notizen geplant werden. Sehen wir uns einmal an, was passiert, wenn wir ein längeres Planungsintervall (250 ms Voraussicht, 200 ms Abstand) und eine Tempoerhöhung in der Mitte verwenden:

setTimeout() mit langem Lookahead und langen Intervallen.
setTimeout() mit langem Lookahead und langen Intervallen

Dieses Beispiel zeigt, dass jeder „setTimeout()“-Aufruf am Ende mehrere Audioereignisse planen kann. Tatsächlich ist dieses Metronom eine einfache Anwendung mit nur einer Note, aber Sie können leicht erkennen, wie dieser Ansatz für einen Drumcomputer (mit häufig mehreren gleichzeitigen Noten) oder einen Sequencer (mit häufigen ungleichmäßigen Intervallen zwischen den Noten) funktioniert.

In der Praxis empfiehlt es sich, das Planungsintervall und den Lookahead anzupassen, um zu sehen, wie sich das auf das Layout, die automatische Speicherbereinigung und andere Vorgänge im Haupt-JavaScript-Ausführungsthread auswirkt. Außerdem können Sie beispielsweise die Genauigkeit der Temposteuerung anpassen. Wichtig ist, dass die geplante Planung groß genug ist, um Verzögerungen zu vermeiden, aber nicht so groß, dass bei der Anpassung der Temposteuerung eine deutliche Verzögerung erzeugt wird. Selbst der obige Fall hat eine sehr geringe Überschneidung, sodass er auf einem langsamen Computer mit einer komplexen Webanwendung nicht sehr robust ist. Ein guter Ausgangspunkt sind 100 ms „Lookahead“ mit Intervallen von 25 ms. Dies kann bei komplexen Anwendungen auf Computern mit einer hohen Latenz von Audiosystemen immer noch zu Problemen führen. In diesem Fall sollten Sie die Lookahead-Zeit erhöhen oder einen kürzeren Lookahead verwenden, wenn Sie eine stärkere Kontrolle mit einem Verlust an Ausfallsicherheit benötigen.

Der Kerncode des Planungsprozesses befindet sich in der Funktion Scheduler():

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

Diese Funktion ruft nur die aktuelle Audiohardware-Zeit ab und vergleicht sie mit der Zeit für die nächste Note in der Sequenz. In diesem Fall hat dies meistens* keine Wirkung, da keine Metronom-Noten auf die Planung warten. Wenn es erfolgreich ist, wird diese Note jedoch mithilfe der Web Audio API geplant und zur nächsten Note gewechselt.

Die Funktion „scheduleNote()“ ist für die tatsächliche Planung der nächsten Web Audio-„Notiz“ verantwortlich, die abgespielt werden soll. In diesem Fall habe ich Oszillatoren verwendet, um Pieptöne in verschiedenen Frequenzen zu erzeugen. Sie können genauso einfach AudioBufferSource-Knoten erstellen und deren Zwischenspeicher auf Trommel- oder andere Töne einstellen.

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

Sobald diese Oszillatoren geplant und verbunden sind, kann dieser Code sie vollständig vergessen. Sie starten, stoppen dann den Betrieb und werden dann automatisch bereinigt.

Die Methode nextNote() ist dafür verantwortlich, zur nächsten sechzehnten Note überzugehen, d. h., die Variablen "nextNoteTime" und "current16thNote" auf die nächste Notiz zu setzen:

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

Das ist ziemlich einfach – obwohl es wichtig zu verstehen ist, dass ich in diesem Planungsbeispiel nicht die „Sequenzzeit“ verfolge, d. h. die Zeit seit Beginn des Metronoms. Wir müssen uns nur merken, wann wir die letzte Note gespielt haben und herausfinden, wann die nächste Note gespielt wird. Auf diese Weise können wir das Tempo leicht ändern (oder die Wiedergabe stoppen).

Diese Planungsmethode wird von einer Reihe anderer Audioanwendungen im Web verwendet, z. B. die Web Audio Drum Machine, das unterhaltsame Acid Defender-Spiel und noch detailliertere Audiobeispiele wie die Demo zu Granular Effects.

Noch ein anderes Timing-System

Wie jeder gute Musiker weiß, braucht jede Audio-App mehr Kuhglocke – äh mehr Timer. Es muss erwähnt werden, dass zur richtigen Darstellung ein THIRD-Zeitsystem verwendet wird.

Warum, warum, oh je?, warum brauchen wir noch ein anderes Zeitsystem? Diese wird über die requestAnimationFrame API mit der visuellen Darstellung synchronisiert, d. h. mit der Aktualisierungsrate der Grafik. Für die Zeichenfelder in unserem Metronom-Beispiel mag das keine große Sache sein, aber je komplexer Ihre Grafiken werden, desto wichtiger wird es, requestAnimationFrame() zur Synchronisierung mit der visuellen Aktualisierungsrate zu verwenden – und dies ist von Anfang an genauso einfach zu verwenden wie die Verwendung von setTimeout()! Bei sehr komplexen synchronisierten Grafiken (z.B. der präzisen Darstellung der dichten Synchronisierungsgrafiken, der meisten Animationen und der präzisesten Synchronisierungs-Musiknotationen, die Sie in einer musikalischen Animation und besonders präzisen Musiknotation abspielen), wird es immer wichtiger.

Im Planer haben wir die Beats in der Warteschlange erfasst:

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

Die Interaktion mit der aktuellen Zeit unseres Metronoms ist in der Methodedraw() zu finden, die (mithilfe von requestAnimationFrame) aufgerufen wird, wenn das Grafiksystem für eine Aktualisierung bereit ist:

var currentTime = audioContext.currentTime;

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

Wie Sie sehen, überprüfen wir die Uhr des Audiosystems, um zu sehen, ob wir ein neues Feld zeichnen sollten. Das ist nämlich die, mit der wir synchronisieren wollen, da es die Noten spielt. Wir verwenden die requestAnimationFrame-Zeitstempel gar nicht, da wir anhand der Uhr des Audiosystems herausfinden, wo wir uns gerade befinden.

Natürlich hätte ich einfach die Verwendung eines setTimeout()-Callbacks komplett überspringen und meinen Notizen-Planer in den requestAnimationFrame-Callback einfügen können – dann wären wir wieder auf zwei Timer gesetzt. Das ist auch in Ordnung, aber es ist wichtig zu verstehen, dass requestAnimationFrame in diesem Fall nur ein Ersatz für „setTimeout()“ ist. Sie möchten trotzdem die Planungsgenauigkeit des Web Audio-Timings für die tatsächlichen Notizen.

Fazit

Ich hoffe, dass Ihnen dieses Tutorial bei der Erläuterung von Uhren, Timern und der Integration von großartigem Timing in Web-Audio-Anwendungen weitergeholfen hat. Dieselben Techniken lassen sich leicht für den Bau von Sequenzspielern, Drumcomputern und anderen Elementen übertragen. Bis zum nächsten Mal...