Eine Geschichte über zwei Uhren

Web-Audio präzise planen

Chris Wilson
Chris Wilson

Einleitung

Eine der größten Herausforderungen bei der Entwicklung einer erstklassigen Audio- und Musiksoftware mithilfe der Webplattform ist das Zeitmanagement. Eines der am wenigsten verständlichen Themen bei Web Audio ist die korrekte Funktionsweise der Audiouhr. Das Web Audio AudioContext-Objekt hat eine currentTime-Eigenschaft, die diese Audiouhr anzeigt.

Besonders bei musikalischen Anwendungen von Webaudio ist es wichtig, Audioereignisse möglichst genau und stimmig zu starten. Sie sollten nicht nur Töne starten und anhalten, sondern auch Änderungen am Ton (z. B. Änderung der Frequenz oder Lautstärke) planen. Manchmal ist es wünschenswert, Ereignisse mit etwas zeitlich zufälliger Reihenfolge zu verwenden, z. B. bei der Demo zu Maschinengewehren in Audio für ein Spiel mit der Web Audio API entwickeln. In der Regel möchten wir jedoch ein konsistentes und genaues Timing für Musiknoten haben.

In Erste Schritte mit Web Audio und Spiel-Audio entwickeln mit der Web Audio API haben wir Ihnen bereits gezeigt, wie Sie mithilfe der Web Audio-Methoden „noteOn“ und „noteOff“ (jetzt „start“ und „stop“) die Zeitparameter der Methode planen. Komplexere Szenarien wie das Abspielen langer Musiksequenzen oder Rhythmen haben wir jedoch noch nicht näher betrachtet. Dazu benötigen wir zunächst ein wenig Hintergrundwissen zu Uhren.

The Best of Times – Web Audio Clock

Das Web Audio API ermöglicht Zugriff auf die Hardwareuhr des Audio-Subsystems. Diese Uhr wird im AudioContext-Objekt durch seine .currentTime-Eigenschaft als Gleitkommazahl in Sekunden seit der Erstellung des AudioContext angegeben. Dadurch kann dieser Takt (im Folgenden als „Audio-Clock“ bezeichnet) sehr genau sein. Er ist so konzipiert, dass er auch bei einer hohen Abtastrate die Ausrichtung auf einer einzelnen Tonabtastrate spezifizieren kann. Da ein „Double“ eine Genauigkeit von etwa 15 Dezimalstellen hat, sollte die Audiouhr auch bei hoher Abtastrate noch genügend Bits übrig haben, um auf ein bestimmtes Sample zu verweisen, selbst wenn die Audiouhr tagelang läuft.

Die Audiouhr wird in der gesamten Web Audio API für Planungsparameter und Audioereignisse verwendet – natürlich für start() und stop(), aber auch für set*ValueAtTime() auf AudioParams. So können wir Audioereignisse sehr genau zeitlich im Voraus planen. Es ist verlockend, einfach alles in Web Audio als Start-/Stopp-Zeiten festzulegen. In der Praxis gibt es jedoch ein Problem damit.

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

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 ist sehr gut geeignet. Wenn du jedoch das Tempo in der Mitte dieser beiden Takte ändern oder aufhören willst, bevor die beiden Takte zu Ende sind, ist das kein Glück. (Ich habe gesehen, dass Entwickler Dinge wie einen Verstärkungsknoten zwischen ihre vorab geplanten AudioBufferSourceNodes und die Ausgabe einfügen, nur um ihre eigenen Sounds stummzuschalten.)

Kurz gesagt: Da du die Flexibilität benötigst, das Tempo oder Parameter wie die Frequenz oder Verstärkung zu ändern (oder die Planung vollständig zu beenden), solltest du nicht zu viele Audioereignisse in die Warteschlange verschieben – oder genauer gesagt nicht zu weit in die Zukunft schauen, da du diese Planung vielleicht komplett ändern möchtest.

The Worst of Times – die JavaScript-Uhr

Wir haben auch unsere beliebte und viel schlechtere JavaScript-Uhr, dargestellt durch Date.now() und setTimeout(). Das Gute an der JavaScript-Uhr ist, dass sie über einige sehr nützliche Callme-back-later window.setTimeout()- und window.setInterval() -Methoden verfügt, mit denen das System unseren Code zu bestimmten Zeiten zurückrufen kann.

Das Nachteil der JavaScript-Uhr ist, dass sie nicht sehr genau ist. Für den Anfang gibt Date.now() einen Wert in Millisekunden zurück, d. h. eine ganze Zahl von Millisekunden. Die höchste Genauigkeit, die Sie sich auszahlen können, beträgt also eine Millisekunde. In manchen musikalischen Kontexten ist das nicht schlimm – wenn deine Note eine Millisekunde zu früh oder zu spät beginnt, fällt dir vielleicht gar nichts auf. Aber selbst bei einer relativ niedrigen Audiohardware-Rate von 44,1 kHz ist es etwa 44,1-mal zu langsam, um als Takt für die Audioplanung verwendet zu werden. Denken Sie daran, dass das Weglassen von Samples zu Audiofehlern führen kann. Wenn Sie Samples also verketten, müssen diese unter Umständen genau aufeinanderfolgen.

Die demnächst kommende Spezifikation für die Zeit bis zur hohen Auflösung bietet eine viel bessere Genauigkeit für die aktuelle Zeit über „window.performance.now()“. Sie ist sogar in vielen aktuellen Browsern implementiert (wenn auch mit Präfix). Dies kann in einigen Situationen helfen, obwohl es für den schlechtesten Teil der JavaScript Timing APIs nicht wirklich relevant ist.

Das Schlimmste an den JavaScript-Timing-APIs ist, dass die Genauigkeit von Date.now() zwar nicht allzu schlecht klingt, aber der tatsächliche Callback von Timer-Ereignissen in JavaScript (über window.setTimeout() oder window.setInterval) durch Layout, Rendering, Garbage Collection, XMLHTTPRequest und andere Callbacks, kurz gesagt, durch eine beliebige Anzahl von Callbacks, kann leicht um 10 Millisekunden oder mehr verzerrt werden. Weißt du noch, 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 bei einem komplexen Layout oder einer anderen langen Aufgabe vorübergehend angehalten wird, wird das Audio immer noch genau zu den Zeiten abgespielt, an denen sie angekündigt wurden. Selbst wenn Sie an einem Haltepunkt im Debugger angehalten werden, spielt der Audiothread weiterhin geplante Ereignisse ab.

JavaScript-Funktion setTimeout() in Audio-Apps verwenden

Da der Hauptthread leicht für mehrere Millisekunden gleichzeitig ins Stocken geraten kann, ist es nicht sinnvoll, die JavaScript-Funktion setTimeout zu verwenden, um Audioereignisse direkt wiederzugeben. Im Idealfall werden Ihre Notizen innerhalb von etwa einer Millisekunde nach der Erwartung ausgelöst, im schlimmsten Fall sogar noch länger. Das Schlimmste ist, dass rhythmische Sequenzen nicht in genauen Intervallen ausgelöst werden, da das Timing von anderen Dingen im JavaScript-Hauptthread abhängt.

Um dies zu demonstrieren, habe ich ein Beispiel für eine „schlechte“ Metronom-Anwendung geschrieben – eine, die direkt setTimeout zum Planen von Notizen verwendet und außerdem ein umfangreiches Layout hat. Öffne diese App, klicke auf „Wiedergabe“ und ändere schnell die Größe des Fensters, während es abgespielt wird. Du wirst feststellen, dass das Timing merklich wackelt (du hörst, dass der Rhythmus nicht konstant bleibt). „Aber das ist konstruiert!“ sagen Sie? Na ja, natürlich – aber das bedeutet nicht, dass das nicht auch in der realen Welt passiert. Selbst eine relativ statische Benutzeroberfläche wird in setTimeout aufgrund von Layoutänderungen zu Zeitproblemen führen. Ich habe zum Beispiel festgestellt, dass eine schnelle Größenanpassung des Fensters dazu führt, dass das Timing auf dem ansonsten hervorragenden WebkitSynth merklich ins Stocken gerät. Überlege dir jetzt, was passiert, wenn du versuchst, eine komplette Partitur zusammen mit dem Audio flüssig zu scrollen, und du kannst dir leicht vorstellen, wie sich dies auf komplexe Musik-Apps in der realen Welt auswirken würde.

Eine der häufigsten Fragen, die ich höre, lautet: „Warum kann ich keine Rückrufe von Audioereignissen erhalten?“ Auch wenn es für diese Art von Callbacks Verwendungsmöglichkeiten gibt, lässt sich das vorliegende Problem nicht lösen. Es ist wichtig zu verstehen, dass diese Ereignisse im Haupt-JavaScript-Thread ausgelöst werden. Daher würden sie denselben potenziellen Verzögerungen wie „setTimeout“ unterliegen, d. h., sie konnten erst nach der geplanten Zeit um einige Millisekunden und die angegebene Variable verarbeitet werden.

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

Ein solides Timing durch einen Blick in die Zukunft

Kehren wir zu dieser Metronom-Demo zurück. Tatsächlich habe ich die erste Version dieser einfachen Metronom-Demo korrekt geschrieben, um diese kooperative Planungstechnik richtig zu demonstrieren. (Der Code ist auch auf GitHub verfügbar) In dieser Demo werden von einem Oszillator erzeugte Pieptöne für jede Sechzehntel-, Achtel- oder Viertelnote mit hoher Präzision wiedergegeben und die Tonhöhe je nach Beat verändert. Außerdem kannst du das Tempo und das Notenintervall während des Abspielens ändern oder die Wiedergabe jederzeit stoppen – eine wichtige Funktion für jeden rhythmischen Sequenzer. Es wäre ziemlich einfach, Code hinzuzufügen, um auch die Töne dieses Metronoms während des Betriebs zu ändern.

Die Möglichkeit, die Temperaturregelung bei gleichbleibendem Timing zu ermöglichen, ist eine Zusammenarbeit: ein setTimeout-Timer, der einmalig ausgelöst wird und die spätere Web Audio-Planung für einzelne Notizen einrichtet. Der setTimeout-Timer prüft im Grunde, ob Notizen „bald“ basierend auf dem aktuellen Tempo geplant werden müssen, und plant sie dann so:

setTimeout() und die Interaktion mit dem Audioereignis.
Interaktion mit „setTimeout()“ und Audioereignis.

In der Praxis können setTimeout()-Aufrufe im Laufe der Zeit verzögert werden, sodass das Timing der Planungsaufrufe im Laufe der Zeit jittert (und sich verzerren kann, je nachdem, wie Sie setTimeout verwenden). Obwohl die Ereignisse in diesem Beispiel etwa 50 ms auseinanderliegen, werden sie häufig etwas länger (und manchmal sogar noch viel mehr) ausgelöst. Bei jedem Aufruf planen wir Web Audio-Ereignisse jedoch nicht nur für Noten, die sofort abgespielt werden müssen (z.B. die allererste Note), sondern auch für alle Noten, die zwischen diesem Zeitpunkt und dem nächsten Intervall gespielt werden müssen.

Tatsächlich möchten wir nicht nur genau das Intervall zwischen setTimeout()-Aufrufen vorausschauen. Wir benötigen auch eine Planungsüberschneidung zwischen diesem und dem nächsten Timer-Aufruf, um das Worst-Case-Verhalten des Hauptthreads zu berücksichtigen, also den Worst-Case-Fall einer Speicherbereinigung, des Layouts, des Renderings oder eines anderen Codes im Hauptthread, der den nächsten Timeraufruf verzögert. Außerdem müssen wir die Zeit für die Planung von Audioblöcken berücksichtigen, d. h. wie viel Audio im Verarbeitungspuffer des Betriebssystems beibehält. Dieser variiert je nach Betriebssystem und Hardware und reicht von wenigen einzelnen Stellen im Millisekundenbereich bis zu etwa 50 ms. Jeder oben dargestellte „setTimeout()“-Aufruf hat ein blaues Intervall, das den gesamten Zeitraum zeigt, in dem versucht wird, Ereignisse zu planen. Das vierte Web-Audioereignis, das im obigen Diagramm geplant ist, könnte beispielsweise „verspätet“ abgespielt worden sein, wenn wir mit der Wiedergabe 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 stärker sein. Diese Überschneidung wird mit zunehmender Komplexität Ihrer App noch wichtiger.

Die allgemeine Lookahead-Latenz wirkt sich darauf aus, wie eng die Temposteuerung (und andere Echtzeitsteuerungen) sein kann. Das Intervall zwischen der Planung von Aufrufen ist ein Kompromiss zwischen der minimalen Latenz und der Häufigkeit, mit der Ihr Code den Prozessor beeinträchtigt. Wie stark sich der Lookahead mit der Startzeit des nächsten Intervalls überschneidet, bestimmt, wie stabil Ihre App auf verschiedenen Rechnern ist. Außerdem wird die Komplexität mit zunehmender Komplexität zunehmen (dabei können Layout und Speicherbereinigung länger dauern). Generell empfiehlt es sich, einen großen Gesamt-Lookahead und ein relativ kurzes Intervall zu haben, um langsameren Maschinen und Betriebssystemen standzuhalten. Sie können sich auf kürzere Überlappungen und längere Intervalle einstellen, um weniger Callbacks zu verarbeiten, aber irgendwann hören Sie möglicherweise, dass eine große Latenz z. B. zu Tempoänderungen führt. Wenn Sie hingegen den Lookahead zu stark verringert haben, hören Sie möglicherweise etwas Zittern (da ein Planungsanruf unter Umständen Ereignisse „erfinden“ musste, die in der Vergangenheit hätten stattfinden sollen).

Das folgende Zeitdiagramm zeigt, was der Metronom-Democode tatsächlich tut: Er hat ein setTimeout-Intervall von 25 ms, aber eine viel widerstandsfähigere Überschneidung: Bei jedem Aufruf werden die nächsten 100 ms geplant. Der Nachteil dieses langen Lookaheads ist, dass es bei z. B. Tempoänderungen eine Zehntelsekunde dauert, aber Unterbrechungen sind weniger zuverlässig:

Planung mit langen Überschneidungen.
Planung mit langen Überschneidungen

In diesem Beispiel kam es zu einer Unterbrechung von setTimeout in der Mitte. Wir hätten einen setTimeout-Callback nach etwa 270 ms durchführen sollen, aber aus irgendeinem Grund wurde er um etwa 320 ms verzögert – also 50 ms später, als er hätte sein sollen. Durch die hohe Lookahead-Latenz ging das Timing ohne Probleme weiter und wir haben nichts verpasst, obwohl wir das Tempo kurz davor auf Sechzehntelnoten bei 240 bpm erhöht haben (über das Hardcore-Drum-and-Bass-Tempo hinaus!)

Es ist auch möglich, dass bei jedem Planeraufruf mehrere Notizen geplant werden. Sehen wir uns an, was passiert, wenn wir ein längeres Planungsintervall verwenden (Lookahead von 250 ms, Abstand von 200 ms) und ein Tempoanstieg in der Mitte:

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

Dieser Fall zeigt, dass jeder setTimeout()-Aufruf am Ende mehrere Audioereignisse planen kann. Tatsächlich handelt es sich bei diesem Metronom um eine einfache Anwendung, bei der jeweils nur eine Notiz verwendet wird, aber Sie können leicht erkennen, wie dieser Ansatz für einen Drumcomputer funktioniert (wo häufig mehrere Noten gleichzeitig vorhanden sind) oder einen Sequencer (der häufig nicht regelmäßige Intervalle zwischen den Noten aufweist).

In der Praxis sollten Sie Ihr Planungsintervall und den Lookahead anpassen, um zu sehen, wie stark das Layout, die automatische Speicherbereinigung und andere Dinge im Hauptthread der JavaScript-Ausführung darauf wirkt. Außerdem sollten Sie den Detaillierungsgrad der Geschwindigkeitskontrolle usw. optimieren. Wenn Sie beispielsweise ein sehr komplexes Layout haben, das häufig auftritt, sollten Sie den Lookahead vergrößern. Der wichtigste Punkt dabei ist, dass die geplanten Abläufe so groß genug sein sollten, um Verzögerungen zu vermeiden, aber nicht so groß, dass es bei der Feinabstimmung des Temporeglers zu merklichen Verzögerungen kommt. Selbst der obige Fall hat eine sehr kleine Überschneidung, sodass er auf langsamen Computern mit einer komplexen Webanwendung nicht sehr stabil ist. Ein guter Ausgangspunkt sind 100 ms für Lookahead-Zeiten mit Intervallen von 25 ms. Dies kann bei komplexen Anwendungen auf Computern mit einer hohen Latenz im Audiosystem weiterhin zu Problemen führen. In diesem Fall sollten Sie die Lookahead-Zeit erhöhen oder, wenn Sie mehr Kontrolle und weniger Stabilität benötigen, einen kürzeren Lookahead verwenden.

Der Hauptcode 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 genauen Szenario* geschieht nichts, da es keine Metronom-"Noten" gibt, die auf die Planung warten, aber wenn sie erfolgreich ist, wird diese Note mithilfe der Web Audio API geplant und zur nächsten Note gewechselt.

Mit der Funktion „scheduleNote()“ wird die Wiedergabe der nächsten „Web Audio-Notiz“ geplant. In diesem Fall habe ich Oszillatoren verwendet, um Töne in verschiedenen Frequenzen zu erzeugen. Genauso einfach können Sie AudioBufferSource-Knoten erstellen und deren Puffer auf Drumsounds oder andere Töne stellen.

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 werden gestartet, dann gestoppt und anschließend automatisch bereinigt.

Mit der Methode nextNote() wird zur nächsten Sechzehntel Note gewechselt, d. h., die Variablen "nextNoteTime" und "current16thNote" werden auf die nächste Note gesetzt:

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 ist zu verstehen, dass ich in diesem Beispiel für die Zeitplanung nicht die "Sequenzzeit" im Auge behalte, d. h. die Zeit seit Beginn des Metronoms. Wir müssen uns nur daran erinnern, wann wir die letzte Note gespielt haben, und herauszufinden, wann die nächste Note gespielt werden soll. Auf diese Weise können wir sehr leicht das Tempo ändern oder die Wiedergabe unterbrechen.

Diese Planungstechnik wird von einer Reihe anderer Audioanwendungen im Web verwendet, z. B. der Web Audio Drum Machine, dem unterhaltsamen Spiel „Acid Defender“ und noch ausführlicheren Audiobeispielen wie der Demo „Granular Effects“.

Noch ein weiteres Timing-System

Jeder gute Musiker weiß, dass jede Audio-App mehr Schnulzen – äh, mehr Timer – braucht. Es sollte erwähnt werden, dass die richtige Art der visuellen Darstellung darin besteht, ein DRITTEs Zeitsystem zu verwenden!

Warum, warum brauchen wir noch ein anderes Zeitsystem? Nun, diese wird über die requestAnimationFrame API mit der visuellen Anzeige, d. h. der Aktualisierungsrate für die Grafik, synchronisiert. Für das Zeichnen von Rahmen in unserem Metronom-Beispiel mag das nicht nach einer großen Sache erscheinen. Je komplexer Ihre Grafiken jedoch werden, desto wichtiger wird es, requestAnimationFrame() zur Synchronisierung mit der visuellen Aktualisierungsrate zu verwenden. Tatsächlich ist sie von Anfang an genauso einfach zu verwenden wie bei setTimeout()! Bei sehr komplexen synchronisierten Grafiken (z. B. der präzisen Darstellung der dichten Animation und der flüssigen Synchronisierung der Musiknotation) werden die meisten Musiknoten flüssig wiedergegeben.

Wir haben die Beats in der Warteschlange im Planer verfolgt:

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

Die Interaktion mit der aktuellen Zeit unseres Metronoms ist in derdraw()-Methode 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, weil wir die Synchronisierung mit diesem System durchführen möchten, da es die Noten abspielt. Tatsächlich verwenden wir die Zeitstempel der requestAnimationFrame-Methode gar nicht, da wir anhand der Uhr des Audiosystems feststellen, wo wir gerade sind.

Natürlich hätte ich ganz einfach einen setTimeout()-Callback verwenden und meinen Notizplaner in den requestAnimationFrame-Callback einfügen können. Dann wären wieder zwei Timer eingestellt. Das ist auch in Ordnung, aber es ist wichtig zu verstehen, dass „requestAnimationFrame“ in diesem Fall nur eine Alternative für setTimeout() ist. Trotzdem sollte die Genauigkeit des Web Audio-Timings für die eigentlichen Notizen wichtig sein.

Fazit

Ich hoffe, dass dieses Tutorial hilfreich bei der Erklärung von Uhren und Timern war und wie man gutes Timing in Web-Audioanwendungen einbauen kann. Dieselben Techniken lassen sich einfach extrapolieren, um Sequenzer, Drumcomputer und mehr zu entwickeln. Bis zum nächsten Mal...