Erstellung eines Audio-Wellenform-Visualisierers mit Vanilla JavaScript

Avatar of Matthew Ström
Matthew Ström am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Als UI-Designer werde ich ständig an den Wert des Programmierens erinnert. Ich bin stolz darauf, bei der Gestaltung von Benutzeroberflächen an die Entwickler in meinem Team zu denken. Aber manchmal trete ich in eine technische Landmine.

Vor ein paar Jahren, als Design Director von wsj.com, half ich bei der Neugestaltung des Podcast-Verzeichnisses des Wall Street Journal. Einer der Designer des Projekts arbeitete am Podcast-Player und ich stieß auf den eingebetteten Player von Megaphone.

Ich habe zuvor bei SoundCloud gearbeitet und wusste, dass diese Art von Visualisierungen für Benutzer nützlich ist, die durch Audio springen. Ich fragte mich, ob wir einen ähnlichen Look für den Player auf der Website des Wall Street Journal erzielen könnten.

Die Antwort der Ingenieure: definitiv nicht. Angesichts der Zeitpläne und Einschränkungen war dies für dieses Projekt keine Möglichkeit. Wir haben die neu gestalteten Seiten schließlich mit einem viel einfacheren Podcast-Player veröffentlicht.

Aber ich war von dem Problem gefesselt. Über Nächte und Wochenenden hinweg hackte ich daran, diesen Effekt zu erzielen. Ich lernte viel darüber, wie Audio im Web funktioniert, und konnte schließlich den Look mit weniger als 100 Zeilen JavaScript erzielen!

Es stellt sich heraus, dass dieses Beispiel eine perfekte Möglichkeit ist, sich mit der Web Audio API und der Visualisierung von Audiodaten mit der Canvas API vertraut zu machen.

Aber zuerst eine Lektion darüber, wie digitale Audio funktioniert

In der realen, analogen Welt ist Schall eine Welle. Wenn Schall von einer Quelle (wie einem Lautsprecher) zu Ihren Ohren reist, komprimiert und dekomprimiert er die Luft in einem Muster, das Ihre Ohren und Ihr Gehirn als Musik, Sprache oder das Bellen eines Hundes usw. usw. wahrnehmen.

Eine analoge Schallwelle ist eine glatte, kontinuierliche Funktion.

Aber in der Welt der elektronischen Signale eines Computers ist Schall keine Welle. Um eine glatte, kontinuierliche Welle in Daten umzuwandeln, die er speichern kann, machen Computer etwas namens Sampling. Sampling bedeutet, die Schallwellen, die ein Mikrofon treffen, tausende Male pro Sekunde zu messen und diese Datenpunkte dann zu speichern. Beim Abspielen von Audio kehrt Ihr Computer den Prozess um: Er erstellt den Ton Stück für Stück, ein winziger Sekundenbruchteil nach dem anderen.

Eine digitale Audiodatei besteht aus winzigen Schnitten des Originalaudios, die die glatte kontinuierliche Welle grob nachbilden.

Die Anzahl der Datenpunkte in einer Audiodatei hängt von ihrer Abtastrate ab. Möglicherweise haben Sie diese Zahl schon einmal gesehen; die typische Abtastrate für MP3-Dateien beträgt 44,1 kHz. Das bedeutet, dass es für jede Sekunde Audio 44.100 einzelne Datenpunkte gibt. Bei Stereo-Dateien sind es 88.200 pro Sekunde – 44.100 für den linken Kanal und 44.100 für den rechten. Das bedeutet, dass ein 30-minütiger Podcast 158.760.000 einzelne Datenpunkte zur Beschreibung des Audios enthält!

Wie kann eine Webseite eine MP3-Datei lesen?

In den letzten neun Jahren hat das W3C (die Leute, die Webstandards pflegen) die Web Audio API entwickelt, um Webentwicklern bei der Arbeit mit Audio zu helfen. Die Web Audio API ist ein sehr tiefgreifendes Thema; wir werden in diesem Aufsatz kaum an der Oberfläche kratzen. Aber alles beginnt mit etwas namens AudioContext.

Betrachten Sie den AudioContext wie einen Sandkasten für die Arbeit mit Audio. Wir können ihn mit ein paar Zeilen JavaScript initialisieren

// Set up audio context
window.AudioContext = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContext();
let currentBuffer = null;

Die erste Zeile nach dem Kommentar ist notwendig, da Safari AudioContext als webkitAudioContext implementiert hat.

Als Nächstes müssen wir unserem neuen audioContext die MP3-Datei geben, die wir visualisieren möchten. Holen wir sie uns mit… fetch()!

const visualizeAudio = url => {
  fetch(url)
    .then(response => response.arrayBuffer())
    .then(arrayBuffer => audioContext.decodeAudioData(arrayBuffer))
    .then(audioBuffer => visualize(audioBuffer));
};

Diese Funktion nimmt eine URL entgegen, ruft sie ab und transformiert das Response-Objekt dann mehrmals.

  • Zuerst ruft sie die Methode arrayBuffer() auf, die – Sie haben es erraten – ein ArrayBuffer zurückgibt! Ein ArrayBuffer ist nur ein Container für Binärdaten; er ist eine effiziente Möglichkeit, viele Daten in JavaScript zu bewegen.
  • Wir senden dann den ArrayBuffer über die Methode decodeAudioData() an unseren audioContext. decodeAudioData() nimmt einen ArrayBuffer entgegen und gibt einen AudioBuffer zurück, einen spezialisierten ArrayBuffer zum Lesen von Audiodaten. Wussten Sie, dass Browser all diese praktischen Objekte mitbrachten? Ich definitiv nicht, als ich mit diesem Projekt begann.
  • Schließlich senden wir unseren AudioBuffer zur Visualisierung.

Filtern der Daten

Um unseren AudioBuffer zu visualisieren, müssen wir die Menge der Daten reduzieren, mit der wir arbeiten. Wie bereits erwähnt, begannen wir mit Millionen von Datenpunkten, aber in unserer endgültigen Visualisierung werden wir weit weniger haben.

Zuerst begrenzen wir die Kanäle, mit denen wir arbeiten. Ein Kanal repräsentiert den Ton, der an einen einzelnen Lautsprecher gesendet wird. In Stereo-Sound gibt es zwei Kanäle; in 5.1 Surround-Sound gibt es sechs. AudioBuffer hat eine integrierte Methode dafür: getChannelData(). Rufen Sie audioBuffer.getChannelData(0) auf, und wir bleiben mit den Daten eines Kanals übrig.

Als Nächstes der schwierige Teil: Schleifen Sie durch die Daten des Kanals und wählen Sie einen kleineren Datensatz aus. Es gibt mehrere Möglichkeiten, wie wir das angehen könnten. Nehmen wir an, ich möchte, dass meine endgültige Visualisierung 70 Balken hat; ich kann die Audiodaten in 70 gleiche Teile aufteilen und einen Datenpunkt aus jedem davon betrachten.

const filterData = audioBuffer => {
  const rawData = audioBuffer.getChannelData(0); // We only need to work with one channel of data
  const samples = 70; // Number of samples we want to have in our final data set
  const blockSize = Math.floor(rawData.length / samples); // Number of samples in each subdivision
  const filteredData = [];
  for (let i = 0; i < samples; i++) {
    filteredData.push(rawData[i * blockSize]); 
  }
  return filteredData;
}
Dies war der erste Ansatz, den ich verfolgt habe. Um eine Vorstellung davon zu bekommen, wie die gefilterten Daten aussehen, habe ich das Ergebnis in eine Tabellenkalkulation eingegeben und es gezeichnet.

Das Ergebnis hat mich überrascht! Es sieht überhaupt nicht wie die Visualisierung aus, die wir emulieren. Es gibt viele Datenpunkte, die nahe bei Null liegen oder Null sind. Aber das ist sehr sinnvoll: In einem Podcast gibt es viel Stille zwischen Wörtern und Sätzen. Indem wir nur die erste Abtastung in jedem unserer Blöcke betrachten, ist es sehr wahrscheinlich, dass wir einen sehr leisen Moment erfassen.

Lassen Sie uns den Algorithmus modifizieren, um den Durchschnitt der Abtastungen zu finden. Und während wir dabei sind, sollten wir den Absolutwert unserer Daten nehmen, damit alles positiv ist.

const filterData = audioBuffer => {
  const rawData = audioBuffer.getChannelData(0); // We only need to work with one channel of data
  const samples = 70; // Number of samples we want to have in our final data set
  const blockSize = Math.floor(rawData.length / samples); // the number of samples in each subdivision
  const filteredData = [];
  for (let i = 0; i < samples; i++) {
    let blockStart = blockSize * i; // the location of the first sample in the block
    let sum = 0;
    for (let j = 0; j < blockSize; j++) {
      sum = sum + Math.abs(rawData[blockStart + j]) // find the sum of all the samples in the block
    }
    filteredData.push(sum / blockSize); // divide the sum by the block size to get the average
  }
  return filteredData;
}

Schauen wir uns an, wie diese Daten aussehen.

Das ist großartig. Es gibt nur noch eine Sache zu tun: Da wir so viel Stille in der Audiodatei haben, sind die resultierenden Durchschnittswerte der Datenpunkte sehr klein. Um sicherzustellen, dass diese Visualisierung für alle Audiodateien funktioniert, müssen wir die Daten normalisieren; das heißt, die Skala der Daten so ändern, dass die lautesten Abtastungen als 1 gemessen werden.

const normalizeData = filteredData => {
  const multiplier = Math.pow(Math.max(...filteredData), -1);
  return filteredData.map(n => n * multiplier);
}

Diese Funktion findet den größten Datenpunkt im Array mit Math.max(), nimmt dessen Kehrwert mit Math.pow(n, -1) und multipliziert jeden Wert im Array mit dieser Zahl. Dies garantiert, dass der größte Datenpunkt auf 1 gesetzt wird und die restlichen Daten proportional skaliert werden.

Jetzt, da wir die richtigen Daten haben, schreiben wir die Funktion, die sie visualisieren wird.

Visualisierung der Daten

Zur Erstellung der Visualisierung verwenden wir die JavaScript Canvas API. Diese API zeichnet Grafiken in ein HTML-<canvas>-Element. Der erste Schritt zur Verwendung der Canvas API ähnelt dem der Web Audio API.

const draw = normalizedData => {
  // Set up the canvas
  const canvas = document.querySelector("canvas");
  const dpr = window.devicePixelRatio || 1;
  const padding = 20;
  canvas.width = canvas.offsetWidth * dpr;
  canvas.height = (canvas.offsetHeight + padding * 2) * dpr;
  const ctx = canvas.getContext("2d");
  ctx.scale(dpr, dpr);
  ctx.translate(0, canvas.offsetHeight / 2 + padding); // Set Y = 0 to be in the middle of the canvas
};

Dieser Code findet das <canvas>-Element auf der Seite und prüft das Pixelverhältnis des Browsers (im Wesentlichen die Bildschirmauflösung), um sicherzustellen, dass unsere Grafik in der richtigen Größe gezeichnet wird. Dann erhalten wir den Kontext des Canvas (seine einzelnen Methoden und Werte). Wir berechnen die Pixelabmessungen des Canvas unter Berücksichtigung des Pixelverhältnisses und fügen etwas Polsterung hinzu. Schließlich ändern wir das Koordinatensystem des <canvas>. Standardmäßig ist (0,0) oben links im Feld, aber wir können uns viel Rechenaufwand ersparen, indem wir (0, 0) in die Mitte des linken Rands setzen.

Jetzt zeichnen wir einige Linien! Zuerst erstellen wir eine Funktion, die ein einzelnes Segment zeichnet.

const drawLineSegment = (ctx, x, y, width, isEven) => {
  ctx.lineWidth = 1; // how thick the line is
  ctx.strokeStyle = "#fff"; // what color our line is
  ctx.beginPath();
  y = isEven ? y : -y;
  ctx.moveTo(x, 0);
  ctx.lineTo(x, y);
  ctx.arc(x + width / 2, y, width / 2, Math.PI, 0, isEven);
  ctx.lineTo(x + width, 0);
  ctx.stroke();
};

Die Canvas API verwendet ein Konzept namens „Turtle-Grafik“. Stellen Sie sich vor, der Code ist eine Reihe von Anweisungen, die einem Turtle mit einem Stift gegeben werden. Im Grunde funktioniert die Funktion drawLineSegment() wie folgt:

  1. Beginnen Sie bei der Mittellinie, x = 0.
  2. Zeichnen Sie eine vertikale Linie. Machen Sie die Höhe der Linie relativ zu den Daten.
  3. Zeichnen Sie einen Halbkreis mit der Breite des Segments.
  4. Zeichnen Sie eine vertikale Linie zurück zur Mittellinie.

Die meisten Befehle sind geradlinig: ctx.moveTo() und ctx.lineTo() bewegen den Turtle zum angegebenen Koordinatenpunkt, entweder ohne zu zeichnen oder beim Zeichnen.

Zeile 5, y = isEven ? -y : y, sagt unserem Turtle, ob er von der Mittellinie nach unten oder nach oben zeichnen soll. Die Segmente wechseln sich oberhalb und unterhalb der Mittellinie ab, sodass sie eine glatte Welle bilden. In der Welt der Canvas API sind **negative y-Werte weiter oben als positive.** Das ist etwas kontraintuitiv, also behalten Sie es als mögliche Fehlerquelle im Hinterkopf.

In Zeile 8 zeichnen wir einen Halbkreis. ctx.arc() nimmt sechs Parameter entgegen:

  • Die x- und y-Koordinaten des Mittelpunkts des Kreises
  • Der Radius des Kreises
  • Die Position im Kreis, an der mit dem Zeichnen begonnen werden soll (Math.PI oder π ist die Position, in Radiant, von 9 Uhr)
  • Die Position im Kreis, an der das Zeichnen beendet werden soll (0 in Radiant repräsentiert 3 Uhr)
  • Ein boolescher Wert, der unserem Turtle sagt, ob er gegen den Uhrzeigersinn (wenn true) oder im Uhrzeigersinn (wenn false) zeichnen soll. Die Verwendung von isEven in diesem letzten Argument bedeutet, dass wir für gerade nummerierte Segmente die obere Hälfte eines Kreises – im Uhrzeigersinn von 9 Uhr bis 3 Uhr – und für ungerade nummerierte Segmente die untere Hälfte zeichnen.

OK, zurück zur Funktion draw().

const draw = normalizedData => {
  // Set up the canvas
  const canvas = document.querySelector("canvas");
  const dpr = window.devicePixelRatio || 1;
  const padding = 20;
  canvas.width = canvas.offsetWidth * dpr;
  canvas.height = (canvas.offsetHeight + padding * 2) * dpr;
  const ctx = canvas.getContext("2d");
  ctx.scale(dpr, dpr);
  ctx.translate(0, canvas.offsetHeight / 2 + padding); // Set Y = 0 to be in the middle of the canvas

  // draw the line segments
  const width = canvas.offsetWidth / normalizedData.length;
  for (let i = 0; i < normalizedData.length; i++) {
    const x = width * i;
    let height = normalizedData[i] * canvas.offsetHeight - padding;
    if (height < 0) {
        height = 0;
    } else if (height > canvas.offsetHeight / 2) {
        height = height > canvas.offsetHeight / 2;
    }
    drawLineSegment(ctx, x, height, width, (i + 1) % 2);
  }
};

Nach unserem vorherigen Einrichtungscode müssen wir die Pixelbreite jedes Liniensegments berechnen. Dies ist die Bildschirmbreite des Canvas, geteilt durch die Anzahl der Segmente, die wir anzeigen möchten.

Dann durchläuft eine for-Schleife jeden Eintrag im Array und zeichnet ein Liniensegment mit der zuvor definierten Funktion. Wir setzen den x-Wert auf den aktuellen Iterationsindex mal die Segmentbreite. Die Höhe, die gewünschte Höhe des Segments, ergibt sich aus der Multiplikation unserer normalisierten Daten mit der Höhe des Canvas, abzüglich der zuvor festgelegten Polsterung. Wir prüfen einige Fälle: Das Subtrahieren der Polsterung könnte height negativ gemacht haben, also setzen wir sie wieder auf Null. Wenn die Höhe des Segments dazu führt, dass eine Linie außerhalb der Oberseite des Canvas gezeichnet wird, setzen wir die Höhe auf einen Maximalwert zurück.

Wir übergeben die Segmentbreite und für den isEven-Wert verwenden wir einen cleveren Trick: (i + 1) % 2 bedeutet „finde den Rest von i + 1 geteilt durch 2.“ Wir prüfen i + 1, weil unser Zähler bei 0 beginnt. Wenn i + 1 gerade ist, ist sein Rest Null (oder false). Wenn i ungerade ist, ist sein Rest 1 oder true.

Und das ist alles. Setzen wir es zusammen. Hier ist das vollständige Skript, in seiner ganzen Pracht.

In der Funktion drawAudio() haben wir der endgültigen Funktion ein paar Funktionen hinzugefügt: draw(normalizeData(filterData(audioBuffer))). Diese Kette filtert, normalisiert und zeichnet schließlich die Audiodaten, die wir vom Server erhalten.

Wenn alles nach Plan verlaufen ist, sollte Ihre Seite so aussehen

Hinweise zur Leistung

Selbst mit Optimierungen führt dieses Skript wahrscheinlich Hunderte von Tausenden von Operationen im Browser aus. Abhängig von der Implementierung des Browsers kann dies viele Sekunden dauern und sich negativ auf andere Berechnungen auf der Seite auswirken. Außerdem wird die gesamte Audiodatei heruntergeladen, bevor die Visualisierung gezeichnet wird, was viel Daten verbraucht. Es gibt ein paar Möglichkeiten, wie wir das Skript verbessern könnten, um diese Probleme zu lösen:

  1. Analysieren Sie den Ton serverseitig. Da Audiodateien sich nicht oft ändern, können wir die serverseitigen Rechenressourcen nutzen, um die Daten zu filtern und zu normalisieren. Dann müssen wir nur den kleineren Datensatz übertragen; kein Herunterladen der MP3-Datei zum Zeichnen der Visualisierung!
  2. Zeichnen Sie die Visualisierung nur, wenn ein Benutzer sie benötigt. Unabhängig davon, wie wir den Ton analysieren, ist es sinnvoll, den Prozess bis weit nach dem Laden der Seite aufzuschieben. Wir könnten entweder warten, bis das Element in Sicht ist, indem wir einen Intersection Observer verwenden, oder noch länger warten, bis ein Benutzer mit dem Podcast-Player interagiert.
  3. Progressive Enhancement. Bei der Erkundung des Podcast-Players von Megaphone entdeckte ich, dass ihre Visualisierung nur eine Fassade ist – sie ist für jeden Podcast gleich. Dies könnte als großartige Standardeinstellung für unser (bei weitem überlegenes) Design dienen. Nach den Prinzipien des Progressive Enhancement könnten wir ein Standardbild als Platzhalter laden. Dann können wir prüfen, ob es sinnvoll ist, die echte Wellenform zu laden, bevor wir unser Skript starten. Wenn der Benutzer JavaScript deaktiviert hat, sein Browser die Web Audio API nicht unterstützt oder er den Header save-data gesetzt hat, ist nichts kaputt.

Ich würde gerne Ihre Gedanken zur Optimierung hören.

Einige abschließende Gedanken

Dies ist eine sehr, sehr unpraktische Art, Audio zu visualisieren. Es läuft auf der Clientseite und verarbeitet Millionen von Datenpunkten zu einer ziemlich geradlinigen Visualisierung.

Aber es ist cool! Ich habe beim Schreiben dieses Codes viel gelernt und beim Schreiben dieses Artikels noch mehr. Ich habe viel vom ursprünglichen Projekt refaktorisiert und das Ganze halbiert. Projekte wie dieses werden vielleicht nie in einer Produktionscodebasis landen, aber sie sind einzigartige Gelegenheiten, neue Fähigkeiten zu entwickeln und ein tieferes Verständnis für einige der tollen APIs zu erlangen, die moderne Browser unterstützen.

Ich hoffe, dies war ein hilfreiches Tutorial. Wenn Sie Ideen zur Verbesserung haben oder coole Variationen des Themas, melden Sie sich bitte! Ich bin @ilikescience auf Twitter.