Programmieren eines Spiels für ein eingebettetes Gerät auf ESP32: Laufwerk, Akku, Sound

Bild


Start: Montagesystem, Eingabe, Anzeige .

Teil 4: fahren


Odroid Go verfügt über einen microSD-Kartensteckplatz, der zum Herunterladen von Ressourcen (Sprites, Sounddateien, Schriftarten) und möglicherweise sogar zum Speichern des Spielstatus nützlich ist.

Der Kartenleser ist über SPI verbunden, aber IDF erleichtert die Interaktion mit der SD-Karte, indem SPI-Anrufe abstrahiert und Standard- POSIX- Funktionen wie fopen , fread und fwrite verwendet werden . All dies basiert auf der FatFs- Bibliothek , daher muss die SD-Karte im Standard-FAT-Format formatiert sein.

Es ist mit demselben SPI-Bus wie das LCD verbunden, verwendet jedoch eine andere Chipauswahlleitung. Wenn wir auf die SD-Karte lesen oder schreiben müssen (und dies kommt nicht sehr oft vor), schaltet der SPI-Treiber das CS-Signal vom Display zum SD-Kartenleser und führt dann den Vorgang aus. Dies bedeutet, dass beim Senden von Daten an das Display keine Vorgänge mit der SD-Karte ausgeführt werden können und umgekehrt.

Im Moment machen wir alles in einem Thread und blockieren die Übertragung über SPI zum Display, sodass keine gleichzeitigen Transaktionen mit der SD-Karte und dem LCD-Display möglich sind. In jedem Fall besteht eine hohe Wahrscheinlichkeit, dass wir alle Ressourcen zum Startzeitpunkt laden.

Änderung von ESP-IDF


Wenn wir versuchen, die Schnittstelle der SD-Karte nach der Initialisierung des Displays zu initialisieren, tritt ein Problem auf, das das Laden von Odroid Go unmöglich macht. ESP-IDF v4.0 unterstützt bei Verwendung mit einer SD-Karte keinen gemeinsamen Zugriff auf den SPI-Bus. Vor kurzem haben Entwickler diese Funktionalität hinzugefügt, sie befindet sich jedoch noch nicht in einer stabilen Version. Daher werden wir die IDF selbst geringfügig ändern.

Kommentieren Sie die Zeile 303 aus. Esp-idf / components / driver / sdspi_host.c :

// Initialize SPI bus
esp_err_t ret = spi_bus_initialize((spi_host_device_t)slot, &buscfg,
    slot_config->dma_channel);
if (ret != ESP_OK) {
    ESP_LOGD(TAG, "spi_bus_initialize failed with rc=0x%x", ret);
    //return ret;
}

Nach dieser Änderung wird während der Initialisierung immer noch ein Fehler angezeigt, der ESP32 wird jedoch nicht mehr neu gestartet, da sich der Fehlercode oben nicht ausbreitet.

Initialisierung




Wir müssen IDF mitteilen, welche ESP32-Pins mit dem MicroSD-Lesegerät verbunden sind, damit der zugrunde liegende SPI-Treiber, der tatsächlich mit dem Lesegerät kommuniziert, korrekt konfiguriert wird.

Die allgemeinen Hinweise VSPI.XXXX werden wieder im Diagramm verwendet , aber wir können sie zu den tatsächlichen Kontaktnummern auf ESP32 durchgehen.

Die Initialisierung ähnelt der Initialisierung des LCD, aber anstelle der allgemeinen SPI-Konfigurationsstruktur verwenden wir sdspi_slot_config_t , das für eine über den SPI-Bus angeschlossene SD-Karte entwickelt wurde. Wir konfigurieren die entsprechenden Kontaktnummern und Kartenmontageeigenschaften im FatFS-System.

In der IDF-Dokumentation wird die Verwendung der Funktion esp_vfs_fat_sdmmc_mount nicht empfohlenim Code des fertigen Programms. Dies ist eine Wrapper-Funktion, die für uns viele Operationen ausführt, aber bisher ganz normal funktioniert, und wahrscheinlich wird sich in Zukunft nichts ändern.

Der Parameter "/ sdcard" dieser Funktion legt den virtuellen Einhängepunkt der SD-Karte fest, den wir dann bei der Arbeit mit Dateien als Präfix verwenden. Wenn wir eine Datei mit dem Namen "test.txt" auf unserer SD-Karte hätten, wäre der Pfad, den wir zum Verknüpfen verwenden würden, "/sdcard/test.txt".

Nach der Initialisierung der Schnittstelle der SD-Karte ist die Interaktion mit den Dateien trivial: Wir können einfach Standardaufrufe für POSIX- Funktionen verwenden , was sehr praktisch ist.

8.3, . , fopen . make menuconfig, , 8.3.



Ich habe in Aseprite (schrecklich) ein 64x64-Sprite erstellt , das nur zwei Farben verwendet: vollständig schwarz (Pixel deaktiviert) und vollständig weiß (Pixel aktiviert). Aseprite hat nicht die Möglichkeit, RGB565-Farben zu speichern oder als Roh-Bitmap zu exportieren (d. H. Ohne Komprimierung und Bildheader), daher habe ich das Sprite in ein temporäres PNG-Format exportiert.

Dann habe ich mit ImageMagick die Daten in eine PPM-Datei konvertiert, die das Bild mit einem einfachen Header in unkomprimierte Rohdaten umwandelte. Als nächstes öffnete ich das Bild in einem Hex-Editor, löschte den Header und konvertierte die 24-Bit-Farbe in 16-Bit. Dabei löschte ich alle Vorkommen 0x000000 bis 0x0000 und alle Vorkommen 0xFFFFFF bis 0xFFFF. Die Bytereihenfolge ist hier kein Problem, da sich 0x0000 und 0xFFFF beim Ändern der Bytereihenfolge nicht ändern.

Die Rohdatei kann hier heruntergeladen werden .

FILE* spriteFile = fopen("/sdcard/key", "r");
assert(spriteFile);

uint16_t* sprite = (uint16_t*)malloc(64 * 64 * sizeof(uint16_t));

for (int i = 0; i < 64; ++i)
{
	for (int j = 0; j < 64; ++j)
	{
		fread(sprite, sizeof(uint16_t), 64 * 64, spriteFile);
	}
}

fclose(spriteFile);

Zuerst öffnen wir die Schlüsseldatei mit den Rohbytes und lesen sie in den Puffer ein. In Zukunft werden wir Sprite-Ressourcen anders laden, aber für eine Demo ist dies völlig ausreichend.

int spriteRow = 0;
int spriteCol = 0;

for (int row = y; row < y + 64; ++row)
{
	spriteCol = 0;

	for (int col = x; col < x + 64; ++col)
	{
		uint16_t pixelColor = sprite[64 * spriteRow + spriteCol];

		if (pixelColor != 0)
		{
			gFramebuffer[row * LCD_WIDTH + col] = color;
		}

		++spriteCol;
	}

	++spriteRow;
}

Um ein Sprite zu zeichnen, durchlaufen wir iterativ seinen Inhalt. Wenn das Pixel weiß ist, zeichnen wir es in der von den Schaltflächen ausgewählten Farbe. Wenn es schwarz ist, betrachten wir es als Hintergrund und zeichnen nicht.


Die Kamera meines Telefons verzerrt die Farben erheblich. Und tut mir leid, dass ich sie geschüttelt habe.

Um die Aufnahme des Bildes zu testen, bewegen wir den Schlüssel an eine Stelle auf dem Bildschirm, ändern seine Farbe und schreiben dann den Bildspeicher auf die SD-Karte, damit er auf dem Computer angezeigt werden kann.

if (input.menu)
{
	const char* snapFilename = "/sdcard/framebuf";

	ESP_LOGI(LOG_TAG, "Writing snapshot to %s", snapFilename);

	FILE* snapFile = fopen(snapFilename, "wb");
	assert(snapFile);

		fwrite(gFramebuffer, sizeof(gFramebuffer[0]), LCD_WIDTH * LCD_HEIGHT, snapFile);
	}

	fclose(snapFile);
}

Durch Drücken der Menütaste wird der Inhalt des Bildpuffers in einer Datei namens framebuf gespeichert . Dies ist ein Rohbildpuffer, sodass die Pixel weiterhin im RGB565-Format mit umgekehrter Bytereihenfolge verbleiben. Wir können ImageMagick wieder verwenden, um dieses Format in PNG zu konvertieren und es auf einem Computer anzuzeigen.

convert -depth 16 -size 320x240+0 -endian msb rgb565:FRAMEBUF snap.png

Natürlich können wir das Lesen / Schreiben im BMP / PNG-Format implementieren und all diese Aufregung mit ImageMagick loswerden, aber dies ist nur ein Demo-Code. Bisher habe ich mich noch nicht entschieden, welches Dateiformat ich zum Speichern von Sprites verwenden möchte.


Hier ist er! Der Odroid Go-Bildspeicher wird auf dem Desktop-Computer angezeigt.

Verweise



Teil 5: Batterie


Odroid Go verfügt über einen Lithium-Ionen-Akku, sodass wir ein Spiel erstellen können, das Sie auch unterwegs spielen können. Dies ist eine verlockende Idee für jemanden, der als Kind den ersten Gameboy gespielt hat.

Daher benötigen wir eine Möglichkeit, den Akkuladestand des Odroid Go anzufordern. Die Batterie ist mit dem Kontakt des ESP32 verbunden, sodass wir die Spannung ablesen können, um eine ungefähre Vorstellung von der verbleibenden Betriebszeit zu erhalten.

Planen



Das Diagramm zeigt IO36 , das an die VBAT- Spannung angeschlossen ist, nachdem es über einen Widerstand gegen Masse gezogen wurde. Zwei Widerstände ( R21 und R23 ) bilden einen Spannungsteiler ähnlich dem am Kreuz des Gamepads verwendeten. Die Widerstände haben wieder den gleichen Widerstand, so dass die Spannung die Hälfte des Originals beträgt.

Aufgrund des Spannungsteilers liest IO36 eine Spannung, die der halben VBAT entspricht . Dies liegt wahrscheinlich daran, dass die ADC-Kontakte am ESP32 die Hochspannung des Lithium-Ionen-Akkus (4,2 V bei maximaler Ladung) nicht lesen können. Wie dem auch sei, dies bedeutet, dass Sie die vom ADC (ADC) gelesene Spannung verdoppeln müssen, um die wahre Spannung zu erhalten.

Beim Lesen des Werts von IO36 erhalten wir einen digitalen Wert, verlieren jedoch den analogen Wert, den er darstellt. Wir brauchen eine Möglichkeit, einen digitalen Wert mit einem ADC in Form einer physikalischen analogen Spannung zu interpretieren.

Mit IDF können Sie den ADC kalibrieren, der versucht, einen Spannungspegel basierend auf der Referenzspannung zu erhalten. Diese Referenzspannung ( Vref ) beträgt standardmäßig 1100 mV, aber aufgrund der physikalischen Eigenschaften unterscheidet sich jedes Gerät geringfügig. ESP32 in Odroid Go verfügt über eine manuell definierte Vref, die in eFuse „geflasht“ ist und die wir als genauere Vref verwenden können.

Das Verfahren ist wie folgt: Zuerst konfigurieren wir die ADC-Kalibrierung, und wenn wir die Spannung ablesen möchten, nehmen wir eine bestimmte Anzahl von Proben (zum Beispiel 20), um die Durchschnittswerte zu berechnen; dann verwenden wir die IDF, um diese Messwerte in Spannung umzuwandeln. Die Berechnung des Durchschnitts eliminiert Rauschen und liefert genauere Messwerte.

Leider besteht kein linearer Zusammenhang zwischen Spannung und Batterieladung. Wenn die Ladung abnimmt, fällt die Spannung ab, wenn sie zunimmt, steigt sie an, jedoch auf unvorhersehbare Weise. Alles, was gesagt werden kann: Wenn die Spannung unter etwa 3,6 V liegt, wird die Batterie entladen, aber es ist überraschend schwierig, den Spannungspegel genau in einen Prozentsatz der Batterieladung umzuwandeln.

Für unser Projekt ist dies nicht besonders wichtig. Wir können eine grobe Annäherung implementieren, um den Spieler über die Notwendigkeit zu informieren, das Gerät schnell aufzuladen, aber wir werden nicht leiden, wenn wir versuchen, den genauen Prozentsatz zu erhalten.

Status-LED



Auf der Vorderseite unter dem Odroid Go-Bildschirm befindet sich eine blaue LED (LED), die wir für jeden Zweck verwenden können. Sie können ihnen zeigen, dass das Gerät eingeschaltet ist und funktioniert. In diesem Fall leuchtet jedoch im Dunkeln eine hellblaue LED in Ihrem Gesicht. Daher werden wir es verwenden, um eine niedrige Batterieladung anzuzeigen (obwohl ich dafür eine rote oder bernsteinfarbene Farbe bevorzugen würde).

Um die LED zu verwenden, müssen Sie IO2 als Ausgang einstellen und dann ein High- oder Low-Signal anlegen, um die LED ein- und auszuschalten.

Ich denke, dass ein 2-kΩ- Widerstand ( Strombegrenzungswiderstand ) ausreicht, damit wir die LED nicht verbrennen und zu viel Strom vom GPIO-Pin liefern.

Die LED hat einen relativ geringen Widerstand. Wenn also 3,3 V an sie angelegt werden, wird sie durch Ändern des Stroms verbrannt. Zum Schutz ist in der Regel ein Widerstand in Reihe mit der LED geschaltet.

Die Strombegrenzungswiderstände für LEDs sind jedoch normalerweise viel kleiner als 2 kΩ, daher verstehe ich nicht, warum der R7- Widerstand ein solcher Widerstand ist.

Initialisierung


static const adc1_channel_t BATTERY_READ_PIN = ADC1_GPIO36_CHANNEL;
static const gpio_num_t BATTERY_LED_PIN = GPIO_NUM_2;

static esp_adc_cal_characteristics_t gCharacteristics;

void Odroid_InitializeBatteryReader()
{
	// Configure LED
	{
		gpio_config_t gpioConfig = {};

		gpioConfig.mode = GPIO_MODE_OUTPUT;
		gpioConfig.pin_bit_mask = 1ULL << BATTERY_LED_PIN;

		ESP_ERROR_CHECK(gpio_config(&gpioConfig));
	}

	// Configure ADC
	{
		adc1_config_width(ADC_WIDTH_BIT_12);
    	adc1_config_channel_atten(BATTERY_READ_PIN, ADC_ATTEN_DB_11);
    	adc1_config_channel_atten(BATTERY_READ_PIN, ADC_ATTEN_DB_11);

    	esp_adc_cal_value_t type = esp_adc_cal_characterize(
    		ADC_UNIT_1, ADC_ATTEN_DB_11, ADC_WIDTH_BIT_12, 1100, &gCharacteristics);

    	assert(type == ESP_ADC_CAL_VAL_EFUSE_VREF);
    }

	ESP_LOGI(LOG_TAG, "Battery reader initialized");
}

Zuerst setzen wir die GPIO-LED als Ausgang, damit wir sie bei Bedarf umschalten können. Dann konfigurieren wir den ADC-Pin wie bei einem Cross - mit einer Bitbreite von 12 und minimaler Dämpfung.

esp_adc_cal_characterize führt Berechnungen durch, um den ADC zu charakterisieren, damit wir die digitalen Messwerte später in physischen Stress umwandeln können.

Batterie lesen


uint32_t Odroid_ReadBatteryLevel(void)
{
	const int SAMPLE_COUNT = 20;


	uint32_t raw = 0;

	for (int sampleIndex = 0; sampleIndex < SAMPLE_COUNT; ++sampleIndex)
	{
		raw += adc1_get_raw(BATTERY_READ_PIN);
	}

	raw /= SAMPLE_COUNT;


	uint32_t voltage = 2 * esp_adc_cal_raw_to_voltage(raw, &gCharacteristics);

	return voltage;
}

Wir nehmen zwanzig Rohproben des ADC aus dem Kontakt des ADC und teilen sie dann, um den Durchschnittswert zu erhalten. Wie oben erwähnt, hilft dies, das Rauschen der Messwerte zu reduzieren.

Dann verwenden wir esp_adc_cal_raw_to_voltage , um den Rohwert in die reale Spannung umzuwandeln. Aufgrund des oben erwähnten Spannungsteilers verdoppeln wir den Rückgabewert: Der Lesewert ist die Hälfte der tatsächlichen Batteriespannung.

Anstatt knifflige Möglichkeiten zu finden, um diese Spannung in einen Prozentsatz der Batterieladung umzuwandeln, geben wir eine einfache Spannung zurück. Lassen Sie die aufrufende Funktion selbst entscheiden, was mit der Spannung geschehen soll - ob sie in einen Prozentsatz der Ladung umgewandelt oder einfach als hoher oder niedriger Wert interpretiert werden soll.

Der Wert wird in Millivolt zurückgegeben, daher muss die aufrufende Funktion die entsprechende Konvertierung durchführen. Dies verhindert einen Überlauf des Schwimmers.

LED-Einstellung


void Odroid_EnableBatteryLight(void)
{
	gpio_set_level(BATTERY_LED_PIN, 1);
}

void Odroid_DisableBatteryLight(void)
{
	gpio_set_level(BATTERY_LED_PIN, 0);
}

Diese beiden einfachen Funktionen reichen aus, um die LED zu verwenden. Wir können das Licht entweder ein- oder ausschalten. Lassen Sie die aufrufende Funktion entscheiden, wann sie ausgeführt werden soll.

Wir könnten eine Aufgabe erstellen, die die Batteriespannung regelmäßig überwacht und den Status der LED entsprechend ändert. Ich sollte jedoch die Batteriespannung in unserem Hauptzyklus abfragen und dann entscheiden, wie die Batteriespannung von dort aus eingestellt werden soll.

Demo


uint32_t batteryLevel = Odroid_ReadBatteryLevel();

if (batteryLevel < 3600)
{
	Odroid_EnableBatteryLight();
}
else
{
	Odroid_DisableBatteryLight();
}

Wir können einfach den Batteriestand im Hauptzyklus anfordern. Wenn die Spannung unter dem Schwellenwert liegt, schalten Sie die LED ein, um anzuzeigen, dass ein Ladevorgang erforderlich ist. Aufgrund der untersuchten Materialien kann ich sagen, dass 3600 mV (3,6 V) ein gutes Zeichen für eine geringe Ladung von Lithium-Ionen-Batterien sind, aber die Batterien selbst sind komplex.

Verweise



Teil 6: Ton


Der letzte Schritt, um eine vollständige Schnittstelle zu aller Odroid Go-Hardware zu erhalten, besteht darin, eine Soundebene zu schreiben. Wenn wir damit fertig sind, können wir beginnen, uns einer allgemeineren Programmierung des Spiels zuzuwenden, die weniger mit der Programmierung für Odroid zu tun hat. Alle Interaktionen mit Peripheriegeräten werden über die Odroid- Funktionen ausgeführt .

Aufgrund meiner mangelnden Erfahrung mit Soundprogrammierung und des Mangels an guter Dokumentation seitens IDF nahm die Implementierung von Sound bei der Arbeit an einem Projekt die meiste Zeit in Anspruch.

Letztendlich war nicht so viel Code erforderlich, um den Sound abzuspielen. Die meiste Zeit wurde damit verbracht, die Audiodaten in den gewünschten ESP32 zu konvertieren und den ESP32-Audiotreiber so zu konfigurieren, dass er der Hardwarekonfiguration entspricht.

Grundlagen des digitalen Klangs


Digitaler Sound besteht aus zwei Teilen: Aufnahme und Wiedergabe .

Aufzeichnung


Um Ton auf einem Computer aufzunehmen, müssen wir ihn zuerst aus dem Raum eines kontinuierlichen (analogen) Signals in den Raum eines diskreten (digitalen) Signals konvertieren. Diese Aufgabe wird mit einem Analog-Digital-Wandler (ADC) erledigt (über den wir in Teil 2 bei der Arbeit mit dem Kreuz gesprochen haben).

Der ADC empfängt eine Probe der eingehenden Welle und digitalisiert den Wert, der dann in einer Datei gespeichert werden kann.

abspielen


Eine digitale Audiodatei kann mithilfe eines Digital-Analog-Wandlers (DAC) vom digitalen in den analogen Raum zurückgegeben werden . DAC kann Werte nur in einem bestimmten Bereich wiedergeben. Beispielsweise kann ein 8-Bit-DAC mit einer 3,3-V-Quelle analoge Spannungen im Bereich von 0 bis 3,3 mV in Schritten von 12,9 mV (3,3 V geteilt durch 256) ausgeben.

Der DAC nimmt digitale Werte auf und wandelt sie wieder in Spannung um, die an einen Verstärker, Lautsprecher oder ein anderes Gerät übertragen werden kann, das ein analoges Audiosignal empfangen kann.

Abtastrate


Bei der Aufnahme von analogem Ton über den ADC werden Samples mit einer bestimmten Frequenz aufgenommen, und jedes Sample ist zu einem bestimmten Zeitpunkt eine „Momentaufnahme“ des Tonsignals. Dieser Parameter wird als Abtastfrequenz bezeichnet und in Hertz gemessen .

Je höher die Abtastfrequenz, desto genauer werden die Frequenzen des ursprünglichen Signals wiederhergestellt. Der Satz von Nyquist-Shannon (Kotelnikov) besagt (in einfachen Worten), dass die Abtastfrequenz doppelt so hoch sein sollte wie die höchste Signalfrequenz, die wir aufzeichnen möchten.

Das menschliche Ohr kann ungefähr im Bereich von 20 Hz bis 20 kHz hören , daher wird die Abtastfrequenz von 44,1 kHz am häufigsten verwendet, um qualitativ hochwertige Musik wiederherzustellenDies ist etwas mehr als das Doppelte der maximalen Frequenz, die das menschliche Ohr erkennen kann. Dies stellt sicher, dass ein vollständiger Satz von Instrumentenfrequenzen und Stimmen neu erstellt wird.

Jedes Sample nimmt jedoch Platz in der Datei ein, sodass wir nicht die maximale Sampling-Rate auswählen können. Wenn Sie jedoch nicht schnell genug probieren, können Sie wichtige Informationen verlieren. Die ausgewählte Abtastfrequenz sollte von den Frequenzen abhängen, die im neu erstellten Ton vorhanden sind.

Die Wiedergabe sollte mit derselben Abtastfrequenz wie die Quelle erfolgen, da sonst der Klang und die Dauer unterschiedlich sind.

Angenommen, zehn Sekunden Ton wurden mit einer Abtastfrequenz von 16 kHz aufgezeichnet. Wenn Sie es mit einer Frequenz von 8 kHz spielen, ist der Ton niedriger und die Dauer beträgt 20 Sekunden. Wenn Sie es mit einer Abtastfrequenz von 32 kHz spielen, ist der hörbare Ton höher und der Ton selbst dauert fünf Sekunden.

Dieses Video zeigt den Unterschied in den Abtastraten anhand von Beispielen.

Bittiefe


Die Abtastfrequenz ist nur die halbe Miete. Der Sound hat auch eine Bittiefe, dh die Anzahl der Bits pro Sample.

Wenn der ADC ein Sample eines Audiosignals erfasst, muss er diesen analogen Wert in einen digitalen Wert umwandeln. Der Bereich der erfassten Werte hängt von der Anzahl der verwendeten Bits ab. 8 Bit (256 Werte), 16 Bit (65.526 Werte), 32 Bit (4.294.967.296 Werte) usw.

Die Anzahl der Bits pro Abtastung hängt mit dem Dynamikbereich des Klangs zusammen, d.h. mit den lautesten und leisesten Teilen. Die häufigste Bittiefe für Musik beträgt 16 Bit.

Während der Wiedergabe muss die gleiche Bittiefe wie bei der Quelle angegeben werden, da sich sonst der Klang und die Dauer ändern.

Sie haben beispielsweise eine Audiodatei mit vier Samples, die als 8 Bit gespeichert sind: [0x25, 0xAB, 0x34, 0x80]. Wenn Sie versuchen, sie so abzuspielen, als wären sie 16-Bit, erhalten Sie nur zwei Samples: [0x25AB, 0x3480]. Dies führt nicht nur zu falschen Werten von Klangbeispielen, sondern halbiert auch die Anzahl der Abtastwerte und damit die Dauer des Klangs.

Es ist auch wichtig, das Format der Proben zu kennen. 8-Bit ohne Vorzeichen, 8-Bit ohne Vorzeichen, 16-Bit ohne Vorzeichen, 16-Bit ohne Vorzeichen usw. Normalerweise sind 8-Bit vorzeichenlos und 16-Bit vorzeichenlos. Wenn sie verwirrt sind, wird der Ton stark verzerrt.

Dieses Video zeigt den Unterschied in der Bittiefe anhand von Beispielen.

WAV-Dateien


Am häufigsten werden rohe Audiodaten auf einem Computer im WAV-Format gespeichert , das einen einfachen Header enthält, der das Audioformat (Abtastfrequenz, Bittiefe, Größe usw.) beschreibt, gefolgt von den Audiodaten selbst.

Der Sound wird überhaupt nicht komprimiert (im Gegensatz zu Formaten wie MP3), sodass wir ihn problemlos abspielen können, ohne dass eine Codec-Bibliothek erforderlich ist.

Das Hauptproblem bei WAV-Dateien besteht darin, dass sie aufgrund der fehlenden Komprimierung sehr groß sein können. Die Dateigröße steht in direktem Zusammenhang mit der Dauer, der Abtastrate und der Bittiefe.

Größe = Dauer (in Sekunden) x Abtastrate (Abtastwerte / s) x Bittiefe (Bit / Abtastwert)

Die Abtastfrequenz wirkt sich am meisten auf die Dateigröße aus. Der einfachste Weg, Platz zu sparen, besteht darin, einen ausreichend niedrigen Wert auszuwählen. Wir werden einen Old-School-Sound erzeugen, daher passt eine niedrige Abtastfrequenz zu uns.

I2S


ESP32 verfügt über Peripheriegeräte, aufgrund derer es relativ einfach ist, eine Schnittstelle mit Audiogeräten bereitzustellen: Inter-IC Sound (I2S) .

Das I2S-Protokoll ist recht einfach und besteht aus nur drei Signalen: einem Taktsignal, einer Auswahl von Kanälen (links oder rechts) und auch der Datenleitung selbst.

Die Taktfrequenz hängt von der Abtastfrequenz, der Bittiefe und der Anzahl der Kanäle ab. Die Beats werden für jedes Datenbit ersetzt. Für eine ordnungsgemäße Klangwiedergabe müssen Sie daher die Taktfrequenz entsprechend einstellen.

Taktfrequenz = Abtastfrequenz (Samples / s) x Bittiefe (Bits / Sample) x Anzahl der Kanäle

Der ESP32-Mikrocontroller-I2S-Treiber verfügt über zwei mögliche Modi: Er kann entweder Daten an Kontakte ausgeben, die an einen externen I2S-Empfänger angeschlossen sind, der das Protokoll decodieren und Daten an den Verstärker übertragen kann, oder er kann Daten an einen internen ESP32-DAC übertragen, der ein analoges Signal ausgibt, an das gesendet werden kann Verstärker.

Odroid Go hat keinen I2S-Decoder auf der Karte, daher müssen wir den internen 8-Bit-ESP32-DAC verwenden, dh wir müssen 8-Bit-Sound verwenden. Das Gerät verfügt über zwei DACs, von denen einer an IO25 und der andere an IO26 angeschlossen ist .

Das Verfahren sieht folgendermaßen aus:

  1. Wir übertragen Audiodaten an den I2S-Treiber
  2. Der I2S-Treiber sendet Audiodaten an den internen 8-Bit-DAC
  3. Der interne DAC gibt ein analoges Signal aus
  4. Das analoge Signal wird an den Tonverstärker übertragen



Wenn wir uns die Audio- Schaltung in der Odroid Go-Schaltung ansehen , sehen wir zwei GPIO-Pins ( IO25 und IO26 ), die mit den Eingängen des Tonverstärkers ( PAM8304A ) verbunden sind. IO25 ist
auch mit dem Signal / SD des Verstärkers verbunden, dh dem Kontakt, der den Verstärker ein- oder ausschaltet (niedriges Signal bedeutet Abschalten). Die Verstärkerausgänge sind mit einem Lautsprecher ( P1 ) verbunden.

Denken Sie daran, dass IO25 und IO26 Ausgänge von 8-Bit-ESP32-DACs sind, dh ein DAC ist mit IN- und der andere mit IN + verbunden .

IN- und IN + sindDifferenzeingänge des Tonverstärkers. Differenzeingänge werden verwendet, um durch elektromagnetische Störungen verursachte Störungen zu reduzieren . Jedes in einem Signal vorhandene Rauschen ist auch in einem anderen vorhanden. Ein Signal wird von einem anderen subtrahiert, wodurch Rauschen vermieden wird.

Wenn Sie sich die technischen Daten des Tonverstärkers ansehen , verfügt er über eine typische Anwendungsschaltung. Dies ist die vom Hersteller empfohlene Art, den Verstärker zu verwenden.


Er empfiehlt, IN- mit Masse, IN + mit dem Eingangssignal und / SD mit dem Ein / Aus-Signal zu verbinden. Wenn es ein Geräusch von 0,005 V, dann mit IN- 0V + 0,005 V gelesen werden , und mit IN + - VIN + 0,005 V . Die Eingangssignale müssen voneinander subtrahiert werden und erhalten den wahren Signalwert ( VIN ) ohne Rauschen.

Die Entwickler von Odroid Go verwendeten jedoch nicht die empfohlene Konfiguration.

Wenn wir uns noch einmal die Odroid Go-Schaltung ansehen, sehen wir, dass die Entwickler den DAC-Ausgang mit IN- verbunden haben und dass derselbe DAC-Ausgang mit / SD verbunden ist . / SD- Dies ist ein Abschaltsignal mit einem aktiven niedrigen Pegel. Damit der Verstärker funktioniert, müssen Sie ein hohes Signal einstellen.

Dies bedeutet, dass wir zur Verwendung des Verstärkers den IO25 nicht als DAC verwenden dürfen, sondern als GPIO-Ausgang mit einem immer hohen Signal. In diesem Fall wird jedoch ein hohes Signal auf IN- gesetzt , was von der Spezifikation des Verstärkers nicht empfohlen wird (es muss geerdet sein). Dann müssen wir den an IO26 angeschlossenen DAC verwenden , da unser I2S-Ausgang IN + zugeführt werden muss . Dies bedeutet, dass wir nicht die notwendige Rauschunterdrückung erreichen, da IN- nicht mit Masse verbunden ist. Von den Lautsprechern geht ständig ein leises Geräusch aus.

Wir müssen die korrekte Konfiguration des I2S-Treibers sicherstellen, da wir nur den an IO26 angeschlossenen DAC verwenden möchten . Wenn wir einen an IO25 angeschlossenen DAC verwenden würden , würde dies das Signal des Verstärkers ständig ausschalten und der Ton wäre schrecklich.

Zusätzlich zu dieser Verrücktheit erfordert der I2S-Treiber im ESP32 bei Verwendung eines internen 8-Bit-DAC die Übertragung von 16-Bit-Samples, sendet jedoch nur das High-Byte an den 8-Bit-DAC. Daher müssen wir unseren 8-Bit-Sound in einen doppelt so großen Puffer einfügen, während der Puffer halb leer ist. Dann übergeben wir es an den I2S-Treiber und es übergibt dem DAC das High-Byte jedes Samples. Leider bedeutet dies, dass wir für 16 Bit „bezahlen“ müssen, aber wir können nur 8 Bit verwenden.

Multitasking


Leider kann das Spiel nicht auf einem Kern funktionieren, wie ich es ursprünglich wollte, da es einen Fehler im I2S-Treiber zu geben scheint.

Der I2S-Treiber muss DMA verwenden (wie der SPI-Treiber), dh wir können einfach die Übertragung von I2S initiieren und dann unsere Arbeit fortsetzen, während der I2S-Treiber Audiodaten überträgt.

Stattdessen ist die CPU für die Dauer des Sounds blockiert, was für das Spiel völlig ungeeignet ist. Stellen Sie sich vor, Sie drücken die Sprungtaste und das Sprite des Players pausiert seine Bewegung für 100 ms, während der Sprungton abgespielt wird.

Um dieses Problem zu lösen, können wir die Tatsache nutzen, dass sich an Bord des ESP32 zwei Kerne befinden. Wir können im zweiten Kern eine Aufgabe (d. H. Einen Thread) erstellen, die sich mit der Tonwiedergabe befasst. Dank dessen können wir den Zeiger auf den Soundpuffer von der Hauptaufgabe des Spiels auf die Soundaufgabe übertragen, und die Soundaufgabe initiiert die Übertragung von I2S und ist für die Dauer der Soundwiedergabe blockiert. Die Hauptaufgabe auf dem ersten Kern (mit Eingabeverarbeitung und Rendering) wird jedoch weiterhin ohne Blockierung ausgeführt.

Initialisierung


Wenn wir dies wissen, können wir den I2S-Treiber ordnungsgemäß initiieren. Dazu benötigen Sie nur wenige Codezeilen. Die Schwierigkeit besteht jedoch darin, herauszufinden, welche Parameter Sie für eine ordnungsgemäße Klangwiedergabe einstellen müssen.

static const gpio_num_t AUDIO_AMP_SD_PIN = GPIO_NUM_25;

static QueueHandle_t gQueue;

static void PlayTask(void *arg)
{
	for(;;)
	{
		QueueData data;

		if (xQueueReceive(gQueue, &data, 10))
		{
			size_t bytesWritten;
			i2s_write(I2S_NUM_0, data.buffer, data.length, &bytesWritten, portMAX_DELAY);
			i2s_zero_dma_buffer(I2S_NUM_0);
		}

		vTaskDelay(1 / portTICK_PERIOD_MS);
	}
}

void Odroid_InitializeAudio(void)
{
	// Configure the amplifier shutdown signal
	{
		gpio_config_t gpioConfig = {};

		gpioConfig.mode = GPIO_MODE_OUTPUT;
		gpioConfig.pin_bit_mask = 1ULL << AUDIO_AMP_SD_PIN;

		ESP_ERROR_CHECK(gpio_config(&gpioConfig));

		gpio_set_level(AUDIO_AMP_SD_PIN, 1);
	}

	// Configure the I2S driver
	{
		i2s_config_t i2sConfig= {};

		i2sConfig.mode = I2S_MODE_MASTER | I2S_MODE_TX | I2S_MODE_DAC_BUILT_IN;
		i2sConfig.sample_rate = 5012;
		i2sConfig.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT;
		i2sConfig.communication_format = I2S_COMM_FORMAT_I2S_MSB;
		i2sConfig.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT;
		i2sConfig.dma_buf_count = 8;
		i2sConfig.dma_buf_len = 64;

		ESP_ERROR_CHECK(i2s_driver_install(I2S_NUM_0, &i2sConfig, 0, NULL));
		ESP_ERROR_CHECK(i2s_set_dac_mode(I2S_DAC_CHANNEL_LEFT_EN));
	}

	// Create task for playing sounds so that our main task isn't blocked
	{
		gQueue = xQueueCreate(1, sizeof(QueueData));
		assert(gQueue);

		BaseType_t result = xTaskCreatePinnedToCore(&PlayTask, "I2S Task", 1024, NULL, 5, NULL, 1);
		assert(result == pdPASS);
	}
}

Zuerst konfigurieren wir IO25 (das mit dem Ausschaltsignal des Verstärkers verbunden ist) als Ausgang, damit er den Klangverstärker steuern kann, und legen ein hohes Signal an, um den Verstärker einzuschalten.

Als nächstes konfigurieren und installieren wir den I2S-Treiber selbst. Ich werde jeden Teil der Konfiguration Zeile für Zeile analysieren, da jede Zeile einer Erklärung bedarf:

  • Modus
    • Wir setzen den Treiber als Master (Steuerung des Busses), als Sender (weil wir Daten an die Empfänger übertragen) und konfigurieren ihn für die Verwendung des integrierten 8-Bit-DAC (weil die Odroid Go-Karte keinen externen DAC hat).
  • Beispielrate
    • 5012, , , . , , . -, 2500 .
  • bits_per_sample
    • , ESP32 8-, I2S , 16 , 8 .
  • communication_format
    • , , - , 8- 16- .
  • channel_format
    • GPIO, IN+IO26, «» I2S. , I2S , IO25, , .
  • dma_buf_count dma_buf_len
    • DMA- ( ) , , , IDF. , .

Dann erstellen wir eine Warteschlange - auf diese Weise sendet FreeRTOS Daten zwischen Aufgaben. Wir stellen Daten in die Warteschlange einer Aufgabe und extrahieren sie aus der Warteschlange einer anderen Aufgabe. Erstellen Sie eine Struktur namens QueueData , die den Zeiger auf den Soundpuffer und die Länge des Puffers zu einer einzigen Struktur kombiniert, die in die Warteschlange gestellt werden kann.

Erstellen Sie als Nächstes eine Aufgabe, die auf dem zweiten Kern ausgeführt wird. Wir verbinden es mit der PlayTask- Funktion , die die Tonwiedergabe durchführt. Die Aufgabe selbst ist eine Endlosschleife, die ständig überprüft, ob sich Daten in der Warteschlange befinden. Wenn dies der Fall ist, sendet sie sie an den I2S-Treiber, damit sie abgespielt werden können. Der i2s_write- Aufruf wird blockiert, und das passt zu uns, weil die Aufgabe auf einem vom Hauptthread des Spiels getrennten Kernel ausgeführt wird.

Ein Aufruf von i2s_zero_dma_buffer ist erforderlich, damit nach Abschluss der Wiedergabe keine Töne mehr aus den Lautsprechern kommen. Ich weiß nicht, ob dies ein Fehler des I2S-Treibers oder des erwarteten Verhaltens ist, aber ohne diesen gibt der Lautsprecher nach Beendigung der Wiedergabe des Soundpuffers ein Müllsignal aus.

Ton abspielen


void Odroid_PlayAudio(uint16_t* buffer, size_t length)
{
	QueueData data = {};

	data.buffer = buffer;
	data.length = length;

	xQueueSendToBack(gQueue, &data, portMAX_DELAY);
}

Aufgrund der Tatsache, dass die gesamte Konfiguration bereits abgeschlossen wurde, ist der Aufruf der Soundpuffer-Wiedergabefunktion selbst äußerst einfach, da die Hauptarbeit in einer anderen Aufgabe erledigt wird. Wir setzen den Zeiger auf den Puffer und die Länge des Puffers in die QueueData- Struktur und setzen ihn dann in die Warteschlange, die von der PlayTask- Funktion verwendet wird .

Aufgrund dieses Betriebsmusters muss ein Soundpuffer die Wiedergabe abschließen, bevor der zweite Puffer gestartet werden kann. Wenn also ein Sprung und ein Schießen gleichzeitig stattfinden, wird der erste Ton vor dem zweiten und nicht gleichzeitig damit abgespielt.

Höchstwahrscheinlich werde ich in Zukunft verschiedene Frame-Sounds in den Soundpuffer mischen, der an den I2S-Treiber übertragen wird. Auf diese Weise können Sie mehrere Sounds gleichzeitig abspielen.

Demo


Wir werden unsere eigenen Soundeffekte mit jsfxr erzeugen , einem Tool, das speziell entwickelt wurde, um die Art von Spiel-Sounds zu erzeugen, die wir benötigen. Wir können die Abtastfrequenz und die Bittiefe direkt einstellen und dann die WAV-Datei ausgeben.

Ich habe einen einfachen Sprung-Soundeffekt erstellt, der dem Klang von Marios Sprung ähnelt. Es hat eine Abtastfrequenz von 5012 (wie wir es während der Initialisierung konfiguriert haben) und eine Bittiefe von 8 (da der DAC 8-Bit ist).


Anstatt die WAV-Datei direkt im Code zu analysieren, werden wir etwas Ähnliches tun, wie wir das Sprite in der Demo von Teil 4 geladen haben: Wir werden den WAV-Header mit dem Hex-Editor aus der Datei entfernen. Dank dessen sind die von der SD-Karte gelesenen Dateien nur Rohdaten. Außerdem werden wir die Dauer des Sounds nicht lesen, sondern in den Code schreiben. In Zukunft werden wir Soundressourcen anders laden, aber das reicht für die Demo.

Die Rohdatei kann hier heruntergeladen werden .

// Load sound effect
uint16_t* soundBuffer;
int soundEffectLength = 1441;
{
	FILE* soundFile = fopen("/sdcard/jump", "r");
	assert(soundFile);

	uint8_t* soundEffect = malloc(soundEffectLength);
	assert(soundEffect);

	soundBuffer = malloc(soundEffectLength*2);
	assert(soundBuffer);

	fread(soundEffect, soundEffectLength, 1, soundFile);

    for (int i = 0; i < soundEffectLength; ++i)
    {
        // 16 bits required but only MSB is actually sent to the DAC
        soundBuffer[i] = (soundEffect[i] << 8u);
    }
}

Wir laden die 8-Bit-Daten in den 8-Bit- SoundEffect- Puffer und kopieren diese Daten dann in den 16-Bit- SoundBuffer- Puffer , in dem die Daten in den hohen acht Bits gespeichert werden. Ich wiederhole - dies ist aufgrund der Funktionen der IDF-Implementierung erforderlich.

Nachdem wir einen 16-Bit-Puffer erstellt haben, können wir den Klang eines Knopfdrucks wiedergeben. Es wäre logisch, hierfür die Lautstärketaste zu verwenden.

int lastState = 0;

for (;;)
{
	[...]

	int thisState = input.volume;

	if ((thisState == 1) && (thisState != lastState))
	{
		Odroid_PlayAudio(soundBuffer, soundEffectLength*2);
	}

	lastState = thisState;

	[...]
}

Wir überwachen den Status der Schaltfläche, damit Sie versehentlich mit einem Klick auf die Schaltfläche nicht versehentlich mehrmals Odroid_PlayAudio aufrufen .


Quelle


Der gesamte Quellcode ist hier .

Verweise



All Articles