3 Lerndemonstration zur Implementierung von digitalen Filtern auf Mikrocontrollern
4 Abstract
Diese Bachelorarbeit behandelt die praxisnahe Implementierung biquadratischer digitaler IIR-Filter (Biquads) auf Mikrocontrollerbasis anhand des Lyrat V4.3 mit dem ESP32. Ziel der Arbeit ist es, die theoretischen und praktischen Grundlagen digitaler Biquad-Filter zu vermitteln und in einer anwendungsorientierten Lerndemonstration umzusetzen. Dabei werden verschiedene Filterstrukturen wie Direktform 1, Direktform 2 sowie die transponierte Direktform 2 in Arduino und MicroPython realisiert und miteinander verglichen. Der Fokus liegt auf der Audioverarbeitung, insbesondere der Verarbeitung von WAV-Dateien sowie die Echtzeit-Filterung über Mikrofoneingänge.
Ein zentraler Bestandteil des Gesamtprozesses ist der digitale Filterentwurf in Python. Mithilfe von Bibliotheken wie scipy.signal sowie dem Tool pyfda werden Biquads entworfen, analysiert und deren Koeffizienten zur Implementierung auf dem Mikrocontroller exportiert. Diese Vorgehensweise ermöglicht eine effiziente Verifikation der Filtereigenschaften im Vorfeld und bildet die Brücke zwischen Design und Hardwareumsetzung.
Durch die Kombination von theoretischen Grundlagen, grafischen Designwerkzeugen, kommentierten Codebeispielen und praxisnahen Anwendungen bietet diese Arbeit eine kompakte Anleitung für Studierende und Entwickler, um eigene digitale Filterlösungen auf Mikrocontrollern mit begrenzten Ressourcen zu realisieren und weiterzuentwickeln. Die gesamten Inhalte dieser Arbeit sind innerhalb eines Git Repositorys hinterlegt und dokumentiert.
5 Motivation
Die Motivation dieser Arbeit liegt darin, die erlernten Grundkenntnisse aus der Theorie in die Praxis umzusetzen. Im Rahmen des Studiums wurden die Grundlagen der digitalen Signalverarbeitung, insbesondere digitaler Filter, intensiv behandelt, jedoch meist in abstrakter und simulierter Form. Ziel dieser Thesis ist es daher, diese theoretischen Konzepte durch eine praxisnahe Anwendung auf echter Hardware greifbar zu machen und so ein tieferes Verständnis für deren Umsetzung und Verhalten im realen System zu gewinnen.
Im Mittelpunkt der Arbeit steht die Implementierung digitaler Biquad-Filter auf dem Lyrat-Audioboard mit dem ESP32. Dabei werden verschiedene Filterstrukturen wie Direktform 1, Direktform 2 und die transponierte Direktform 2 implementiert und im Hinblick auf ihre Eigenschaften verglichen. Neben der Entwicklung der Filterlogik in C++ (Arduino) und MicroPython werden auch praxisrelevante Anwendungsbeispiele realisiert, darunter die Echtzeitfilterung über den Mikrofoneingang sowie die Verarbeitung von WAV-Dateien von einer SD-Karte.
Ergänzend erfolgt der digitale Filterentwurf mithilfe von Python-Tools wie scipy.signal und pyfda, wodurch die benötigten Koeffizienten grafisch analysiert und direkt für die Implementierung aufbereitet werden können. Durch diese Verbindung aus Theorie, Software-Design und Hardwareumsetzung entsteht eine vollständige Lerndemonstration, die nicht nur den eigenen Lernfortschritt fördert, sondern auch als Hilfestellung für andere Studierende und Entwickler dienen kann, die sich mit digitaler Signalverarbeitung auf Mikrocontrollerbasis beschäftigen.
6 Einführung
Die digitale Signalverarbeitung ist ein zentraler Bestandteil moderner Audio- und Kommunikationstechnik. Insbesondere digitale Filter spielen eine entscheidende Rolle bei der Rauschunterdrückung, Signalglättung und Frequenzselektion. In ressourcenbeschränkten eingebetteten Systemen wie Mikrocontrollern müssen solche Filter effizient, stabil und echtzeitfähig implementiert werden. Eine weit verbreitete Struktur sind dabei Biquad-Filter, die sich durch geringe Rechenlast, gute Skalierbarkeit und hohe Flexibilität auszeichnen.
Ziel dieser Bachelorarbeit ist die systematische Entwicklung, Berechnung und Umsetzung digitaler Biquad-Filter auf dem Lyrat-Audioboard mit dem ESP32. Als praxisorientierte Lerndemonstration wird gezeigt, wie digitale Filter mit den verschiedenen Strukturen: Direktform 1, Direktform 2 und transponierte Direktform 2, auf Mikrocontrollern programmiert und eingesetzt werden können. Die Implementierung erfolgt in zwei Programmierumgebungen: Arduino und MicroPython. Im Vordergrund stehen dabei sowohl die Filterung von WAV-Dateien als auch die Echtzeitverarbeitung von Audiosignalen über den Mikrofoneingang.
Ein wichtiger Bestandteil des Workflows ist der digitale Filterentwurf in Python. Mit Hilfe von Bibliotheken wie SciPy, NumPy und dem Tool pyFDA werden die benötigten IIR-Koeffizienten entworfen, analysiert und für die spätere Implementierung auf dem Mikrocontroller vorbereitet. Durch die grafische Analyse lassen sich Frequenzgang, Stabilität und Quantisierungseinflüsse bereits im Vorfeld bewerten und optimieren. Der Entwurf in Python dient dabei als flexible und zugängliche Plattform zur Verifikation und Visualisierung der Filtereigenschaften, bevor die praktische Umsetzung auf dem Zielsystem erfolgt.
Die Arbeit richtet sich an Studierende und Entwickler, die sich mit digitaler Signalverarbeitung in eingebetteten Systemen beschäftigen. Sie kombiniert theoretische Grundlagen, praxisorientierte Entwurfswerkzeuge und kommentierte Codebeispiele, um einen umfassenden und anwendungsnahen Einstieg in die Implementierung digitaler Filter auf Mikrocontrollern zu bieten.
Diese Bachelorarbeit dokumentiert den Entwicklungs- und Implementierungsprozess, der im zugehörigen GitHub-Repository festgehalten wurde. Sowohl das Repository als auch Teile dieser Arbeit sind im Rahmen einer Zusammenarbeit mit einer parallel durchgeführten Bachelorarbeit entstanden, die eine vergleichbare Fragestellung behandelt. Da beide Arbeiten auf denselben theoretischen Grundlagen basieren, wurden diese gemeinsam erarbeitet. Auch der Vergleich der unterschiedlichen Implementierungsmethoden erfolgte in enger Abstimmung und wurde kollaborativ ausgearbeitet.
7 Filterimplementierung auf Mikrocontrollern
Die Zielhardware bei der Implementierung von digitalen IIR-Filtern auf Mikrocontroller ist das Lyrat V4.3 mit dem ESP32. Das Lyrat V4.3 ist ein Audio-Entwicklungsboard von Espressif Systems mit zwei Tensilica Xtensa LX6 Prozessorkernen welche bis zu 240 MHz Frequenz takten und einem 520 KB internem RAM. Dieses Board verfügt über einen ES8388 Audiocodec. Über diesen Codec werden Hardware-Ausstattung wie ein integriertes Stereo-Mikrofon für Audioaufnahme, AUX-IN und AUX-OUT Anschlüsse für externe Audioquellen und Audioausgabegeräte angesprochen. Über einen MicroSD-Kartenslot können WAV-Dateien als Audioquellen eingebunden werden. Diese Konfiguration ermöglicht sowohl die Verarbeitung von Audio-Streams in Echtzeit als auch die Filterung gespeicherter Audiodateien.
7.1 Arduino Implementierung
Das Arduino-Framework basiert auf C/C++ und bietet durch spezialisierte Audio-Bibliotheken wie die arduino-audio-tools sowie die arduino-audio-driver von Phil Schatzmann eine Basis für die Echtzeitaudioverarbeitung. Bei der Implementierung werden die Programme mittels der Arduino IDE verfasst, kompiliert und auf den ESP32 geflasht.
7.2 Micropython Implementierung
MicroPython verwendet eine interpretierte Python-Syntax, die eine schnellere Entwicklung und vereinfachtes Debugging ermöglicht, jedoch aufgrund geringerer Ausführungsgeschwindigkeit primär für Prototyping geeignet ist und für keine hohen Verarbeitungsprozesse geeignet ist. Für die Implementierung in Micropython wird die ThonnyIDE mit dem Micropython Interpreter genutzt und der ESP32 wird mit einer Kompaktiblen Firmware geflasht.
7.3 Vorgehen bei der Implementierung
Für die Implementierung von digitalen Filtern auf Mikrocontrollern werde anhand der theoretischen Grundlagen die Strukturen durch ihre korrespondierenden Differenzengleichungen in Software umgesetzt. Diese Umsetzungen werden als Basis für weitere Verarbeitung von WAV-Datei genutzt. Im Folgenden werden die Strukturen in Arduino realisiert.
8 Implementierungserklärung der Biquad Filter in Arduino
Für die Implementierung der biquadratischen Filter in Arduino werden die Differenzengleichungen aus den Theoretischen Grundlagen entnommen und in Code umgesetzt. Die Filter sollen über das Einsetzten der b
und a
Koeffizienten implementiert werden.
8.1 Filter Basisklasse
class Filter
{
public:
virtual ~Filter() {}
virtual float filter(float) = 0;
};
Diese abstrakte Basisklasse dient als Interface für alle Filterimplementierungsstrukturen. Der virtuelle Destruktor virtual ~Filter() {}
stellt sicher, dass beim Löschen eines Objekts über einen Basisklassenzeiger der korrekte Destruktor der abgeleiteten Klasse aufgerufen wird. Die rein virtelle Methode virtual float filter(float) = 0;
definiert die einheitliche Schnittstelle - jede abgeleitete Filterklasse muss eine filter()
- Methode implementieren, die einen float-Wert entgegennimmt und einen gefilterten float-Wert zurückgibt. Diese Struktur ermöglicht die Verwendung der verschiedenen Filtertypen im Anwendiungsfall einer Kaskadierung.
8.2 Direktform 1
8.2.1 Private Member-Variablen
private:
const float b_0;
const float b_1;
const float b_2;
const float a_1;
const float a_2;
float x_1 = 0;
float y_1 = 0;
float y_2 = 0;
Die Filterkoeffizienten b_0, b_1, b_2, a_1, a_2
sind als const
deklariert, da sie nach der Initialisierung unveränderlich bleiben sollen. Dies entspricht der mathematischen Definition eines zeitinvarianten Systems. Die Verzögerungselemente x_1, y_1, y_2
speichern die vorherigen Ein- und Ausgangswerte. Der Wert a_0
wird nicht gespeichert, da durch die Normalisierung im Konstruktor alle Koeffizienten bereits durch a_0
geteilt wurden.
8.2.2 Konstruktor
(const float (&b)[3], const float (&a)[3], float gain)
BiquadFilterDF1: b_0(gain * b[0] / a[0]),
(gain * b[1] / a[0]),
b_1(gain * b[2] / a[0]),
b_2(a[1] / a[0]),
a_1(a[2] / a[0])
a_2{
}
Der Konstruktor verwendet Initialisierungslisten für optimale Performance. Die Koeffizienten werden direkt bei der Objekterstellung normalisiert, indem alle durch a[0]
geteilt werden. Dies entspricht der Standardform der Differenzengleichung, wo der führende Koeffizient des Nenners auf 1 normiert wird. Der gain
Parameter wird in die Zählerkoeffizienten eingerechnet, was mathematisch äquivalent zur Multiplikation der gesamten Übertragungsfunktion mit dem Verstärkungsfaktor ist. Die Array-Referenz-Syntax const float (&b)[3]
stellt sicher, dass exakt drei Elemente übergeben werden müssen.
8.2.3 Filter Methode
float filter(float x_0)
{
float x_2 = x_1;
= x_0;
x_1
float y_0 = b_0 * x_0 + b_1 * x_1 + b_2 * x_2 - a_1 * y_1 - a_2 * y_2;
= y_1;
y_2 = y_0;
y_1
return y_0;
}
Diese Implementierung wird durch die Struktur der DF1 umgesetzt. Zuerst werden die Eingangsverzögerungen aktualiseirt: x_2
erhält den vorherigen Wert von x_1
, dann wird x_1
auf den aktuellen Eingangswert x_0
gesetzt. Die Differenzengleichung wird direkt implementiert: y[n] = b_0 * x[n] + b_1 * x[n-1] + b_2 * x[n-2] - a_1 * y[n-1] - a_2 * y[n-2]
. Im Anschluss werden die Ausgangsverzögerungen für die nächste Iteration aktualisiert. Diese Reihenfolge ist kritisch, da die Berechnung vor Aktualisierung der Ausgangsverzögerungen erfolgen muss.
8.3 Direktform 2
8.3.1 Private Member Variablen
private:
const float b_0;
const float b_1;
const float b_2;
const float a_1;
const float a_2;
float w_0 = 0;
float w_1 = 0;
Die DF2-Struktur benötigt nur zwei Verzögerungselemente w_0, w_1
anstatt der vier in DF1. Dies reduziert den Speicherbedarf um die 50%. Die w
Variablen repräsentieren die internen Knotenpunkte der DF2-Struktur, wo sowohl die Rückkopplung als auch die Vorwärtskopplung zusammenlaufen.
8.3.2 Konstruktor
(const float (&b)[3], const float (&a)[3], float gain)
BiquadFilterDF2: b_0(gain * b[0] / a[0]),
(gain * b[1] / a[0]),
b_1(gain * b[2] / a[0]),
b_2(a[1] / a[0]),
a_1(a[2] / a[0])
a_2{
}
Der Konstruktor ist identisch zur DF1 Implementierung, da die Koeffizientennormalisierung unabhängig von der internen Filterstruktur ist. Die mathematische Übertragungsfunktion bleibt dieselbe, nur die interne Realisierung unterscheidet sich
8.3.3 Filter Methode
float filter(float x_0)
{
float w_2 = w_1;
= w_0;
w_1 = x_0 - a_1 * w_1 - a_2 * w_2;
w_0
float y_0 = b_0 * w_0 + b_1 * w_1 + b_2 * w_2;
return y_0;
}
Die DF2 Implementierung teilt die Berechnung in zwei Phasen: Zuerst wird der interne Zustand w_0
berechnet, der das Eingangssignal minus der Rückkopplung darstellt. Dies entspricht der Implementierung des Nennerpols der Übertragungsfunktion. Dann wird der Ausgang durch die Vorwärtskopplung mit den b
Koeffizienten berechnet, was dem Zählerpolynom entspricht. Die Verzögerungselemente werden vor der w_0
Berechnung aktualisiert, damit die korrekten vorherigen Werte verwendet werden.
8.4 Transponierte Direktfrom 2
8.4.1 Private Member Variablen
private:
const float b_0;
const float b_1;
const float b_2;
const float a_1;
const float a_2;
float s_1 = 0;
float s_2 = 0;
Die TDF2 Struktur verwendet Zustandsvariablen s_1, s_2
, die als “shift register” fungieren. Diese Struktur ist die transponierte Version der DF2, was bedeutet, dass der Signalfluss umgekehrt wird: die Ein- und Ausgänge werden vertauscht, sowohl als auch die Richtung der Verzögerungselemente wird umgekehrt.
8.4.2 Konstruktor
(const float (&b)[3], const float (&a)[3], float gain)
BiquadFilterTDF2: b_0(gain * b[0] / a[0]),
(gain * b[1] / a[0]),
b_1(gain * b[2] / a[0]),
b_2(a[1] / a[0]),
a_1(a[2] / a[0])
a_2{
}
Auch hier ist der Konstruktor identisch zu den anderen Implementierungen, da die Koeffizientennormalisierung eine mathematische Anforderung ist, die unabhängig von der gewählten Realisierungsform gilt.
8.4.3 Filter Methode
float filter(float x_0)
{
float y_0 = s_1 + b_0 * x_0;
s_1 = s_2 + b_1 * x_0 - a_1 * y_0;
s_2 = b_2 * x_0 - a_2 * y_0;
return y_0;
}
Bei der TDF2 Implementierung setzt sich der Ausgangswert aus der addition aus dem ersten Zustandsregister s_1
mit dem verstärkten Eingangswert. Die Zustandsregister werden dann für die nächste Iteration aktualisiert. s_1
wird zum nächsten Wert von s_2
addiert mit den gewichteten Ein- und Ausgangswerten. s_2
wird komplett neu berechnet. Diese Struktur hat den Vorteil, dass der Ausgangswert sehr früh im Berechnungszyklus verfügbar ist, was bei Pipeline Implementierungen sich als vorteilhaft erweisen kann.
8.5 Eingabe Koeffiziente
const float b_0 = f;
const float b_1 = f;
const float b_2 = f;
const float a_0 = f;
const float a_1 = f;
const float a_2 = f;
Bei den Eingabe-Koeffezienten wird der f
Suffix an die float Literalen angehängt, sodass sicher gestellt wird das die Werte als singel-precision floating-point interpretiert werden, was bei Arduino-System von hoher Wichtigkeit ist.
8.6 Koeffizienten Arrays und Filter Instanziierung
const float b_coefficients[] = { b_0, b_1, b_2};
const float a_coefficients[] = { a_0, a_1, a_2};
const float gain = 1;
(b_coefficients, a_coefficients, gain); BiquadFilterTDF2 biquad
Die Koeffizienten werden in separaten Arrays gespeichert und an den Konstruktor übergeben. Dies ermöglicht eine saubere Trennung zwischen Filterdesign (Koeffizienten) und Implementierung (Filterklasse). Über den gain
Paramter kann auf den zu Implementierenden Filter eine gewünschte Verstärkung angewandt werden. Bei einem Wert von 1 wird keine zusätzliche Verstärkung angewendet. Bei dieser Filter Instanziierung wird die TDF2 verwendet mir ihrem korosponierenden Klassennamen. Somit wird das Objekt biquad
durch die Klasse BiquadFilterTDF2
instanziiert. Innerhalb des Objekts werden die Koeffizienten mit dem Verstärkungsfaktor an die jeweilige Filterklasse übergeben. Der Implementierte Filter steht bereit zur Verwendung.
8.7 Filter Anwendung
float filtered = biquad.filter(Input);
Die direkte Anwendung erfolgt durch den Aufruf der filter()
-Methode auf dem Filterobjekt biquad
. Der Eingangswert Input
wird der Methode übergeben, durch die spezifische Filterimplementierung entsprechend der definierten Differenzengleichung verarbeitet und als transformierter Ausgangswert filtered zurückgegeben.
9 Filterung einer WAV Datei in Arduino
Zur Demonstration von digitalen Filtern auf Mikrocontrollern wird eine WAV-Datei mittels eines ESP32 gefiltert. Die Audio-Datei wird über eine SD-Karte eingelesen, gefiltert und als gefilterte Variante abgespeichert.
Zu beachten ist, dass nur 16-Bit-WAV-Dateien unterstützt werden. Die Datei kann jedoch Mono oder Stereo mit und einer beliebigen Samplerate sein. Die WAV-Datei, die gefiltert werden soll, muss zu original.wav
umbenannt werden bevor diese auf die SD-Karte übertragen wird.
9.1 Vorbereitung der WAV-Filterung
#include "SD_MMC.h" // SD-Karten-Support für ESP32 mit SD_MMC-Anschluss
Durch die Einbindung vom SD_MMC.h wird eine erleichterte Nutzung von SD-Karten mit einem ESP32 geboten, als auch die Möglichkeit für 1-Wire-Modus sowie 4-Wire-Modus.
// --- WAV Hilfsfunktionen ---
uint32_t readLEUint32(uint8_t* buf) {
return (uint32_t)buf[0] | ((uint32_t)buf[1] << 8)
| ((uint32_t)buf[2] << 16) | ((uint32_t)buf[3] << 24);
}
uint16_t readLEUint16(uint8_t* buf) {
return (uint16_t)buf[0] | ((uint16_t)buf[1] << 8);
}
Diese Hilfsfunktionen konvertieren Byte-Arrays aus WAV-Dateien in Ganzzahlen im Little-Edian-Format um. readLEUint32()
kombiniert 4 Bytes zu einem 32-Bit-wert, readLEUint16()
kombiniert 2 Bytes zu einem 16-Bit-Wert, wobei das niedrigste Byte zuerst kommt.
// WAV-Header für die Ausgabedatei schreiben
void writeWavHeader(File &file, uint32_t dataSize, uint16_t channels,
uint32_t sampleRate, uint16_t bitsPerSample) {
const char* riff = "RIFF";
const char* wave = "WAVE";
const char* fmt = "fmt ";
const char* data = "data";
uint32_t chunkSize = 36 + dataSize;
uint16_t blockAlign = channels * bitsPerSample / 8;
uint32_t byteRate = sampleRate * blockAlign;
uint32_t subchunk1Size = 16;
uint16_t audioFormat = 1; // PCM
.seek(0);
file.write((const uint8_t*)riff, 4);
file.write((uint8_t*)&chunkSize, 4);
file.write((const uint8_t*)wave, 4);
file.write((const uint8_t*)fmt, 4);
file.write((uint8_t*)&subchunk1Size, 4);
file.write((uint8_t*)&audioFormat, 2);
file.write((uint8_t*)&channels, 2);
file.write((uint8_t*)&sampleRate, 4);
file.write((uint8_t*)&byteRate, 4);
file.write((uint8_t*)&blockAlign, 2);
file.write((uint8_t*)&bitsPerSample, 2);
file.write((const uint8_t*)data, 4);
file.write((uint8_t*)&dataSize, 4);
file}
Die writeWavHeader
-Funktion schreibt einen WAV-Header in die Ausgabedatei. Die Funktion erstellt die erforderlichen Chunks (RIFF, WAVE, fmt, data) mit den übergebenen Audio-Parametern und berechnet automatisch die abgeleiteten Werte wie Byte-Rate und Block-Alignment. Der Header wird im Little-Endian-Format geschrieben.
// --- Variablen für die Ein- und Ausgangsdatei --
; // Originale Eingangsdatei
File wavFile; // Gefiltere Ausgangsdatei File filteredFile
Hilfsvariablen für die originale Eingangsdatei als auch die gefilterte Ausgangsdatei.
Der Gesamtprozess bestehend aus einlesen, filtern und neuen WAV-Datei erstellen wird innerhalb der Setup()
-Funktion durchgeführt.
void setup() {
.begin(115200); Serial
Zunächst wird die serielle Kommunikation mit 115200 Baud initialisiert, um Informationen des Mikrocontroller in der Konsole auslesen zu können.
// SD-Karte im 4-Wire Modus initialisieren
if (!SD_MMC.begin("/sdcard", false)) { //false = 4-Wire, true - 1-Wire
.println("Fehler beim Initialisieren der SD-Karte.");
Serialreturn;
}
.println("SD-Karte gefunden."); Serial
Die SD-Karte wird im 4-Wire-Modus initialisiert, welcher eine höhere Datenübertragungsrate ermöglicht als der 1-Wire-Modus. Durch den Parameter false
wird der 4-Wire-Modus aktiviert, während true
den 1-Wire-Modus aktiviert. Wird keine SD-Karte gefunden oder besteht ein Problem bei der Initialisierung, wird eine Fehlermeldung ausgegeben und das Programm beendet.
// Eingangs WAV Datei öffnen
= SD_MMC.open("/original.wav", FILE_READ);
wavFile if (!wavFile) {
.println("Fehler beim Öffnen der WAV-Datei.");
Serialreturn;
}
// WAV Header auslesen
if (!readWavHeader()) {
.close();
wavFilereturn;
}
Die originale WAV-Datei wird im Lesemodus geöffnet. Für den Fall, dass das Öffnen fehlschlägt, wird dementsprechende Fehlermeldung ausgegeben und das Programm beendet. Bei erfolgreichen Öffnen wird die Funktion readWavHeader
aufgerufen und der Header der Eingangsdatei analysiert. Falls diese Funktion false
zurückgibt, wird die Datei geschlossen und das Programm beendet.
bool readWavHeader() {
uint8_t riffHeader[12];
if (wavFile.read(riffHeader, 12) != 12) {
.println("Fehler beim Lesen des RIFF-Headers.");
Serialreturn false;
}
//Prüfen auf "RIFF" und "WAVE"
if (memcmp(riffHeader, "RIFF", 4) != 0 || memcmp(&riffHeader[8],
"WAVE", 4) != 0) {
.println("Keine gültige WAV-Datei.");
Serialreturn false;
}
Die Header-Analyse-Funkion beginnt mit der Validierung der WAV-Datei Signatur. Dazu werden die ersten 12 Bytes der Datei gelesen, die den RIFF-Header bilden. Anschließend wird überprüft, ob die Bytes 0-3 die Zeichenkette “RIFF” und die Bytes 8-11 die Zeichenkette “WAVE” enthalten. Diese Signaturen sind für alle gültigen WAV-Dateien notwendig und identifizieren das genaue Dateiformat.
// Variablen für WAV-Informationen
uint16_t audioFormat = 0, numChannels = 0, bitsPerSample = 0;
uint32_t sampleRate = 0, dataSize = 0;
bool fmtFound = false, dataFound = false;
Für die Chunk-Analyse werden die folgenden Variablen initialisiert, welche die Audio-Parameter speichern. Die 16-Bit Variablen audioFormat
, numChannels
und bitsPerSample
enthalten die grundlegenden Format-Informationen, während sampleRate
und dataSize
als 32-Bit-Werte die Abtastrate und Datengröße speichern. Die booleschen Flags fmtFound
und dataFound
verfolgen, die notwendigen Chunks bereits gefunden wurden.
// Suche nach "fmt " und "data" Chunks
while (wavFile.position() < wavFile.size()) {
uint8_t chunkHeader[8];
if (wavFile.read(chunkHeader, 8) != 8) {
.println("Fehler beim Lesen eines Chunk-Headers.");
Serialbreak;
}
uint32_t chunkSize = readLEUint32(&chunkHeader[4]);
Der Chunk-Parser durchläuft systematisch die WAV-Datei, um relevante Datenabschnitte zu finden. Die WAV-Datei ist in Chunks unterteilt, die jeweils mit einem 8-Byte-Header beginnt: 4 Bytes für die Chunk-ID und 4 Bytes für die Größe im Little-Endian-Format. Die Schleife läuft bis zum Dateiende oder bis ein Lesefehler auftritt.
//Format-Chunk gefunden
if (memcmp(chunkHeader, "fmt ", 4) == 0) {
uint8_t fmtChunk[chunkSize];
if (wavFile.read(fmtChunk, chunkSize) != chunkSize) {
.println("Fehler beim Lesen des fmt-Chunks.");
Serialbreak;
}
= readLEUint16(&fmtChunk[0]);
audioFormat = readLEUint16(&fmtChunk[2]);
numChannels = readLEUint32(&fmtChunk[4]);
sampleRate = readLEUint16(&fmtChunk[14]);
bitsPerSample = true;
fmtFound
// Parameter der Eingangs-WAV
.printf("AudioFormat: %d\n", audioFormat);
Serial.printf("Kanäle: %d\n", numChannels);
Serial.printf("SampleRate: %d\n", sampleRate);
Serial.printf("Bits pro Sample: %d\n", bitsPerSample);
Serial
if (chunkSize % 2 == 1) wavFile.seek(wavFile.position() + 1);
}
Wenn der Chunk-Header “fmt” gefunden wird, werden die Chunk-Daten in einen Puffer geladen und die wichtigsten Audio-Parameter extrahiert: Audi-Format(PCM), Kanalzahl(Mono oder Stereo), Samplerate und die Bits pro Sample aus festen Byte-Positionen. Die Parameter werden zur Kontrolle ausgegeben und bei ungerader Chunk-Größe wird ein Padding-Byte übersprungen.
// Daten-Chunk gefunden
else if (memcmp(chunkHeader, "data", 4) == 0) {
= chunkSize;
dataSize = true;
dataFound break;
}
Der Data-Chunk enthält die eigentlichen Audio-Daten mit den Samples. Sobald dieser gefunden wird, wird seine Größe gespeichert und die Suche endet. Der Dateizeiger steht dann am Beginn der Audio-Samples für die weitere Verarbeitung.
// Irrelavante Chucks überspringen
else {
.seek(wavFile.position() + chunkSize);
wavFileif (chunkSize % 2 == 1) wavFile.seek(wavFile.position() + 1);
}
}
Irrelevante Chunks, wie LIST oder INFO, werden übersprungen, indem der Dateizeiger um die Chunk-Größe voranbewegt wird. Bei ungerader Chunk-Größe wird ein zusätzliches Padding-Byte übersprungen.
// Überprüfen ob alle nötigen Informationen gefunden wurden
if (!fmtFound || !dataFound) {
.println("WAV-Header unvollständig oder Datenchunk nicht gefunden.");
Serialreturn false;
}
if (audioFormat != 1) {
.println("Nur PCM WAV-Dateien werden unterstützt.");
Serialreturn false;
}
if (bitsPerSample != 16) {
.println("Nur 16-Bit PCM wird unterstützt.");
Serialreturn false;
}
if (numChannels != 1 && numChannels != 2) {
.println("Nur Mono oder Stereo wird unterstützt.");
Serialreturn false;
}
Es wird überprüft, ob Format- und Data-Chunk gefunden wurden und ob die Datei das kompatible Format besitzt. Entspricht die Datei nicht dem vorgesetzten Format wird eine Fehlermeldung ausgeben und das Programm beendet.
// Ausgabedatei erstellen und Header schreiben
= SD_MMC.open("/gefiltert.wav", FILE_WRITE);
filteredFile if (!filteredFile) {
.println("Fehler beim Öffnen der Ausgabedatei.");
Serialreturn false;
}
(filteredFile, dataSize, numChannels, sampleRate, bitsPerSample);
writeWavHeader
.printf("Gesamtanzahl Frames (Samples pro Kanal): %d\n",
Serial/ (numChannels * bitsPerSample / 8));
dataSize .println("Starte Filterung...");
Serial
// Filterprozess starten
(numChannels, dataSize, bitsPerSample);
filterAudio
.println("Filterung abgeschlossen.");
Serial
.close();
wavFile.close();
filteredFile
return true;
Die Ausgabedatei “gefiltert.wav” wird im Schreibmodus erstellt und erhält einen neuen WAV-Header mit den identischen Parametern der Eingangsdatei. Würde man keinen neuen WAV-Header erstellen, sondern den bestehenden verwenden, würde die resultierende Datei fehlerhaft enden. Es werden die Anzahl der Frames berechnet und ausgeben, und im Anschluss wird die Sample-für-Sample Filterung über die filterAudio()
-Funktion gestartet. Nach der Filterung werden beide Dateien geschlossen und endet damit das Programm.
9.2 Filterprozess der WAV-Datei
// --- Filterprozess auf Samples der WAV anwenden ---
void filterAudio(uint16_t numChannels, uint32_t dataSize, uint16_t bitsPerSample) {
uint16_t blockAlign = numChannels * bitsPerSample / 8;
const int bufferFrames = 512;
int16_t buffer[bufferFrames * numChannels]; // Zwsichenspeicher für Samples
uint32_t totalFrames = dataSize / blockAlign;
uint32_t framesLeft = totalFrames;
while (framesLeft > 0) {
int framesToRead = (framesLeft > bufferFrames) ? bufferFrames : framesLeft;
int bytesToRead = framesToRead * blockAlign;
// Rohdaten lesen
int bytesRead = wavFile.read((uint8_t*)buffer, bytesToRead);
if (bytesRead != bytesToRead) {
.println("Fehler beim Lesen der Samples.");
Serialbreak;
}
int samplesInBuffer = bytesRead / 2; // 2 Bytes = 16 Bit pro Sample
// Samples einzeln filtern
for (int i = 0; i < samplesInBuffer; i += numChannels) {
if (numChannels == 1) {
float filteredSample = filterL.filter((float)buffer[i]);
[i] = constrain((int)filteredSample, -32768, 32767);
buffer}
else {
float filteredL = filterL.filter((float)buffer[i]);
float filteredR = filterR.filter((float)buffer[i + 1]);
[i] = constrain((int)filteredL, -32768, 32767);
buffer[i + 1] = constrain((int)filteredR, -32768, 32767);
buffer}
}
// Gefilterte Daten in neue Datei schreiben
.write((uint8_t*)buffer, bytesRead);
filteredFile-= framesToRead; framesLeft
Die filterAudio
-Funktion führt die Sample-für-Sample-Verabreitung der Audio-Daten durch.
Blockweise Verarbeitung: Die Audio-Daten werden in 512-Frame-Blöcken eingelesen, anstatt die gesamte Datei in den Speicher zu laden. Die ermöglicht auch die Verarbeitung großer Dateien mit begrenztem Arbeitsspeicher.
Filteranwendung: Jedes der Sample wird durch die digitalen Filter gesendet. Bei Mono wird jeweils jedes Sample durch eine einzige Filterinstanz (filterL
) verarbeitet. Bei Stereo liegen die 16-Bit-Samples im Interleaved-Format vor, bei welchem sich der linken und rechten Kanalsamples abwechseln(LRLRLR…). Die Schleife springt um numChannels
Positionen (i +=numChannels
), wodurch buffer[i]
stets den linken und buffer[i]
den rechten Kanal adressiert. Beide Kanäle werden durch separate Filterinstanzen (filterL
für links, filterR
für rechts) unabhängig voneinander gefiltert, was die Stereo-Trennung gewährleistet.
Wertebreichsschutz: Da digitale Filter die Amplitude verstärken können, werden die gefilterten Werte mit constrain()
auf den gültigen 16-Bit-Bereich begrenzt. Dies verhindert Übersteuerung und damit verbundene Verzerrungen.
Kontinuierliche Ausgabe: Die verarbeiteten Samples werden sofort in die Ausgabedatei geschrieben, wodurch ein kontinuierlicher Datenfluss ohne große Zwischenspeicherung gewährleistet wird.
9.3 Filterkaskadierung
Die Kaskadierung wird durchgeführt, indem die Filterung durch mehrere Stufen durchgeführt wird. Diese Stufen können durch Biquad-Filter oder Segmente einer SOS-Matrix definiert werden.
#define NUM_STAGES 2 // Anzahl der Filterstufen
// Erste Sektion
const float gain_s1 = 1.0;
const float b_coefficients_s1[] = { b_0_s1, b_1_s1, b_2_s1};
const float a_coefficients_s1[] = { a_0_s1, a_1_s1, a_2_s1};
// Zweite Sektion
const float gain_s2 = 1.0;
const float b_coefficients_s2[] = { b_0_s2, b_1_s2, b_2_s2};
const float a_coefficients_s2[] = { a_0_s2, a_1_s2, a_2_s2};
[NUM_STAGES] = {
BiquadFilterTDF2 filterL(b_coefficients_s1, a_coefficients_s1, gain_s1),
BiquadFilterTDF2(b_coefficients_s2, a_coefficients_s2, gain_s2),
BiquadFilterTDF2};
[NUM_STAGES] = {
BiquadFilterTDF2 filterR(b_coefficients_s1, a_coefficients_s1, gain_s1),
BiquadFilterTDF2(b_coefficients_s2, a_coefficients_s2, gain_s2),
BiquadFilterTDF2};
Anhand des Parameters NUM_STAGES
kann die Anzahl der Biquad-Sektionen definiert werden. Jede Sektion hat ihre eigenen b
- und a
-Koeffizienten sowie einen gain
-Parameter mit den entsprechenden Bezeichnungen. Die Filterinstanzierung erfolgt durch Arrays von Biquad-Filtern, wobei jedes Array-Elemement eine separate Filtersstufe mit eigenen Koeffizienten repräsentiert.
Werden identische Filterparameter in mehrere Filterstufen implementiert, wird die Filtercharakteristik der vorherigen Stufe verstärkt. Alternativ können Filter höherer Ordnung implementiert werden, indem die SOS-Matrix etappenweise auf die einzelnen Filterstufen aufgeteilt wird.
// Samples einzeln filtern - KASKADIERT
for (int i = 0; i < samplesInBuffer; i += numChannels) {
if (numChannels == 1) {
float sample = (float)buffer[i];
for (int s = 0; s < NUM_STAGES; s++) {
= filterL[s].filter(sample);
sample }
[i] = constrain((int)sample, -32768, 32767);
buffer}
else {
float left = (float)buffer[i];
float right = (float)buffer[i + 1];
for (int s = 0; s < NUM_STAGES; s++) {
= filterL[s].filter(left);
left = filterR[s].filter(right);
right }
[i] = constrain((int)left, -32768, 32767);
buffer[i + 1] = constrain((int)right, -32768, 32767);
buffer}
Bei der kaskadierten Filterung werden die Filterstufen in Serie Implementiert, bei welcher die Samples durch jede Stufe sequenziell verarbeitet werden. Das Ausgangssignal einer Stufe wird zum Eingangssignal der nächsten Stufe, wodurch eine erhöhte Filterordnung oder verstärkte Filterwirkung ermöglicht. Bei Mono druchläuft jedes Sample alle NUM_STAGES
Filterstufen des filterL
-Arrays, bei Stereo werden beide Kanäle parallel durch ihre jeweiligen Filterkaskaden(filterL
und filterR
) verarbeitet. Nach der kompletten Kaskadierung werden die gefilterten Daten auf den 16-Bit-Bereich begrenzt und zurückgeschrieben.
10 Implementierungserklärung der Biquad Filter in Micropython
Für die Implementierung der biquadratischen Filter in Micropython werden die Differenzengleichungen aus den theoretischen Grundlagen entnommen und in Code umgesetzt. Die Filter werden über das Einsetzen der b
und a
Koeffizienten implementiert. Die DF1, DF2 und TDF2 werden als Klassen umgesetzt.
10.1 Micropython-Native Optimierung
import micropython
@micropthon.native
def filter(self,x0):
Der @micropython.native
Decorator kompiliert die filter()
Methoden zu nativem Maschinencode, was eine erhebliche Leistungssteigerung bei rechenintensiven Operationen ermöglicht.
10.2 __slots__
Optimierung
__slots__=('b0', 'b1', 'b2', 'a2', 'gain')`
Alle Filterklassen verwenden __slots__
, um den Speicherverbrauch zu reduzieren und die Attributzugriffe zu beschleunigen. Dies verhindert, dass Python ein dynamisches Dictionary für jede Instanz erstellt, wodurch sowohl Speicher als auch Zugriffszeit gespart werden.
10.3 Direktform 1
10.3.1 Instanzvariablen
class BiquadFilterDF1:
__slots__ = ('b0', 'b1', 'b2', 'a1', 'a2', 'gain', 'x1', 'x2', 'y1', 'y2')
Die Filterkoeffizienten b0, b1, b2, a1, a2
werden nach der Initialisierung im Konstruktor nicht mehr verändert, was der mathematischen Definition eines zeitinvarianten Systems entspricht. Die Verzögerungselemente x1, x2
speichern die vorherigen Eingangswerte, während y1, y2
die vorherigen Ausgangswerte speichern. Der Wert a0
wird nicht gespeichert, da durch die Normalisierung im Konstruktor alle Koeffizienten bereits durch a[0]
geteilt wurden.
10.3.2 Konstruktor
def __init__(self, b, a, gain=1):
self.gain = gain
self.b0 = self.gain * (b[0] / a[0])
self.b1 = self.gain * (b[1] / a[0])
self.b2 = self.gain * (b[2] / a[0])
self.a1 = a[1] / a[0]
self.a2 = a[2] / a[0]
self.x1 = 0.0
self.x2 = 0.0
self.y1 = 0.0
self.y2 = 0.0
Der Konstruktor normalisiert alle Koeffizienten druch die Division mit a[0]
, wodurch die Standardform der Differenzengleichung erreicht wird. Dies entspricht der Standardform, wo der führende Koeffizient des Nenners auf 1 normiert wird. Der gain
Parameter wird in die Zählerkoeffizienten eingerechnet, was mathematisch äquivalent zur Multiplikation der gesamten Übertragungsfunktion mit dem Verstärkungsfaktor ist. Die Verzögerungselemente werden auf 0.0
initialisiert, was einem System ohne vorherigen Werte entspricht.
10.3.3 Filter Methode
@micropython.native
def filter(self, x0):
= (self.b0 * x0 + self.b1 * self.x1 + self.b2 * self.x2
y0 - self.a1 * self.y1 - self.a2 * self.y2)
self.x2 = self.x1
self.x1 = x0
self.y2 = self.y1
self.y1 = y0
return y0
Für die Implementierung der DF1 wird die Differenzengleichung dieser Struktur direkt implementiert: y[n] = b0 * x[n] + b1 * x[n-1] + b2 * [x-2] - a1 * y[n-1] - a2 * y[n-2]
. Anschließend werden die Verzögerungselemente für die nächste Iteration aktualisiert. Die Eingangsverzögerung werden durch self.x2 = self.x1
und self.x1 = x0
verschoben, während die Ausgangsverzögerung durch self.y2=self.y1
und self.y1 = y0
aktualisiert werden. Diese Reihenfolge ist kritisch, da die Berechnung vor der Aktualisierung der Ausgangswerte erfolgen muss.
10.4 Direktform 2
10.4.1 Instanzvariablen
class BiquadFilterDF2:
__slots__ = ('b0', 'b1', 'b2', 'a1', 'a2', 'gain', 'w0', 'w1', 'w2')
Die DF2-Struktur benötigt drei Verzögerungselemente w0, w1, w2
statt der vier in der DF1. Dies reduziert den Speicherbedarf gegenüber der DF1. Die w
Variablen repräsentieren die internen Knotenpunkte der DF2-Struktur, wo sowohl die Rückkopplung als auch die Vorwärtskopplung zusammenlaufen.
10.4.2 Konstruktor
def __init__(self, b, a, gain=1):
= gain
slef.gain self.b0 = self.gain * (b[0] / a[0])
self.b1 = self.gain * (b[1] / a[0])
self.b2 = self.gain * (b[2] / a[0])
self.a1 = a[1] / a[0]
self.a2 = a[2] / a[0]
self.w0 = 0.0
self.w1 = 0.0
self.w2 = 0.0
Der Konstruktor ist geradezu identisch zur DF1 Implementierung, da die Koeffizientennormalisierung unabhängig von der internen Filterstruktur ist. Der Unterschied liegt in der Vordefinition der Verzögerungselemente self.w0, self.w1, self.w2
, welchen der Wert von 0.0 zugewiesen wird.
10.4.3 Filter Methode
@micropython.native
def filter(self, x0):
= self.b0 * self.w0 + self.b1 * self.w1 + self.b2 * self.w2
y0 self.w0 = x0 - self.a1 * self.w1 - self.a2 * self.w2
self.w2 = self.w1
self.w1 = self.w0
return y0
Die DF2 Implementierung teilt die Berechnung in zwei Phasen: Zuerst wird der Ausgang aus den aktuellen Zustandsvariablen und den Zählerkoeffizienten berechnet. Dies entspricht der Implementierung des Zählerpolynoms der Übertrangungsfunktion. Dann wird der neue Zustand self.w0
berechnet, der das Eingangssignal minus der Rückkopplung darstellt. Die Verzögerungselemente werden durch self.w2 = self.w1
und self.w1 = self.w0
für die nächste Iteration verschoben.
10.5 Transponierte Direktform 2
10.5.1 Instanzvariablen
class BiquadFilterTDF2:
__slots__ = ('b0', 'b1', 'b2', 'a1', 'a2', 'gain', 's1', 's2')
Die TDF2-Struktur verwendet Zustandsvariablen s1, s2
, die als “shift register” fungieren. Diese Struktur ist die transponierte Version der DF2, was bedeutet, dass der Signalfluss umgekehrt wird: die Ein- und Ausgänge werden vertauscht, sowie die Richtung der Verzögerungselemente wird umgekehrt.
10.5.2 Konstruktor
def __init__(self, b, a, gain=1):
= gain
slef.gain self.b0 = self.gain * (b[0] / a[0])
self.b1 = self.gain * (b[1] / a[0])
self.b2 = self.gain * (b[2] / a[0])
self.a1 = a[1] / a[0]
self.a2 = a[2] / a[0]
self.s1 = 0.0
self.s2 = 0.0
Auch hier ist der Konstruktor identisch zu den anderen Implementierungen, da die Koeffizientennormalisierung eine mathematische Anforderung ist, die unabhängig von der gewählten Realisierungsform gilt. In diesem Konstruktor werden die Variablen self.s1, self.s2
auf 0.0 gesetzt.
10.5.3 Filter Methode
@micropython.native
def filter(self, x0):
= self.b0 * x0 + self.s1
y0 self.s1 = self.s2 + self.b1 * x0 - self.a1 * y0
self.s2 = self.b2 * x0 - self.a2 * y0
return y0
Bei der TDF2 Implementierung setzt sich der Ausgangswert aus der Addition aus dem ersten Zustandsregister self.s1
mit dem verstärkten Eingangswert zusammen. Die Zustandsregister werden dann für die nächste Iteration aktualisiert. self.s1
wird zum nächsten Wert von self.s2
addiert mit den gewichteten Ein- und Ausgangswerten. self.s2
wird komplett neu berechnet. Diese Struktur hat den Vorteil, dass der Ausgangswert sehr früh im Berechnungszyklus verfügbar ist, was bei Pipeline Implementierung vorteilhaft ist.
10.6 Filter Anwendung
from biquad import BiquadFilterTDF2
Für die Verwendung der Biquad-Filter wird aus der biquad.py die gewünschte Filterimplementierung importiert.
= [1.0, 0.5, 0.25]
b = [1.0, -0.3, 0.1]
a = 1.0
gain
= BiquadFilterTDF2(b, a, gain) biquad
Die Koeffizienten werden in separaten Listen gespeichert und an den Konstruktor übergeben. Über den gain
Parameter kann auf den zu implementierenden Filter eine gewünschte Verstärkung angewandt werden. Bei dieser Filter Instanziierung wird die TDF2 verwendet mit ihren entsprechenden Klassennamen. Somit wird das Objekt biquad
durch die Klasse BiquadFilterTDF2
instanziiert. Innerhalb des Objekts werden die Koeffizienten mit dem Verstärkungsfaktor an die jeweilige Filterklasse übergeben und der Filter steht bereit zur Verwendung.
= biquad.filter(input) filtered
Die direkte Anwendung erfolgt durch den Aufruf der filter()-Methode auf dem Filterobjekt biquad
. Der Eingangswert input
wird der Methode übergeben, durch die spezifische Filterimplementierung entsprechend der definierten Differenzengleichung verarbeitet und als transformierter Ausgangswert filtered
zurückgegeben.
11 Filterung einer WAV Datei in Micropython
Zur Demonstration von digitalen Filtern in Micropython wird eine WAV-Datei mittels eines ESP32 gefiltert. Die Audio-Datei wird über eine SD-Karte eingelesen, gefiltert und als gefilterte Variante abgespeichert.
Zu beachten ist, dass nur 16-Bit-WAV-Dateien unterstützt werden. Die Datei kann jedoch Mono oder Stereo mit und einer beliebigen Samplerate sein. Die WAV-Datei, die gefiltert werden soll, muss zu input.wav
umbenannt werden bevor diese auf die SD-Karte übertragen wird.
11.1 Vorbereitung des Programms
import micropython, struct, os,
from machine import SDCard, freq
from biquad import BiquadFilterTDF2
Für die Filterung einer WAV-Datei werden die folgenden Module eingebunden: micropython
ermöglicht die Optimierung durch native Deklaration, struct
dient der binären Datenverarbeitung, os
wird für Dateizugriffe genutzt und SDCard
stellt die Schnittstelle zur SD-Karte bereit, während mit freq
die CPU-Frequenz gesetzt werden kann. Aus der erstellten biquad.py
wird die BiquadFilterTDF2
importiert.
240000000) freq(
Die CPU-Frequenz wird auf 240 MHz gesetzt, um maximale Rechenleistung des ESP32 für die Filterverarbeitung zu gewährleisten.
=1, width=4), "/sd") os.mount(SDCard(slot
Die SD-Karte wird über das SPI-Interface mit 4-Bit Breite gemountet, um höhere Datenübertragungsraten zu ermöglichen.
11.2 Konfiguration der Audioverarbeitung
Audio-Parameter
= "/sd/input.wav"
INPUT = "/sd/output_filtered.wav"
OUTPUT = 44
HEADER_SIZE = 2
CHANNELS = 16
BITS = BITS // 8
BYTES_PER_SAMPLE = CHANNELS * BYTES_PER_SAMPLE
BYTES_PER_FRAME = 13200 # Je nach RAM anpassen
BLOCK_FRAMES = BLOCK_FRAMES * BYTES_PER_FRAME BLOCK_SIZE
Die Konstanten definieren die Audioparameter für Stereo-WAV-Dateien mit 16-Bit Auflösung. Die BLOCK_FRAMES
beeinflussen den Speicherverbrauch vom RAM und die Verarbeitungseffizienz. Der HEADS_SIZE
von 44 Bytes entspricht der Standardgröße eines WAV-Headers.
Filter-Instanziierung
= [0.07033, -0.1380, 0.07033]
b = [1.00000, -0.1380, -0.8593]
a = 1.0
gain = BiquadFilterTDF2(b, a, gain)
filtL = BiquadFilterTDF2(b, a, gain) filtR
Für die Filterung vom Stereo-Format werden zwei Filterinstanzen, filtL für den linken Kanal und filtR für den rechten Kanal, erstellt.
11.3 WAV-Header Erstellung
def write_header(f, frames):
= 44100
rate = rate * CHANNELS * BYTES_PER_SAMPLE
byte_rate = CHANNELS * BYTES_PER_SAMPLE
align = frames * align
size "<4sI4s4sIHHIIHH4sI",
f.write(struct.pack(b'RIFF', 36 + size, b'WAVE',
b'fmt ', 16, 1, CHANNELS, rate,
byte_rate, align, BITS,b'data', size
))
Die write_header()
Funktion generiert einen standardkonformen WAV-Header im Little-Endian Format. Der Header enthält alle notwendigen Informationen: Dateigröße, Audioformat(PCM), Kanalanzahl, Abtastrate und Bit-Tiefe. Das struct.pack()
mit "<4sI4s4sIHHIIHH4sI"
Format sorgt für die korrekte Byte-Reihenfolge auf verschiedenen Plattformen.
11.4 Blockverarbeitung
@micropython.native
def process_block_native(read_mv, write_mv, sample_count, filtL, filtR):
for i in range(0, sample_count, 2):
# Samples einlesen (Little Endian)
= read_mv[i*2] | (read_mv[i*2 + 1] << 8)
sL = read_mv[i*2 + 2] | (read_mv[i*2 + 3] << 8)
sR if sL >= 32768:
-= 65536
sL if sR >= 32768:
-= 65536 sR
Zur Optimierung wird mit dem @micropython.native
Decorator die Verarbeitungsschleife zu nativem Maschinencode kompiliert. Die Verwendung von memoryview
Objekten vermeidet Speicherkopien und reduziert die Garbage Collection. Die Schleife iteriert in 2er-Schritten durch sample_count
, die jeweils ein Stereo-Sample verarbeitet wird.
Sample-Konvertierung
Die 16-Bit-Samples werden aus dem Byte-Array im Little-Endian Format rekonstruiert. Die bitweise OR-Operation kombiniert die Low- und High-Bytes: das erste Byte wird als niederwertige Bits verwendet, das zweite Byte wird um 8 nach links verschoben für die höherwertigen Bits. Die anschließende Überprüfung konvertiert unsigned 16-Bit Werte in signed Werte durch die Subtraktion von 65536 für Werte über 32767.
11.5 Filterung und Clamping
= int(filtL.filter(sL))
l = int(filtR.filter(sR))
r
if l > 32767: l = 32767
elif l < -32768: l = -32768
if r > 32767: r = 32767
elif r < -32768: r = -32768
"<hh", write_mv, i*2, l, r) struct.pack_into(
Bei der Filterung werden die Samples auf ganze Zahlen gerundet und auf den gültigen 16-Bit Bereich begrenzt. Das Clamping verhindert Überläufe und damit verbundene Verzerrung im Ausgangssignal. Die Begrenzung ist notwendig, da die Filteroperation Werte außerhalb des ursprünglichen Bereichs erzeugen kann. Abschließend werden die verarbeiteten Samples mit struct.pack_into()
direkt in den Ausgabepuffer geschrieben. Das Format "<hh"
definiert zwei signed 16-Bit Integers im Little-Endian Format.
11.6 Hauptverarbeitung
= bytearray(BLOCK_SIZE)
read_buf = bytearray(BLOCK_SIZE)
write_buf = memoryview(read_buf)
read_mv = memoryview(write_buf) write_mv
Separate Read- und Write-Puffer ermöglichen effiziente Ein- und Ausgabeoperationen ohne Interferenz. Die memoryview
Objekte bieten direkten Zugriff auf die Pufferdaten ohne Kopieroperationen, wodurch die Performance verbessert wird.
with open(INPUT, "rb") as fin, open(OUTPUT, "wb") as fout:
fin.seek(HEADER_SIZE)0)
write_header(fout,
while True:
= fin.readinto(read_buf)
read_bytes if read_bytes == 0:
break
= read_bytes // 2 # 2 Bytes pro Sample (16 Bit)
sample_count
# Verarbeitung mit native-Code
process_block_native(read_mv, write_mv, sample_count, filtL, filtR)
fout.write(write_mv[:read_bytes])+= read_bytes // BYTES_PER_FRAME
frames = progress(frames, total, last) last
Über den with
Context Manager wird die Eingangsdatei geöffnet. Zunächst wird der WAV-Header der Eingangsdatei mit fin.seek(HEADER_SIZE)
übersprungen und ein temporärer Header mit 0 Frames in die Ausgabedatei geschrieben. Die Verarbeitungsschleife liest die Datenblöcke in den Eingangspuffer mit readinto()
, was Speicherallokationen vermeidet. Bei read_bytes = 0
wird das Dateiende erreicht. Der sample_count
wird durch die Division der gelesenen Bytes durch 2 gerechnet, da jedes 16-Bit Sample 2 Bytes belegt. Nach der nativen Filterung wird der Ausgabepuffer in die Datei geschrieben, wobei write_mv[:read_bytes]
sicherstellt, dass nur die gefilterten Bytes in die Ausgangsdatei geschrieben werden.
# Header nachträglich korrigieren
0)
fout.seek( write_header(fout, frames)
Nach Abschluss der Verarbeitung wird der Header mit der korrekten Anzahl verarbeiteter Frames überschrieben. Dieser Schritt ist notwendig, da die finale Dateigröße erst nach vollständiger Verarbeitung bekannt ist. Mit seek(0)
wird der Dateizeiger auf den Anfang der Header-Aktualisierung positioniert.
12 Echtzeit-Audiofilterung für Mikrofoneingang
Zur Demonstration der Echtzeitfilterung mit biquadratischen Filtern wird ein Mikrofonsignal digital gefiltert. Die Filterung wird mit einem ESP Lyrat 4.3 durchgeführt und mithilfe der audio-tools sowie den audio-board-driver Bibliotheken wird das Programm verfasst. Die Filter.h
wurde für die Nutzung der transponierten Direktform 2 modifiziert, indem diese innerhalb des Bibliotheksverzeichnisses ausgetauscht wird.
12.1 Codeerklärung der Echtzeitfilterung
#include "AudioTools.h"
#include "AudioTools/AudioLibs/AudioBoardStream.h"
Für die Echtzeitfilterung werden die AudioTools.h
und die AudioBoardStream.h
eingebunden. Die AudioBoardStream.h
ermöglicht einen I2S-Stream zwischen dem Audio-Codec des LyRaT mit dem ESP32 und die AudioTools
ermöglichen die Verarbeitung von diesem Audiostream.
(44100, 2, 16);
AudioInfo info(LyratV43);
AudioBoardStream lyrat<int16_t, float> filtered(lyrat, info.channels);
FilteredStream(lyrat, filtered); StreamCopy copier
Die Audioparameter werden durch AudioInfo
mit 44,1kHz, Stereo und 16-Bit definiert. Durch den Parameter LyratV43
in AudioBoardStream
wird die Boardinitialisierung vorbereitet. Der gefilterte Stream filtered
nutzt den lyrat
Stream als Eingang und wird in Stereo gefiltert. Der StreamCopy copier
übernimmt die kontinuierliche Übertragung der gefilterten Audiodaten vom Eingangssignal zur Audioausgabe des LyRaT.
const float b[] = {1.0, 0.0, 0.0};
const float a[] = {1.0, 0.0, 0.0};
const float gain = 1.0;
Die Filterkoeffizienten werden durch drei Arrays definiert. b[]
stellt die Zählerkoeffizienten des Biquads dar und a[]
enthält die Nennerkoeffizienten. Der Verstärkungsfaktor wird durch gain
festgelegt. Mit diesen Werten wird der Filter implementiert, welcher den I2S-Stream verarbeiten soll.
void setup(void) {
.begin(115200);
Serial.setFilter(0, new BiQuadTDF2<float>(b, a, gain));
filtered.setFilter(1, new BiQuadTDF2<float>(b, a, gain));
filteredauto config = lyrat.defaultConfig(RXTX_MODE);
.input_device = ADC_INPUT_LINE1;
config.begin(config);
lyrat}
Die Setup-Funktion beginnt mit der Initialisierung der seriellen Kommunikation mit 115200 Baud. Anschließend wird der Audio-Stream des LyRaT mit der Standard-TX-Konfiguration initialisiert. Für beide Audiokanäle werden separate Biquad-Filter in transponierter Direktform 2 erstellt, wobei der linke Kanal über Index 0 und der rechte Kanal über Index 1 angesprochen wird. Der Audio-Stream des Lyrat wird initialisiert, wobei die Eingabequelle auf ADC_INPUT_LINE1
gesetzt wird, um die Onboard-Mikrofone als Audioquelle zu verwenden.
void loop() {
.copy();
copier}
In der Loop-Funktion wird kontinuierlich copier.copy()
aufgerufen, um die gefilterten Audiodaten vom Eingang zur Ausgabe zu übertragen und eine unterbrechungsfreie Echtzeit-Verarbeitung zu gewährleisten.
12.2 Filterkaskadierung
Die Filterkaskadierung erfolgt durch die serielle Anordnung mehrerer Filterstufen. Jede Stufe kann durch Biquad-Filter oder einzelne Sektionen einer SOS-Matrix realisiert werden.
.setFilter(0, new FilterChain<float, 3>({
filterednew BiQuadTDF2<float>(b, a, gain),
new BiQuadTDF2<float>(b, a, gain),
new BiQuadTDF2<float>(b, a, gain)
}));
.setFilter(1, new FilterChain<float, 3>({
filterednew BiQuadTDF2<float>(b, a, gain),
new BiQuadTDF2<float>(b, a, gain),
new BiQuadTDF2<float>(b, a, gain)
}));
Bei der Filterinstanziierung werden die Filterstufen mittels FilterChain<float, 3>
definiert. Die einzelnen Biquad-Stufen können dabei entweder identische Koeffizienten verwenden oder unterschiedliche Koeffizientensätze erhalten, um Filter höherer Ordnung zu implementieren.
13 Praktische Anwendung der Bilinear-Transformation
Die analogen Biquad Filter für die Anwendung der Bilineartransformation werden aus dem Experiment 4 des Analog Systems Lab Kit PRO entnommen.
13.1 Übertragungsfunktionen der analogen Biquad Filter
Tiefpass: \(H_{TP}(s) = \frac{V_3}{V_i} = \frac{+H_0}{1 + \frac{s}{w_0Q} + \frac{s^2}{w_0^2}} = \frac{+H_0 \cdot w_0^2}{w_0^2 + \frac{sw_0}{Q} + s^2}\)
Hochpass: \(H_{HP}(s) = \frac{V_1}{V_i} = \frac{H_0 \cdot \frac{s^2}{w_0^2}}{1 + \frac{s}{w_0Q} + \frac{s^2}{w_0^2}} = \frac{+H_0 \cdot s^2}{w_0^2 + \frac{sw_0}{Q} + s^2}\)
Bandpass: \(H_{BP}(s) = \frac{V_2}{V_i} = \frac{-H_0 \cdot \frac{s}{w_0}}{1 + \frac{s}{w_0Q} + \frac{s^2}{w_0^2}} = \frac{-H_0 \cdot sw_0}{w_0^2 + \frac{sw_0}{Q} + s^2}\)
Bandsperre: \(H_{BS}(s) = \frac{V_4}{V_i} = \frac{(1 + \frac{s^2}{w_0^2 \cdot}) \cdot H_0}{1 + \frac{s}{w_0Q} + \frac{s^2}{w_0^2}} = \frac{(w_0^2 + s^2) \cdot H_0}{w_0^2 + \frac{sw_0}{Q} + s^2}\)
13.2 Analoge Filterschaltung
Um diese analogen Filter Bilinear zu transformieren stehen MATLAB sowie Python zur Verfügung, um die Durchführung der Substitutionen durchzuführen.
13.3 Python
Python mit scipy.signal
= bilinear(nums ,dens ,fs = fs) numz, denz
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import freqs, bilinear, freqz
Für die Bilinear-Transformation wird die Scipy-Bibliothek mit dem Signal-Modul verwendet. Aus diesem Modul werden die Funktionen freqs, bilinear und freqz importiert. Für weitere Berechnungen wird Numpy als np importiert und für die Möglichkeit zum Plotten wird Matplotlib.pyplot als plt importiert.
= 1000
R = 100e-9
C = 1 / (R * C)
w0 = 4.7
Q = 44100 fs
Aus dem Schaltbild der analogen Filterschaltung werden dessen Parameter entnommen. Die Kreisfrequenz \(w_0\) berechnet sich durch \(w_0 = \frac{1}{R C}\). Der Wert \(H_0\) beschreibt den Gainfaktor der Filterschaltungen und wird auf 1 gesetzt, wodurch dieser nicht mehr im Code aufkommt. Die digitalen Filter sollen für Audioanwendungen verwendet werden, weshalb die Wahl von 44,1 kHz als Abtastfrequenz getroffen wurde. Durch die Wahl der Abtastefrequenz von 44,1 kHz wird kein Prewarping benötigt, da die Frequenzverzerrung vernachlässigbar ist.
# Tiefpass Zähler
= [0, 0, w0**2]
TP_nums
# Hochpass Zähler
= [1, 0, 0]
HP_nums
# Bandpass Zähler
= [0, -w0, 0]
BP_nums
# Bandstop Zähler
= [1, 0, w0**2]
BS_nums
# Nenner
= [1, w0/Q, w0**2] dens
Die Zähler und Nenner der Übertragungsfunktionen der analogen Filter werden als Listen übernommen. Die Listenelemente entsprechen den Koeffizienten in absteigender Potenzordnung: \([s^2, s^1, s^0]\). Die Nenner der vier Filter sind identisch und müssen nur einmal übernommen werden.
# Bilineartransformation
# Tiefpass
= bilinear(TP_nums, dens, fs = fs)
TP_numz, TP_denz
# Hochpass
= bilinear(HP_nums, dens, fs = fs)
HP_numz, HP_denz
# Bandpass
= bilinear(BP_nums, dens, fs = fs)
BP_numz, BP_denz
# Bandstop
= bilinear(BS_nums, dens, fs = fs) BS_numz, BS_denz
Die Bilineartransformation wird durch bilinear
durchgeführt, indem dieser Funktion die Zähler- und Nennerlisten der jeweiligen Filter mit der Abtastfrequenzen \(f_s\) übergeben werden. Die Funktion liefert die Zähler- und Nennerkoeffizienten der digitalen Übertragungsfunktion.
14 Filterentwurf in Python
Für den Entwurf von digitalen IIR-Filtern wird in Python die Scipy-Bibliothek mit dem Signal-Modul verwendet. Diese Bibliothek bietet über eine umfassende Sammlung von Funktionen für den Entwurf und die Analyse von digitalen Filtern. Für den Entwurf von digitalen Biquad-Filtern wird das Entwurfsverfahren mit analogem Prototyping durchgeführt.
from scipy.signal import butter, cheby1, cheby2, ellip
Das Entwurfsverfahren basiert auf der Transformation analoger Filterprototypen, bei welchem ein analoger Filter mit den gewünschten Eigenschaften entworfen wird und anschließend durch die Bilineartransformation mit Prewarping in den digitalen Bereich überführt wird.
Zur beispielhaften Nutzung der Funktionen werden die vier Grundfiltertypen nach den folgenden Parametern entworfen:
# Ordnung für Tief- und Hochpass
= 4
order # Ordnung für Bandpass und Bandsperre
= 2 order_b
Zur Demonstration werden Filter vierter Ordnung entworfen, um die Unterschiede zwischen der Filterprotypen zu verdeutlichen.
Für den Entwurf von biquadratischen Tief- und Hochpass-Filter wird die Ordnung auf den Wert 2
gesetzt. Bei Bandpass und Bandsperr-Filtern muss die Ordnung auf 1
gesetzt werden, um einen biquadratischer Filter zu erhalten.
Beim Entwurf von Bandpass- und Bandsperr-Filtern wird eine Frequenztransformation eines Tiefpass-Prototyps durchgeführt, bei welcher jede s-Variable quadriert wird, wodurch sich die Filterordnung automatisch verdoppelt.
# Abtastrate für Audioanwendungen in Hz - Standard für CD-Qualität
= 44100 fs
# Grenzfrequenz für Tief- und Hochpass in Hz
= 1000
fc # Normalisierte Grenzfrequenz
= fc/(fs/2) wn
# Untere und obere Grenzen für Bandpass und Bandsperre in Hz
= 500
low = 2000
high # Normalisierte untere und obere Grenzen
= [low/(fs/2), high/(fs/2)] wn_b
# Welligkeit(Ripple) im Durchlassbereich in dB
= 0.5
rp # Dämpfung im Sperrbereich in dB
= 80 rs
Über die Funtkionen butter
, cheby1
, cheby2
und ellip
werden die dementsprechenden Filter enworfen. Über den Parameter btype
wird entschieden, welcher Filtertyp entworfen wird durchs Einsetzen von low
, high
, bandpass
und bandstop
.
Die Koeffizienten können als separate b
und a
Arrays oder als eine Second Order System (SOS)-Matrix durch output = 'sos'
ausgegeben werden. Mit der SOS-Matrix bietet sich die Möglichkeit auch Filter höherer Ordnung, als kaskadierte Biquad-Sektionen zu realisieren. Für den Entwurf von digitalen Filtern wird beim Parameter analog = False
gesetzt.
Mit dem Parameter rp
lässt sich die Welligkeit (Ripple) im Durchlassbereich von Chebyshev1- und Elliptischen Filtern kontrollieren und mit dem Parameter rs
die Dämpfung im Sperrbereich von Chebyshev2- und Elliptischen Filtern.
Butterworth-Filter
Der Butterwoth-Filter zeichnet sich aus mit seiner maximalen Flachheit im Durchlassbereich ohne Welligkeit aus. Als Kompromiss weist dieser einen relativ langsamen Übergang vom Durchlass- zum Sperrbereich auf. Er bietet ein ausgewogenes Verhältnis zwischen Phasenverhalten und Flankensteilheit.
= butter(N = order, Wn = wn, btype = 'low', analog = False)
b, a = butter(N = order, Wn = wn, btype = 'high', analog = False)
b, a = butter(N = order_b, Wn = wn_b, btype = 'bandpass',
sos = False, output = 'sos')
analog = butter(N = order_b, Wn = wn_b, btype = 'bandstop',
sos = False, output = 'sos') analog
Chebyshev Typ 1-Filter
Der Chebyshev Typ 1-Filter weist Welligkeit im Durchlassbereich auf, ermöglicht jedoch einen steileren Übergang im Vergleich zum Butterworth-Filter. Die Wellikgkeit ist gleichmäßig über den gesamten Durchlassbereich verteilt.
= cheby1(N = order, rp = rp,Wn = wn, btype = 'low', analog = False)
b, a = cheby1(N = order, rp = rp, Wn = wn, btype = 'high', analog = False)
b, a = cheby1(N = order_b, rp = rp, Wn = wn_b, btype = 'bandpass',
sos = False, output = 'sos')
analog = cheby1(N = order_b, rp = rp, Wn = wn_b, btype = 'bandstop',
sos = False, output = 'sos') analog
Chebyshev Typ 2-Filter
Der Chebyshev Typ 2-Filter kombiniert einen flachen Durchlassbereich, vergleichbar mit dem Butterworth-Filter, mit Welligkeit im Sperrbereich. Bei gleichmäßiger Sperrbereichsdämpfung ermöglicht dieser steile Übergangsflanken.
= cheby2(N = order, rs = rs,Wn = wn, btype = 'low', analog = False)
b, a = cheby2(N = order, rs = rs, Wn = wn, btype = 'high', analog = False)
b, a = cheby2(N = order_b, rs = rs, Wn = wn_b, btype = 'bandpass',
sos = False, output = 'sos')
analog = cheby2(N = order_b, rs = rs, Wn = wn_b, btype = 'bandstop',
sos = False, output = 'sos') analog
Elliptischer Filter (Cauer)
Der elliptische Filter bietet die steilsten Übergangsflanken aller dargestellten Filtertypen, weist jedoch Welligkeiten sowohl im Durchlass- als auch im Sperrbereich auf. Dieser eignet sich Optimal für Anwendungen mit strengen Anforderungen an die Übergangsanforderungen.
= ellip(N = order, rp = rp, rs = rs,Wn = wn, btype = 'low', analog = False)
b, a = ellip(N = order, rp = rp, rs = rs, Wn = wn, btype = 'high', analog = False)
b, a = ellip(N = order_b, rp = rp, rs = rs, Wn = wn_b, btype = 'bandpass',
sos = False, output = 'sos')
analog = ellip(N = order_b, rp = rp, rs = rs, Wn = wn_b, btype = 'bandstop',
sos = False, output = 'sos') analog
Das Notebook filter_desing.ipynb
ermöglicht eine Vergleichsanalyse der verschiedenen Entwurfsmethoden unter indentischen Parametern und bietet eine Basis für die Filterauswahl basierend auf den spezifischen Anwendungsanforderungen.
15 Pyfda - Pythonfilterdesigntool
Pyfda (Python Filter Design and Analysis) ist ein Open-Source-Tool, das als grafische Benutzeroberfläche für den Entwurf und die Analyse digitaler Filter entwickelt wurde. Die Anwendung basiert auf den Python-Bibliotheken PyQt5, NumPy, SciPy sowie Matplotlib und bietet Funktionalitäten, die dem Filter Design Tool in MATLAB ähneln.
Die Installation von Pyfda erfolgt über den Python Package Manager:
pip install pyfda
Das Tool wird anschließend mittels des Befehls pyfdax
gestartet.
15.1 Demonstrativer Filterentwurf mit Frequenzgangbetrachung
Für die vorliegende Arbeit wurde ein Bandpassfilter 6. Ordnung mit einem Durchlassbereich von 300 Hz bis 3400 Hz entworfen. Diese Spezifikation entspricht dem Frequenzbereich der menschlichen Sprache und ist daher für Audioanwendungen von besonderer Relevanz.
Als Filterprototyp wurde ein elliptischer Filter (Cauer-Filter) gewählt, da dieser durch seine äqui-ripple-Charakteristik sowohl im Durchlass- als auch im Sperrbereich die steilsten Übergänge bei gegebener Filterordnung ermöglicht. Die Entwurfsparameter wurden wie folgt festgelegt:
- Welligkeit im Durchlassbereich: rp = 0.5 dB
- Dämpfung im Sperrbereich: rs = 90 dB
- Durchlassbereich: 300 Hz - 3400 Hz
Specs | b, a | P/Z | Info | Fixpoint |
---|---|---|---|---|
Spezifikation der Filterparamtern und des Entwurfsverfahrens. | Ausgabe der b - und a -Koeffizienten. |
Ausgabe der Pole und Nullstellen. | Frequenzanalyse, Parameterauflistung sowie Beschreibung des Filtertyps. | Konvertierung zur Festkomma-Quantisierung für die Direktform 1. |
Magnitude Response | Phase Response | Group Delay | Pole-Zero Plot |
---|---|---|---|
Zeigt den Betrag der Übertragungsfunktion \(|H(f)|\) in dB. | Zeigt die Phase von \(H(f)\) über der Frequenz. | Zeigt die Gruppennachlaufzeit \(\tau_g\) an. | Zeigt die Pole (×) und Nullstellen (○) in der z-Ebene. |
Obwohl Pyfda umfassende Entwurfs- und Analysemöglichkeiten bietet, beschränkt sich der Export auf die b
- und `ae-Koeffizienten sowie die Pol- und Nullstellen. Für die Implementierung auf Mikrocontrollern wird jedoch eine (Second-Order-Sections) SOS-Matrix benötigt, die eine numerisch stabilere Darstellung des Filters ermöglicht.
= ellip(N = 6,
sos = 0.5,
rp = 90,
rs = [300/(44100/2], 3400/(44100/2)]
Wn = 'bandpass',
btype = False,
analog = 'sos') output
Diese SOS-Matrix kann anschließend in die entsprechenden Arduino-Implementierungen (filter_wav_casceded.ino
oder filter_microphone_cascaded.ino
) integriert werden.
Zur Veranschaulichung der Filterwirkung wurden Jupyter Notebooks entwickelt, die im Verzeichnis filter_demonstration/visual/
verfügbar sind. Diese ermöglichen die Visualisierung von den Frequenzgängen sowohl der WAV-Dateien als auch der Echtzeitübertragung.
16 Evaluierung der Filterimplementierung auf Mikrocontrollern
16.1 Zielsetzung der Evaluierung
Ziel dieses Kapitels ist es, die praktische Umsetzung der digitalen Biquad-Filter hinsichtlich Verarbeitungsdauer, Strukturauswahl, Stabilität und Echtzeitfähigkeit systematisch zu bewerten. Dabei wird insbesondere der Vergleich zwischen MicroPython und Arduino untersucht. Darüber hinaus werden die verschiedene Filterstrukturen (Direktform 1, Direktform 2, transponierte Direktform 2) auf derselben Hardware verglichen. Abschließend wird die Verarbeitung eines IIR-Filters höherer Ordnung (6. Ordnung) implementiert und verarbeitet, um die Skalierbarkeit der Implementierung zu bewerten.
16.2 Testumgebung
Die Evaluierung wurde auf dem ESP32 basierten Lyrat V4.3 durchgeführt. Als Eingangssignal diente eine WAV-Datei mit folgenden Eigenschaften:
- Dateiname:
TF2_theme.wav
- Länge: 1 Minute 12 Sekunden (72 Sekunden)
- Abtastrate: 44,1 kHz
- Bittiefe: 16 Bit
- Kanäle: Stereo
- Gesamte Sample-Anzahl: 3.215.360
Das Audiosignal wurde vollständig geladen und jeweils kanalweise (links/rechts) durch einen digitalen Filter verarbeitet. Die Messung der Laufzeit erfolgte mit time.ticks_ms()
unter MicroPython. Für Vergleichbarkeit wurden je fünf Durchläufe pro Konfiguration durchgeführt.
16.3 Vergleich der TDF2: MicroPython vs. Arduino
Für den ersten Benchmark wurde eine Bandsperre zweiter Ordnung mit folgenden Designparametern implementiert:
- Filterart: IIR-Biquad (Bandsperre)
- Fs: 44,1 kHz
- low cutoff: 1250 Hz
- high cutoff: 1450 Hz
- Koeffizienten:
b = [0.07033, -0.1380, 0.07033]
a = [1.00000, -0.1380, -0.8593]
Dieser Filter wurde geziehlt grenzstabil Entworfen, um einen Vergleich zwischen der Stabilität der Strukturen zu Vergleichen.
16.3.1 Verarbeitungsdauer
Plattform | Struktur | Durchschnittliche Dauer | Spanne |
---|---|---|---|
MicroPython | TDF2 | 549,27s | 548,66 – 550,69s |
Arduino (C++) | TDF2 | 16,60s | 16,48 – 16,97s |
16.3.2 Interpretation
Die Ergebnisse zeigen eine massive Performance-Differenz zwischen den beiden Umgebungen: Die Arduino-Variante ist über 33-mal schneller als die MicroPython-Variante. Während die interpretierte Ausführung von MicroPython für einfache Skripte ausreichend ist, erweist sie sich bei Sample-genauer Signalverarbeitung als ungeeignet für Echtzeitanwendungen. Arduino bietet hingegen eine echtzeitnahe Ausführung mit direktem Hardwarezugriff und optimierter Kompilierung, wodurch die Umsetzung effizient und praxisnah gelingt.
16.4 Vergleich verschiedener Filterstrukturen auf Arduino
16.4.1 Übersicht der Filterstrukturen
- Direktform 1 (DF1): Klassische Struktur mit separater Verarbeitung von
x[n]
undy[n]
. - Direktform 2 (DF2): Speicheroptimierte Version mit gleichem Output, aber reduzierter Verzögerung.
- Transponierte Direktform 2 (TDF2): Numerisch stabilere Variante von DF2.
16.4.2 Verarbeitungszeitmessungen (Arduino)
Struktur | Verarbeitungszeiten (s) | Durchschnitt |
---|---|---|
TDF2 | 16,55 – 16,97 | 16,60 |
DF2 | 16,72 – 16,84 | 16,78 |
DF1 | 16,42 – 16,91 | 16,53 |
16.4.3 Funktionalität und Stabilität
Die Implementierungen von TDF2 und DF2 zeigten eine korrekte und deutlich wahrnehmbare Filterwirkung.
Die Implementierung mit DF1 führte hingegen nur zu einer minimalen Dämpfung, welche nicht dem Sperrbereich der Bandsperre entspricht. Eine Ursache könnte darin liegen, dass die Pole des Filters sehr nahe am Einheitskreis liegen. In Direktform 1 kann dies zu numerischer Instabilität und unzureichender Dämpfung führen, insbesondere bei Float-basierten Mikrocontrollerumgebungen mit begrenzter Genauigkeit.
16.4.4 Fazit Strukturen
Struktur | Durchschnitt (s) | Filterwirkung | Bewertung |
---|---|---|---|
TDF2 | 16,60 | Eindeutig, stabil | Empfohlen für Echtzeitanwendung |
DF2 | 16,78 | Eindeutig, stabil | Ebenfalls gut geeignet |
DF1 | 16,53 | Nur leichte Dämpfung | Nicht geeignet bei kritischen Filtern |
16.5 Erweiterung: Filterung mit sechster Ordnung (6 Biquad-Segmente in Kaskade)
16.5.1 Koeffizienten der Kaskade
Der entworfene Filter aus der PyFDA-Demonstration wurde für die Verarbeitszeitmessung zum Vergleich zwischen einfachen Biquads verwendet. Der Filter ist ein elliptischer Bandpass 6. Ordnung, wodurch die Filterung durch sechs Segmente ausgeführt wird.
1: [b0, b1, b2, a0, a1, a2]
Section 2: [...]
Section ...
6: [...] Section
16.5.2 Ergebnisse (Arduino TDF2, 6 Biquad-Sektionen)
Lauf | Dauer (s) |
---|---|
1 | 23,50 |
2 | 23,47 |
3 | 23,45 |
4 | 23,48 |
5 | 23,43 |
Ø | 23,47 |
16.5.3 Beobachtungen
- Im Vergleich zum einem einzelnen Biquad (16,60s) liegt die Verarbeitungszeitsteigerung bei ca. 41%.
- Dies entspricht einer annähernd linearen Skalierung pro zusätzlicher Biquad-Stage.
- Auch bei höherer Ordnung bleibt der Filter echtzeitfähig und speichereffizient.
16.6 Zusammenfassung der Evaluierung
Aspekt | MicroPython | Arduino C++ (ESP32) |
---|---|---|
Filtergeschwindigkeit | Extrem langsam | Echtzeitnah (16s) |
Strukturauswahl | Nur TDF2 praktikabel | TDF2 > DF2 > DF1 |
Stabilität (DF1) | n/a | Eingeschränkt bei bestimmten Koeffizienten |
Erweiterbarkeit (Kaskade) | Ungeeignet | Problemlos bis ≥ 6. Ordnung |
Entwicklungsaufwand | Gering | Mittel (C++) |
Geeignet für Praxis? | Nein | Ja |
Die durchgeführten Benchmarks bestätigen, dass die Implementierung digitaler Biquad-Filter auf dem ESP32 mit Arduino vollständig praxistauglich und echtzeitfähig ist. MicroPython hingegen ist für performante Audiobearbeitung ungeeignet, kann aber zum schnellen Prototyping sinnvoll sein. Die transponierte Direktform 2 stellt sich als beste Struktur durch ihre effizienz, stabilität und leichte skalierbarkeit heraus. Die erfolgreiche Anwendung eines Filters sechster Ordnung belegt zudem, dass auch komplexere Designs zuverlässig auf der Zielhardware ausführbar sind.
17 Gegenüberstellung von Mikrocontroller und FPGA Implementierung
Parallel zu dieser Bachelorarbeit wurde eine weitere Arbeit mit ähnlicher Fragestellung durchgeführt. Beide Arbeiten beschäftigen sich mit der praktischen Umsetzung digitaler IIR-Filter, unterscheiden sich jedoch grundlegend in der gewählten Implementierungsplattform. Während sich die eine Arbeit auf die softwarebasierte Realisierung mit FPGA konzentriert, steht in dieser Arbeit die softwarebasierte Realisierung mit Mikrocontrollern im Mittelpunkt.
Die Mikrocontroller-Implementierung erfolgt direkt in Arduino bzw. MicroPython. Die Biquad-Filter werden dabei auf Basis ihrer Differenzengleichungen manuell in Code umgesetzt. Verschiedene Strukturformen wie Direktform 1, Direktform 2 und deren transponierte Variante (TDF2) wurden explizit implementiert. Besonderheiten wie die manuelle Verwaltung interner Zustände oder Verzögerungsglieder sowie Optimierungen (z. B. durch __slots__
in MicroPython oder native Kompilierung einzelner Methoden) tragen zur Effizienzsteigerung bei.
Während Arduino eine vergleichsweise leistungsstarke Umsetzung ermöglicht und eine Echtzeitverarbeitung über die I2S-Schnittstelle grundsätzlich mittles vorhandener Bibliotheken ermöglicht, ist MicroPython aufgrund seiner Interpreterstruktur deutlich langsamer. Zusätzlich verhinderten fehlende I2S-Treiber in MicroPython eine Audioverarbeitung in Echtzeit. Daher beschränkte sich die MicroPython-Implementierung auf die Filterung zuvor gespeicherter WAV-Dateien.
Im Gegensatz dazu basiert die FPGA-Implementierung auf einem modellbasierten Entwicklungsansatz mittels MATLAB/Simulink und dem HDL Coder. Die Filterstrukturen, insbesondere Direct Form II transponiert mit Pipelining, werden als Simulink-Modelle aufgebaut und anschließend in synthesefähigen VHDL-Code überführt. Die resultierenden IP-Cores werden über AXI4-Stream-Schnittstellen in ein Vivado-Projekt eingebunden. Die Verarbeitung erfolgt hardwareseitig, was eine hohe Parallelität und Echtzeitfähigkeit erlaubt. Die PYNQ-Plattform ermöglicht darüber hinaus eine komfortable Steuerung und Visualisierung über Jupyter Notebooks mit Python. Ein geplanter Echtzeitbetrieb über die I2S-Schnittstelle des integrierten Audio-Codecs ADAU1761 konnte jedoch aufgrund von Kompatibilitätsproblemen zwischen den automatisch generierten AXI-Strukturen und den bestehenden I2S-Komponenten nicht umgesetzt werden.
Insgesamt zeigen beide Arbeiten komplementäre Lösungswege auf. Die Mikrocontroller-Variante punktet durch ihre Einfachheit, Offenheit und geringe Einstiegshürden, während die FPGA-basierte Lösung eine leistungsstarke und skalierbare Architektur für komplexe Anwendungen bereitstellt. Beide Umsetzungen verdeutlichen unterschiedliche Strategien zur Realisierung digitaler Filter und bieten eine fundierte Grundlage für zukünftige Lehr-, Forschungs- oder Entwicklungsprojekte.
18 Reflexion
Während der Bearbeitung der Arbeit traten mehrere unerwartete Herausforderungen auf, die zu wertvollen Lernerfahrungen führten und die Bedeutung praxisnaher Umsetzung deutlich machten.
Ein wesentlicher Rückschlag betraf die geplante Echtzeitfilterung von Audiosignalen über Mikrofoneingänge mit MicroPython. Die Grundidee war es, eine einfache, interpretierbare Umgebung zur Signalverarbeitung bereitzustellen. In der praktischen Umsetzung stellte sich jedoch heraus, dass kein passender Treiber für den Audio-Codec (ES8388) in MicroPython verfügbar war. Aufgrund fehlender Vorkenntnisse in der Treiberentwicklung und begrenzter Zeit war es nicht möglich, den Codec in MicroPython in Betrieb zu nehmen. Somit konnte die geplante Mikrofonverarbeitung in dieser Umgebung nicht umgesetzt werden. Diese Einschränkung verdeutlicht, wie wichtig ein direkter Zugriff auf die Hardware ist und wo die Grenzen von stark vereinfachten Programmiersprachen wie MicroPython bei hardwarenahen Anwendungen liegen.
Ein weiterer Reflexionspunkt betrifft die unerwartet aufwendige WAV-Dateiverarbeitung. Es wurde ursprünglich davon ausgegangen, dass der WAV-Header der Eingabedatei einfach übernommen werden könne. In der Praxis stellte sich jedoch heraus, dass ein vollständig neuer Header generiert werden musste, da bestimmte Felder (wie Dateigröße, Datenlänge und Subchunks) dynamisch angepasst werden müssen. Dies erforderte eine tiefere Auseinandersetzung mit dem WAV-Dateiformat und führte zu einem deutlich längeren und komplexeren Codeabschnitt als geplant. Gleichzeitig bot dieser Teil der Arbeit jedoch auch die Möglichkeit, sich intensiv mit binärer Datenverarbeitung und Dateistrukturen auseinanderzusetzen.
19 Schlussbetrachtung
Im Rahmen dieser Bachelorarbeit wurde die vollständige Implementierung und Evaluierung digitaler Biquad-Filter auf einem ressourcenbeschränkten Embedded-System, dem Lyrat V4.3 erfolgreich durchgeführt. Ziel war es, grundlegende Kenntnisse der digitalen Signalverarbeitung, insbesondere der digitalen Filtertechnik, praxisnah anzuwenden und auf reale Audioverarbeitungssysteme zu übertragen.
Ausgehend von einer fundierten theoretischen Auseinandersetzung mit den Eigenschaften, Strukturen und Entwurfsverfahren digitaler Biquads wurden praxisrelevante Filter in Python entworfen und analysiert. Die Implementierung auf Mikrocontroller-Ebene erfolgte sowohl in der Arduino-Umgebung als auch in MicroPython, wobei verschiedene Filterstrukturen (Direktform 1, Direktform 2, transponierte Direktform 2) zum Einsatz kamen.
Die durchgeführte Evaluierung zeigt, dass die Arduino-Umgebung deutlich überlegen ist, was Verarbeitungszeit, Echtzeitfähigkeit und praktische Anwendbarkeit betrifft. Während MicroPython sich gut für das prototypische Testen eignet, ist sie für leistungsaufwendige Audiosignalverarbeitung in Echtzeit nicht geeignet. Die TDF2-Struktur erwies sich als die stabilste und effizienteste Form der Filterumsetzung auf Mikrocontrollern. Auch die erfolgreiche Realisierung eines Kaskadenfilters sechster Ordnung demonstrierte die Skalierbarkeit des Konzepts ohne signifikanten Verlust an Performance.
Mit dieser Arbeit wurde gezeigt, dass sich anspruchsvolle digitale Signalverarbeitung auch auf leistungsschwacher Embedded-Hardware effektiv realisieren lässt, vorausgesetzt, Strukturwahl, Implementierung und Filterdesign sind sorgfältig aufeinander abgestimmt. Durch den konsequenten Bezug zur Praxis, kombiniert mit methodischem Vorgehen und objektiver Bewertung, leistet diese Arbeit einen Beitrag zur anwendungsorientierten Vermittlung digitaler Filtertechnik im Bereich der Mikrocontroller und Open-Scource.
20 Ausblick
Die im Rahmen dieser Arbeit entwickelte Implementierung digitaler Biquad-Filter auf Embedded-System bildet eine solide Grundlage für weiterführende Arbeiten in der digitalen Signalverarbeitung. Im nächsten Schritt bietet sich insbesondere die Erweiterung auf FIR-Filter (Finite Impulse Response) an. Diese stellen eine numerisch stabile Alternative zu IIR-Filtern dar und eignen sich durch ihre lineare Phasenlage besonders für Anwendungen in der Audiotechnik.
Darüber hinaus ließe sich die theoretische Basis deutlich vertiefen, etwa durch Inhalte aus dem Masterstudium wie Multiraten-Systeme, Filterbänke, Spektralschätzverfahren oder adaptive Filteralgorithmen. Eine solche Erweiterung der Theorie würde nicht nur eine umfangreichere Analyse ermöglichen, sondern auch den Übergang zu komplexeren digitalen Signalprozessor-Systemen vorbereiten.
Ein langfristiger Entwicklungspfad liegt schließlich in der Integration analoger Filterstrukturen in integrierter Form, durch den Entwurf und die Simulation analoger Hochfrequenzfilter als ASICs oder auf FPGA-Basis mit analogen Schnittstellen. Die Verbindung der digitalen Entwurfsumgebung mit einer realen Hardwareimplementierung auf analoger Ebene würde die Brücke zwischen der rein digitalen Signalverarbeitung und der physikalischen Signalwelt schlagen und ein tiefes Verständnis für die Systemintegration im Bereich des Analog-Mixed-Signal-Designs fördern.