Frequenzanalyse mit HTML5 und Web Audio API

In meiner Masterarbeit beschäftige ich mich gerade mit dem Thema Signalverarbeitung bzw. Audioanalyse/-verarbeitung. Das ist sicherlich kein neues Thema, denn wenn man nach Quellen sucht stößt man auf Bücher oder Beiträge, die schon etwas älter sind (ca. 14 Jahre) und dennoch thematisch aktuell und das will bei der IT schon etwas heißen, denn hier bedeutet fünf Jahre schon alt.

Interessant wird das Ganze nun wieder unter dem Aspekt, dass man solche Analysen auch im Browser durchführen kann, durch die Unterstützung von HTML5 Audio und der Web Audio API.

ACHTUNG: Die Web Audio API wird noch nicht zu 100% in allen gängigen Webbrowsern unterstützt, da sich diese noch in der Entwurfsphase (Draft) befindet und sich daher auch immer noch ändern kann.

Glücklicherweise, unterstützt nun neben dem Google Chrome auch endlich der Firefox in der Version 25 die Web Audio API. Ich finde jedoch, dass der Chrome flüssiger läuft und würde diesen daher (noch) bei komplexen Geschichten vorziehen.

 

LIVE DEMO HIER ANSCHAUEN

 

Frequenzen visualisieren (Beispiel: Akustik Gitarre)

Um nun die Frequenzen eines Audio-Signals anschaulich zu visualisieren, müssen zunächst die Audio-Daten entsprechend vorbereitet werden. Ich habe mein auf smartjava.org basierendes Beispiel für eine kurzes Audio meiner Akustikgitarre umgebaut (alle Leersaiten werden ein mal angespielt).

Visualisierung der Frequenzen in Bins

Leere A-Saite meiner Akustikgitarre: Visualisierung der Frequenzanteile, unterteilt in Bins (Frequenzbereiche)

 

Um nun überhaupt etwas mit Audioverarbeitung machen zu können, brauchen wir eine Instanz des AudioContext Interface, welches den Knotenpunkt unserer Anwendung darstellt. Hierbei dürfen wir nicht vergessen, die verschiedenen Präfixe der Browser zu berücksichtigen, in diesem Fall Firefox und Chrome.

//handle different prefix of the audio context
var AudioContext = AudioContext || webkitAudioContext;
//create the context.
var context = new AudioContext();

 

Nun wollen wir eine vorhandene Audio-Datei abspielen (Mikrofon kommt weiter unten). Dafür müssen ein paar Audio-Nodes erstellen und diese miteinander verbinden. Ich habe dafür eine Methode, die alles Nötige vorbereitet.

  1. Analyser-Node erstellen (liefert uns die Analyse-Daten)
  2. Source-Node erstellen (beinhaltet später die Audiodaten)
  3. Source-Node wird mit Analyser verbunden (Source-Node-Output geht an Analyser-Input)
  4. Source-Node wird zusätzlich mit den Speakern/Kopfhörern verbunden, damit wir auch etwas hören (context.destination)
  5. mit der Methode requestAnimationFrame sagen wir dem Browser, dass er bei der nächsten Zeichnung eine Aktion durchführen soll, in unserem Fall die Aktualisierung der Frequenz-Visualisierung.

Web Audio API Nodes

function setupAudioNodes() {
    //setup a analyser
    analyser = context.createAnalyser();
    //create a buffer source node
    sourceNode = context.createBufferSource();    
    //connect source to analyser as link
    sourceNode.connect(analyser);
    //and connect source to destination
    sourceNode.connect(context.destination);
    //start updating
    rafID = window.requestAnimationFrame(updateVisualization);
}


Audio-Datei mittels XmlHttpRequest laden

Nun kann die eigentliche Audiodatei geladen werden. In meinem Beispiel wird eine Wave-Datei geladen, bei der ich alle Leersaiten meiner Gitarre (Standardstimmung: E,A,D,G,H,E) nacheinander anspiele, aufgenommen mit dem Notebook-Mikrofon. Um nun die Ressource zu laden benutzen wir ein XmlHttpRequest, damit wird die Datei asynchron im Hintergrund geladen und blockiert nicht den Main-Thread.

var request = new XMLHttpRequest();
request.open('GET', url, true);
request.responseType = 'arraybuffer';

Die Variable URL enthält die hierbei den Pfad der Audiodatei. Wichtig ist den Rückgabetype auf "arraybuffer" zu setzen, damit erhalten wir ein Byte-Array welches wir wunderbar weiterverarbeiten können. Was nun noch fehlt ist das eigentliche laden. Hierfür brauchen wir einen Eventlistener, der die gewünschte Aufgabe erledigt, sobald die Daten geladen wurden.

//when loaded -> decode the data
request.onload = function() {
    // decode the data
    context.decodeAudioData(request.response, function(buffer) {
    // when the audio is decoded play the sound
    sourceNode.buffer = buffer;
    sourceNode.start(0);
    //on error
    }, function(e) {
        console.log(e);
    });
}
//start loading
request.send();

Der Befehl "request.send()" startet das Laden. Sobald dies erledigt wurde, fängt der eigentliche Teil an. Mittels der im AudioContext enthaltenden Methode "decodeAudioData" konvertieren wir die Daten in ein verwertbares Format. Die Methode erwartet drei Paramter. Der erste sind die zu dekodierenden Daten, der zweite ist eine Callback-Funktion, die ausgeführt wird, wenn die Dekodierung erfolgreich war und der dritte ist eine Callback-Funktion, für den Fall, dass etwas schief gelaufen ist.

Wenn alles geklappt hat, bekommt unsere Callback-Funktion die dekodierten Daten als Parameter übergeben. Nun müssen wir noch diese als Quelle für SourceNode setzen und das Abspielen starten (Auschnitt von oben):

    sourceNode.buffer = buffer;
    sourceNode.start(0);

 

Daten visualisieren

Die vorher initialisierten Nodes haben den Zeichnen-Prozess in Gang gesetzt, dafür haben wir die Methode "updateVisualization", die sich über "requestAnimationFrame" indirekt immer wieder selbst aufruft. Hier passiert nun die Magie:

function updateVisualization () {
    // get the average, bincount is fftsize / 2
    var array = new Uint8Array(analyser.frequencyBinCount);
    analyser.getByteFrequencyData (array);

    drawBars(array);
    drawSpectrogram(array);        

    rafID = window.requestAnimationFrame(updateVisualization);
}

Zunächst brauchen wir ein Array vom Typ "Uint8Array" welches ganzzahlige Werte ohne Vorzeichen in dem Bereich von 0 bis 255 enthalten kann. Die Länge wird dabei durch die fftSize bestimmt. Das ist die Größe, die dem Algorithmus FFT (fast Fourier transform) zu Grunde liegt, welcher fester Bestandteil der Web Audio API ist. Ich will nicht im Detail darauf eingehen, es sei nur so viel gesagt: die FFT transformiert die in einem Audiosignal enthaltenden Schwingungen (Zeitbereich) in die enthaltenden Frequenzen (Frequenzbereich).

 

Zeitbereich zu Frequenzbereich

Darstellung des selben Signals im Zeit- und Frequenzbereich (Quelle: Wikipedia)

Mit dem FFT kann immer nur ein bestimmter Zeitbereich analysiert werden, wenige Millisekunden oder ähnliches aber nie ganze Audiodateien. Mit Hilfe des AnalyserNode und der Methode "'getByteFrequencyData" füllen wir nun das Array mit Daten.

Anschließend können wir unsere Zeichnen-Methoden für die Bin-Ansicht und für die Spektralanalyse ausführen. Danach wird der ganze Teile mittels "requestAnimationFrame" wiederholt.

 

Frequenzen als Bins

Wenn die Seite geladen wird, bereite ich ein Canvas vor, auf dem die Bins gezeichnet werden sollen. Dafür habe ich die Methode "initBinCanvas". Diese fügt der Seite ein neues Canvas hinzu, holt sich das Context-Objekt des Canvas zum Zeichnen und erstellt einen Farbverlauf um die Unterschiede der Bins besser darzustellen.

function initBinCanvas () {

    //add new canvas
    $("body").append('<h1>Frequenzanalyse</h1><h2>FFT:Ausgabe der Amplitude der jeweiligen Bins (Frequenzbereiche) in Hz</h2><canvas id="freq" width="'+(window.innerWidth - 50)+'" height="325" style="background:black;"></canvas><br>');
    c = document.getElementById("freq");
   
    //get context from canvas for drawing
    ctx = c.getContext("2d");

    //create gradient for the bins
    var gradient = ctx.createLinearGradient(0,0,0,300);
    gradient.addColorStop(1,'#000000'); //black
    gradient.addColorStop(0.75,'#ff0000'); //red
    gradient.addColorStop(0.25,'#ffff00'); //yellow
    gradient.addColorStop(0,'#ffffff'); //white

    //set new gradient as fill style
    ctx.fillStyle = gradient;
}

Die Methode "drawBars" erhält nun als Parameter das Array mit den FFT-Daten und visualisiert diese. Die Vorgehensweise ist wie folgt:

  1. Canvas mittels "clearRect" leeren (um alte Zeichnungen zu löschen)
  2. Durch alle Array-Werte iterieren
    1. Sollte der Wert über dem "threshold" liegen, wird der Bin gezeichnet. In unserem Beispiel zeichnen wir alle, da der Threshold 0 ist. Dieser kann jedoch für Experimente hochgesetzt werden
    2. Für jeden zweiten Bin geben wir zusätzlich den Frequenzwert aus, damit man die Bins zuordnen kann

Ein Bin wird mit der Methode "fillRect" gezeichnet, die folgende Parameter erwartet: X-Position, Y-Position, Breite und Höhe. Mit der Variable "space", kann der Raum zwischen den einzelnen Bins beeinflusst werden.

function drawBars (array) {

    //just show bins with a value over the treshold
    var threshold = 0;
    // clear the current state
    ctx.clearRect(0, 20, c.width, c.height);
    //the max count of bins for the visualization
    var maxBinCount = array.length;
    //space between bins
    var space = 15;

    //go over each bin
    for ( var i = 0; i < maxBinCount; i++ ){
        
        var value = array[i];
        if (value >= threshold) {                

            //draw bin
            ctx.fillRect(5 + i * space, c.height - value, 5 , c.height);

            //draw every second bin area in hertz    
            if (i % 2 == 0) {
                ctx.font = '12px sans-serif';
                ctx.textBaseline = 'bottom';
                ctx.fillText(Math.floor(context.sampleRate / analyser.fftSize * i), i * space + 5, 20);
            }
        }
    }
}

Für jedes Bin kann man die zugehörige Frequenz berechnen. Die Formel lautet wie folgt:

Frequenz = sampleRate / fftSize * index (Bsp: 44100 / 2048 * 2 => 43,07)

Ist alles gut gelaufen, solltet ihr nun ein ähnliches Bild sehen, wie das hier:

Visualisierung der Frequenzen in Bins

Hier seht ihr die Frequenzen während ich gerade die leere A-Saite meiner Akustikgitarre spiele.

Frequenzen als Spektrogramm

Eine andere Darstellungsweise wäre ein Spektrogramm. Dabei wird der Frequenzanteil nicht durch die Höhe bestimmt, sondern durch die Helligkeit der Farbe. Je weiter oben die gezeichnet wird, desto höher ist die Frequenz und je heller die Farbe an dieser Stelle ist, desto stärker ist dieser Frequenzbereich in dem Audio-Signal vertreten.

Visualisierung der Frequenzen in einem Spektrogramm

Auch hier wurde beim Laden der Seite ein entsprechendes Canvas vorbereitet. Zum Zeichnen haben wir die Methode "drawSpectrogram" die auch von "updateVisualization" aufgerufen wird und die FFT-Daten als Parameter erhält.

Die Methode berechnet die maximale Anzahl an Werten die dargestellt werden sollen. Da pro Pixel ein Wert dargestellt werden soll und das Canvas 512 Pixel hoch ist, ist die ganze maximale Anzahl entweder 512 oder die Länge des Arrays wenn diese kleiner sein sollte.

Danach werden alle bisher gezeichneten Pixel um eins nach links verschoben und alle neue Werten werden am rechten Rand gezeichnet.

function drawSpectrogram(array) {
    var canvas = document.getElementById("draw");
    //max count is the height of the canvas
    var max = array.length > canvas.height ? canvas.height : array.length;
    //move the current pixel one step left
    var imageData = ctxDraw.getImageData(0,0,canvas.width,canvas.height);
    ctxDraw.putImageData(imageData,-1,0);
    //iterate over the elements from the array
    for (var i = 0; i < max; i++) {
        // draw each pixel with the specific color
        var value = array[i];
        //calc the color of the point
        ctxDraw.fillStyle = getColor(value);
        //draw the line at the right side of the canvas        
        ctxDraw.fillRect(canvas.width - 1, canvas.height - i, 1, 1);
    }    
}

Die jeweilige Farbe wird in einer extra Methode "getColor" abhängig von dem Wert "value" berechnet. Der maximale Wert in unserem Array ist 255 (8-bit) und soll als "weiß" dargestellt werden. Nun brauchen wir also den Prozentsatz von dem Wert "value" (x%) zu 255 (100%).

Je nachdem wie hoch der Prozentanteil ist, wird die Farbe zwischen schwarz, rot, gelb und weiß als Farbverlauf berechnet.

function getColor (v) {
    var maxVolume = 255;
    //get percentage of the max volume
    var p = v / maxVolume;
    var np = null;

    if (p < 0.05) {
        np = [0,0,0] //black
    //p is between 0.05 and 0.25
    } else if (p < 0.25) {
        np = [parseInt(255 * (1-p)),0,0] //between black and red
    //p is between 0.25 and 0.75
    } else if (p < 0.75) {
        np = [255,parseInt(255 * (1-p)),0];     //between red and yellow
    //p is between 0.75 and 1
    } else {
        np = [255,255,parseInt(255 * (1-p))]; //between yellow and white
    }

    return 'rgb('+ (np[0]+","+np[1]+","+np[2]) + ")";

Ich weiß, das sieht noch nicht besonders hübsch aus, ich wollte jedoch auf zusätzliche JavaScript-Bibliotheken verzichten. Die Methode hat also noch Nachholbedarf.

Das Mikrofon benutzen

Nun kann man natürlich anstelle einer Audiodatei auch z.B. das Mikrofon in einem Notebook benutzen. Die passende Methode heißt "getUserMedia". Diese beschafft uns einen Stream der gewünschten Quelle. Da diese per Standard auch einen Videostream der Notebook-Kamera zur Verfügung stellt, müssen wir explizit angeben, dass wir diesen nicht brauchen, sondern nur den Sound.

navigator.getUserMedia({audio: true, video: false}, 
    //success
    handleMicrophoneInput,
    //failed    
    function () {
        console.log('capturing microphone data failed!');
        console.log(evt);
    }
);

Auch hier gibt es wieder je eine Callback-Funktion für den Erfolg oder Misserfolg des Capturing. Bei Erfolg wird die Methode "handleMicrophoneInput" ausgeführt. Diese funktioniert ganz ähnlich wie unsere vorherige "setupAudioNodes".

function handleMicrophoneInput (stream) {    
    //convert audio stream to mediaStreamSource (node)
    microphone = context.createMediaStreamSource(stream);
    //create analyser
    if (analyser == null) analyser = context.createAnalyser();
    //connect microphone to analyser
    microphone.connect(analyser);
    //start updating
    rafID = window.requestAnimationFrame( updateVisualization );
}

Die Methode bekommt als Parameter diesmal jedoch ein Objekt vom Typ MediaStream, was der Audio-Stream unserer Quelle (z.B. Notebook-Mikrofon) ist. Mittels der Methode "createMediaStreamSource" wird daraus nun wieder ein Node-Objekt gemacht, dessen Output wir wieder an den Analyser-Node hängen können. Eine Verbindung zwischen Mikrofon und Speaker (Destination) findet diesmal nicht statt, da dies sonst zu unangenehmen Rückkopplungen führt.

Zum Schluss wird nun wieder der Loop mittels "requestAnimationFrame" gestartet und das Audio-Signal aus dem Mikrofon wird wie zuvor visualisiert. Trällert man nun einen Ton, sieht das ganze ungefähr so aus.

Visualisierung der Frequenzen in Bins - Mikrofon

 

Download

Den kompletten Quellcode könnt ihr kostenlos bei GitHub downloaden, benutzen und verändern wie ihr wollt. Es liegt dem Projekt ein minimaler Node.js file server bei, der Code kann jedoch natürlich auch mit PHP oder ähnlichem verwendet werden.

TIPP: Solltet ihr Node.js verwenden wollen, wechselt einfach mit der Kommandozeile in den Projektordner und führt die Datei "server.js" mit "node server.js" aus. Anschließend ist das Projekt im Browser unter "http://localhost:3000" zu erreichen.

 

LIVE DEMO HIER ANSCHAUEN

 

 

Zurück

Einen Kommentar schreiben

Kommentar von Alexander Grau |

Eine sehr gute Einführung in das Thema! :-) Wenn man es weiter treiben will, kann man das ganze um Filter, Oszillatoren, usw. ergänzen, wie z.B. hier :-)
http://www.grauonline.de/alexwww/ardumower/oscilloscope/oscilloscope.html

Wir nutzen es u.a. um das Schleifensignal eines Rasenroboters mit einer Spule im PC darzustellen (Spule an Line-In) und mit einem Matched Filter auszuwerten (Roboter überquert Schleife, Abstand zur Schleife etc.)

Gruss,
Alexander

Kommentar von Jsy |

ja, nettes Beispiel. Danke

Antwort von BerlinPix

gern!

Kommentar von Maik |

Das ist sehr interessant, vielen Dank!