Eine Geschichte über zwei Uhren

Web-Audio präzise planen

Chris Wilson
Chris Wilson

Einführung

Eine der größten Herausforderungen bei der Entwicklung hochwertiger 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 freigibt.

Besonders bei musikalischen Anwendungen von Webaudio ist es wichtig, Audioereignisse möglichst genau und stimmig zu starten, nicht nur beim Ein- und Anhalten von Klängen, sondern auch bei der Planung von Klangänderungen (z. B. Änderung der Frequenz oder Lautstärke). Manchmal ist es wünschenswert, Ereignisse leicht zufällig zu zeitlichen Zeitpunkten auszuführen, z. B. in der Maschinengewehr-Demo in Game-Audio mit der Web Audio API entwickeln. Normalerweise möchten wir jedoch ein einheitliches und genaues Timing für musikalische Noten haben.

Wir haben Ihnen bereits gezeigt, wie Sie Töne mithilfe des Zeitparameters der Web Audio-Methoden „noteOn“ und „noteOff“ (jetzt „start“ und „stop“) planen. Das war im Artikel Einführung in Web Audio und auch im Artikel Game-Audio mit der Web Audio API entwickeln. Wir haben jedoch komplexere Szenarien wie das Abspielen langer musikalischer Sequenzen oder Rhythmen nicht näher erläutert. Dazu benötigen wir zuerst ein wenig Hintergrundwissen zu Uhren.

The Best of Times – die Web-Audiouhr

Die Web Audio API bietet Zugriff auf die Hardwareuhr des Audiosubsystems. Diese Uhr wird über die Eigenschaft „currentTime“ des AudioContext-Objekts als Gleitkommazahl in Sekunden seit der Erstellung des AudioContext-Objekts freigegeben. Dadurch ist diese Uhr (im Folgenden als „Audiouhr“ bezeichnet) sehr präzise. Sie ist so konzipiert, dass die Ausrichtung auch bei einer hohen Abtastrate auf Ebene einzelner Audio-Samples angegeben werden 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 im Voraus sehr genau zeitlich planen. Es ist verlockend, alles in Web Audio als Start-/Stoppzeiten einzurichten. 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 funktioniert hervorragend. Wenn du das Tempo jedoch in der Mitte dieser beiden Takte ändern oder die Wiedergabe beenden möchtest, bevor die beiden Takte abgelaufen sind, hast du Pech. (Ich habe schon Entwickler gesehen, die einen Gain-Knoten zwischen ihre vorab geplanten AudioBufferSourceNodes und die Ausgabe eingefügt haben, nur um ihre eigenen Töne 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 die Planung vielleicht komplett ändern möchtest.

Die schlimmste Zeit – die JavaScript-Uhr

Außerdem gibt es die vielgeliebte und vielgescholtene JavaScript-Uhr, die durch Date.now() und setTimeout() dargestellt wird. Der Vorteil der JavaScript-Uhr ist, dass sie einige sehr nützliche „Rufen Sie mich später an“-Methoden wie window.setTimeout() und window.setInterval() bietet, mit denen das System unseren Code zu bestimmten Zeiten aufrufen kann.

Das Nachteil der JavaScript-Uhr ist, dass sie nicht sehr genau ist. Zuerst: „Date.now()“ gibt einen Wert in Millisekunden zurück – eine Ganzzahl in Millisekunden. Die höchste Genauigkeit, die Sie erreichen können, ist also eine Millisekunde. In einigen musikalischen Kontexten ist das nicht besonders schlimm – wenn eine Note eine Millisekunde zu früh oder zu spät beginnt, fällt das vielleicht gar nicht auf. Aber selbst bei einer relativ niedrigen Audiohardwarerate von 44,1 kHz ist sie etwa 44,1-mal zu langsam, um als Audio-Zeitgeber verwendet zu werden. Denk daran, dass das Platzieren von Samples zu Audiostörungen führen kann. Wenn wir also Samples aneinanderreihen, müssen sie genau sequentiell sein.

Die anstehende High Resolution Time-Spezifikation bietet uns tatsächlich eine viel genauere aktuelle Uhrzeit ü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 Millisekundengenauigkeit von Date.now() zwar nicht allzu schlecht klingt, der tatsächliche Rückruf von Timer-Ereignissen in JavaScript (über window.setTimeout() oder window.setInterval) aber leicht um mehrere zehn Millisekunden durch Layout, Rendering, Garbage Collection, XMLHTTPRequest und andere Rückrufe verzerrt werden kann – kurz gesagt, durch eine Vielzahl von Dingen, die im Hauptausführungsthread passieren. Ich habe bereits erwähnt, dass wir mit der Web Audio API „Audioereignisse“ planen können. Diese werden alle in einem separaten Thread verarbeitet. Selbst wenn der Hauptthread aufgrund eines komplexen Layouts oder einer anderen zeitaufwendigen Aufgabe vorübergehend blockiert ist, werden die Audioinhalte genau zu der Zeit abgespielt, zu der sie geplant waren. Selbst wenn Sie im Debugger an einem Haltepunkt stehen, spielt der Audio-Thread weiterhin geplante Ereignisse ab.

JavaScript-Funktion setTimeout() in Audio-Apps verwenden

Da der Hauptthread leicht für mehrere Millisekunden ins Stocken geraten kann, ist es keine gute Idee, die Wiedergabe von Audioereignissen direkt mit setTimeout in JavaScript zu starten. Im besten Fall werden Ihre Notizen dann mit einer Verzögerung von etwa einer Millisekunde ausgelöst, im schlimmsten Fall sogar noch später. Das Schlimmste ist, dass rhythmische Sequenzen nicht in genauen Intervallen ausgelöst werden, da das Timing empfindlich auf andere Dinge im JavaScript-Hauptthread reagiert.

Zur Veranschaulichung habe ich eine Beispielanwendung für einen „schlechten“ Metronom geschrieben, d. h. eine Anwendung, die Noten direkt mit setTimeout plant und auch viel Layout verwendet. Öffnen Sie diese Anwendung, klicken Sie auf „Wiedergabe“ und ändern Sie dann während der Wiedergabe schnell die Größe des Fensters. Sie werden feststellen, dass das Timing merklich ungleichmäßig ist (Sie hören, dass der Rhythmus nicht gleichmäßig bleibt). „Aber das ist konstruiert“, sagen Sie? Natürlich. Das bedeutet aber nicht, dass es nicht auch in der realen Welt passiert. Selbst bei relativ statischen Benutzeroberflächen treten aufgrund von Neuauslagerungen Timingprobleme in setTimeout auf. Ich habe beispielsweise festgestellt, dass das Timing der ansonsten hervorragenden WebkitSynth merklich stottert, wenn das Fenster schnell neu skaliert wird. Stellen Sie sich nun vor, was passiert, wenn Sie versuchen, eine vollständige Partitur zusammen mit Ihrem Audiotrack flüssig zu scrollen. Sie können sich leicht vorstellen, wie sich das auf komplexe Musik-Apps in der Praxis auswirken würde.

Eine der am häufigsten gestellten Fragen ist: „Warum erhalte ich keine Rückrufe von Audioereignissen?“ Diese Art von Rückrufen kann zwar nützlich sein, aber sie löst das jeweilige Problem nicht. Diese Ereignisse werden im Haupt-JavaScript-Thread ausgelöst und unterliegen daher denselben potenziellen Verzögerungen wie setTimeout. Das heißt, sie können um eine unbekannte und variable Anzahl von Millisekunden von der genauen Zeit verzögert werden, zu der sie geplant wurden, bevor sie tatsächlich verarbeitet werden.

Was können wir tun? Die beste Möglichkeit, das Timing zu steuern, besteht darin, eine Zusammenarbeit zwischen JavaScript-Timern (setTimeout(), setInterval() oder requestAnimationFrame() – dazu später mehr) und der Audiohardware-Planung einzurichten.

Mit Blick nach vorn für ein perfektes Timing sorgen

Kehren wir zur Metronom-Demo zurück. Ich habe die erste Version dieser einfachen Metronom-Demo korrekt geschrieben, um diese Technik zur gemeinsamen Planung zu demonstrieren. (Der Code ist auch auf GitHub verfügbar) In dieser Demo werden Pieptöne (generiert von einem Oszillator) mit hoher Präzision auf jede Sechzehntel-, Achtel- oder Viertelnote abgespielt, wobei die Tonhöhe je nach Takt variiert. Außerdem kannst du das Tempo und das Notenintervall während der Wiedergabe ändern oder die Wiedergabe jederzeit beenden. Das ist eine wichtige Funktion für jeden realistischen Rhythmus-Sequencer. Es wäre ziemlich einfach, Code hinzuzufügen, um die Töne dieses Metronoms auch im laufenden Betrieb zu ändern.

Die Temperaturregelung bei gleichzeitig fehlerfreiem Timing wird durch eine Kombination aus einem setTimeout-Timer, der in regelmäßigen Abständen ausgelöst wird, und der zukünftigen Planung von Web Audio für einzelne Notizen ermöglicht. Der Timer „setTimeout“ prüft im Grunde nur, ob auf Grundlage des aktuellen Tempos „bald“ Noten geplant werden müssen, und plant sie dann so:

setTimeout() und die Interaktion mit dem Audioereignis.
Interaktion zwischen setTimeout() und Audioereignissen.

In der Praxis kann es zu Verzögerungen bei setTimeout()-Aufrufen kommen. Daher kann sich das Timing der Planungsaufrufe im Laufe der Zeit ändern (und je nach Verwendung von setTimeout verzerrt werden). Obwohl die Ereignisse in diesem Beispiel ungefähr 50 ms auseinander liegen, ist das häufig etwas mehr (manchmal auch viel mehr). Bei jedem Aufruf planen wir jedoch nicht nur Web Audio-Ereignisse für Noten, die jetzt abgespielt werden müssen (z. B. die allererste Note), sondern auch für alle Noten, die zwischen jetzt und dem nächsten Intervall abgespielt werden müssen.

Tatsächlich möchten wir nicht nur genau das Intervall zwischen den setTimeout()-Aufrufen nach vorne blicken. Wir benötigen auch eine gewisse Überschneidung zwischen diesem Timeraufruf und dem nächsten, um den Worst-Case-Fall des Hauptthreads zu berücksichtigen. Das ist der Worst-Case-Fall der Garbage Collection, des Layouts, des Renderings oder anderer Code, der im Hauptthread ausgeführt wird und unseren nächsten Timeraufruf verzögert. Außerdem müssen wir die Zeit für die Blockplanung von Audio berücksichtigen, d. h., wie viel Audio das Betriebssystem in seinem Verarbeitungspuffer aufbewahrt. Dieser Wert variiert je nach Betriebssystem und Hardware von wenigen Millisekunden bis zu etwa 50 Millisekunden. Jeder der oben gezeigten setTimeout()-Aufrufe hat ein blaues Intervall, das den gesamten Zeitraum angibt, in dem versucht wird, Ereignisse zu planen. Das vierte Web-Audioereignis, das im Diagramm oben geplant ist, wurde beispielsweise möglicherweise „zu spät“ wiedergegeben, wenn wir mit der Wiedergabe gewartet haben, bis der nächste setTimeout()-Aufruf erfolgt ist, und dieser setTimeout()-Aufruf nur wenige Millisekunden später erfolgt ist. Im wirklichen Leben kann der Jitter in diesen Zeiten noch extremer sein. Diese Überschneidung wird mit zunehmender Komplexität Ihrer App noch wichtiger.

Die Gesamtlatenz des Vorschau-Effekts wirkt sich darauf aus, wie genau die Temposteuerung (und andere Echtzeitsteuerungen) sein kann. Das Intervall zwischen den Planungsaufrufen 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 Rechnern ist. Und wenn dies komplexer wird (und das Layout und die automatische Speicherbereinigung können 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. Du kannst kürzere Überschneidungen und längere Intervalle festlegen, um weniger Rückrufe zu verarbeiten. Wenn die Latenz jedoch zu hoch ist, werden Tempoänderungen usw. möglicherweise nicht sofort wirksam. Wenn du den Vorlauf dagegen zu stark verkürzt hast, kann es zu Rucklern kommen, da ein Planungsaufruf möglicherweise Ereignisse „nachholen“ muss, die in der Vergangenheit hätten stattfinden sollen.

Das folgende Zeitdiagramm zeigt, was der Democode für den Metronom tatsächlich tut: Er hat ein setTimeout-Intervall von 25 Millisekunden, aber eine viel widerstandsfähigere Überschneidung: Jeder Aufruf wird für die nächsten 100 Millisekunden geplant. Der Nachteil dieses langen Vorlaufs ist, dass Tempoänderungen usw. ein Zehntel einer Sekunde dauern, bis sie wirksam werden. Wir sind jedoch viel widerstandsfähiger gegen Unterbrechungen:

Planung mit langen Überschneidungen
Planung mit langen Überschneidungen

In diesem Beispiel ist sogar zu erkennen, dass es in der Mitte eine Unterbrechung der setTimeout-Funktion gab. Der setTimeout-Callback sollte nach etwa 270 ms erfolgen, wurde aber aus irgendeinem Grund auf etwa 320 ms verzögert – also 50 ms später als vorgesehen. Dank der langen Vorschaulatenz blieb das Timing jedoch problemlos erhalten und wir haben keinen Takt verpasst, auch wenn wir kurz zuvor das Tempo auf Sechzehntelnoten bei 240 bpm erhöht hatten (das übertrifft sogar Hardcore-Drum-&-Bass-Tempi!).

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

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

In diesem Fall wird durch jeden Aufruf von setTimeout() möglicherweise mehr als ein Audioereignis geplant. Dieser Metronom ist zwar eine einfache Anwendung, bei der immer nur eine Note gleichzeitig ertönt, aber Sie können sich leicht vorstellen, wie dieser Ansatz für einen Drumcomputer (bei dem häufig mehrere Noten gleichzeitig erklingen) oder einen Sequencer (bei dem es häufig unregelmäßige Intervalle zwischen den Noten gibt) funktioniert.

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 Hauptpunkt ist, dass wir die „Vorauswahl“ so groß wie möglich gestalten möchten, um Verzögerungen zu vermeiden, aber nicht so groß, dass es beim Anpassen der Temporegelung zu einer merklichen Verzögerung kommt. Selbst der Fall oben 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 für Lookahead-Zeiten mit Intervallen von 25 ms. Bei komplexen Anwendungen auf Maschinen mit hoher Audiosystemlatenz können jedoch weiterhin Probleme auftreten. In diesem Fall sollten Sie die Vorlaufzeit verlängern. Wenn Sie eine engere Steuerung mit etwas weniger Ausfallsicherheit benötigen, verwenden Sie eine kürzere Vorlaufzeit.

Der Hauptcode des Planungsvorgangs befindet sich in der Funktion „scheduler()“:

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

Diese Funktion ruft einfach die aktuelle Audiohardwarezeit ab und vergleicht sie mit der Zeit für den nächsten Ton in der Sequenz. In diesem speziellen Fall geschieht in den meisten Fällen* nichts, da keine Metronom-„Noten“ geplant werden müssen. Wenn der Vorgang jedoch erfolgreich ist, wird der Ton mithilfe der Web Audio API geplant und mit dem nächsten Ton fortgefahren.

Die Funktion „scheduleNote()“ ist für die Planung der nächsten wiederzugebenden Web Audio-Note verantwortlich. In diesem Fall habe ich Oszillatoren verwendet, um Pieptöne mit unterschiedlichen Frequenzen zu erzeugen. Sie können genauso gut AudioBufferSource-Knoten erstellen und ihre Buffers auf Trommel- oder andere gewünschte Töne festlegen.

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 ignorieren. Sie werden gestartet, angehalten und dann automatisch vom Garbage Collector beseitigt.

Mit der nextNote()-Methode wird zur nächsten Sechzehntelnote 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. Beachten Sie jedoch, dass ich in diesem Planungsbeispiel nicht die „Sequenzzeit“ berücksichtige, also 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 wieder erklingen soll. So können wir das Tempo ganz einfach ändern oder die Wiedergabe beenden.

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“.

Yet Another Timing System

Wie jeder gute Musiker weiß, braucht jede Audioanwendung mehr Kuhglocken – ähm, mehr Timer. Es ist erwähnenswert, dass die richtige Art der visuellen Darstellung die Verwendung eines DREITTEILS-Timing-Systems ist.

Warum, warum, oh mein Gott, warum brauchen wir ein weiteres Zeitmesssystem? Nun, diese wird über die requestAnimationFrame API mit der visuellen Anzeige, d. h. der Aktualisierungsrate für die Grafik, synchronisiert. Beim Zeichnen von Quadraten in unserem Metronombeispiel mag das nicht so wichtig erscheinen, aber je komplexer Ihre Grafiken werden, desto wichtiger ist es, requestAnimationFrame() zu verwenden, um sie mit der visuellen Aktualisierungsrate zu synchronisieren. Und es ist von Anfang an genauso einfach zu verwenden wie setTimeout(). Bei sehr komplexen synchronisierten Grafiken (z. B. die präzise Darstellung vieler Noten, die in einem Notensatz abgespielt werden) sorgt requestAnimationFrame() für die flüssigste und präziseste Grafik- und Audiosynchronisierung.

Wir haben die Beats in der Warteschlange im Scheduler im Blick behalten:

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

Die Interaktion mit der aktuellen Zeit des Metronoms findet sich in der draw()-Methode, die (mit requestAnimationFrame) aufgerufen wird, sobald 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 bereits erwähnt, prüfen wir die Uhr des Audiosystems, da wir uns mit ihr synchronisieren möchten, da sie die Noten abspielt. So sehen wir, ob wir ein neues Feld zeichnen sollen oder nicht. Tatsächlich verwenden wir die Zeitstempel der requestAnimationFrame-Methode gar nicht, da wir die Zeit des Audiosystems über die Zeit bestimmen.

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 wissen, dass requestAnimationFrame in diesem Fall nur ein Ersatz für setTimeout() ist. Für die tatsächlichen Notizen ist weiterhin die Planungsgenauigkeit des Web Audio-Timings erforderlich.

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. Mit denselben Techniken lassen sich ganz einfach Sequenzer, Drumcomputer und mehr erstellen. Bis zum nächsten Mal...