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.

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.

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 – einArrayBufferzurückgibt! EinArrayBufferist 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 MethodedecodeAudioData()an unserenaudioContext.decodeAudioData()nimmt einenArrayBufferentgegen und gibt einenAudioBufferzurück, einen spezialisiertenArrayBufferzum 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
AudioBufferzur 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;
}


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:
- Beginnen Sie bei der Mittellinie,
x = 0. - Zeichnen Sie eine vertikale Linie. Machen Sie die Höhe der Linie relativ zu den Daten.
- Zeichnen Sie einen Halbkreis mit der Breite des Segments.
- 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.PIoder π ist die Position, in Radiant, von 9 Uhr) - Die Position im Kreis, an der das Zeichnen beendet werden soll (
0in Radiant repräsentiert 3 Uhr) - Ein boolescher Wert, der unserem Turtle sagt, ob er gegen den Uhrzeigersinn (wenn
true) oder im Uhrzeigersinn (wennfalse) zeichnen soll. Die Verwendung vonisEvenin 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:
- 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!
- 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.
- 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-datagesetzt 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.
Das ist großartig! Ich habe schon einmal versucht, genau diese Idee umzusetzen, und Sie haben sie viel weiter gebracht als ich. Ihre Hinweise zur Leistung waren auch das, was ich festgestellt habe und waren für mein Projekt entscheidend. Stattdessen habe ich etwas entwickelt, das meiner Meinung nach einen Schritt besser ist als Megaphone: Ich habe im laufenden Betrieb eine zufällige Wellenform generiert. Schauen Sie sich mein anfängliches Mockup dazu an: https://codepen.io/andrewscofield/pen/oGyrEv
Als ich dies echten Benutzern zeigte, musste ich traurig meine Vermutungen bestätigen… sie kümmerten sich nicht um die Wellenformgenauigkeit so, wie ich es tat. Insbesondere nicht, wenn ich einen trendigen, dicken Linienstil verfolgte; er ist ohnehin nicht genau.
Einen zusätzlichen Schritt, den ich unternommen habe, war, die resultierende zufällige Wellenform an den Server zu senden und in der Datenbank zu speichern. Dann wurde sie nur einmal ausgeführt und danach gecached. Obwohl das Skript selbst in Bezug auf die Leistung ziemlich gut ist, wollte ich konsistente Wellenformen beibehalten.
Matthew, danke dafür! Sehr cooles Thema, endlich etwas Erfrischendes. Es erinnerte mich daran, wie wir das Gleiche auf Amiga in Assembler gemacht haben. Danke nochmals und einen schönen Tag! R.
Sie beschreiben eine digitale Audiodatei als „ungefähre Nachbildung der glatten kontinuierlichen Welle“, aber das ist eigentlich nicht der Fall. Eine digitale Audiodatei kann jeden Ton mit einer Frequenz, die kleiner oder gleich der Hälfte der Abtastrate ist, perfekt wiedergeben (abgesehen von den üblicherweise verwendeten verlustbehafteten Komprimierungstechniken).
Abgesehen davon, toller Artikel! Ich werde das vielleicht bald auf meiner Website verwenden.
Ich verwende derzeit mehrere Canvas, um Dune Buggy-Daten in Echtzeit zu modellieren, und es müssen viele Tausend Punkte verarbeitet werden. Die CPU ist ständig bei 100 %, wenn ich sie benutze, und ich habe versucht, Wege zu finden, die Last zu reduzieren.
Hier ist die Seite: http://adam.teaches.engineering
Oh mein… das ist ein großartiger Artikel!
Ich bin noch relativ neu in JavaScript und Sie haben es geschafft, etwas ziemlich Schwieriges so klar zu erklären. Ich habe nur eine Frage. Als ich zum ersten Mal die Dokumentation zur Audio Web API durchsuchte, stieß ich auf
getFloatTimeDomainData()von AnalyserNode. Gibt es einen bestimmten Grund, warum Sie diese Methode nicht verwendet haben?Auf jeden Fall, nochmals vielen Dank für diese großartige Lektion.
Das ist wirklich hilfreich, vielen Dank für das Teilen!
Falls jemand es braucht, ich habe
Buffer => normalizedDatain ein npm-Paket namensaudioformgepackt.Es ist für die Verwendung in NodeJS gedacht, um die Daten serverseitig/während des Builds vorzubereiten.
Genau wie Dewitte frage ich mich auch, warum Sie
getFloatTimeDomainData()nicht verwendet haben? Ich denke, es kann Graphen auf viel bessere und effizientere Weise erstellen.https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/getFloatTimeDomainData
https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Using_Web_Audio_API