Programmation d'un jeu pour un appareil embarqué sur ESP32: lecteur, batterie, son

image


Début: système d'assemblage, saisie, affichage .

Partie 4: conduire


Odroid Go dispose d'un emplacement pour carte microSD, qui sera utile pour télécharger des ressources (sprites, fichiers audio, polices), et peut-être même pour sauvegarder l'état du jeu.

Le lecteur de carte est connecté via SPI, mais IDF facilite l'interaction avec la carte SD en extrayant les appels SPI et en utilisant les fonctions POSIX standard comme fopen , fread et fwrite . Tout cela est basé sur la bibliothèque FatFs , donc la carte SD doit être formatée au format FAT standard.

Il est connecté au même bus SPI que l'écran LCD, mais utilise une ligne de sélection de puce différente. Lorsque nous devons lire ou écrire sur la carte SD (et cela ne se produit pas très souvent), le pilote SPI commutera le signal CS de l'écran vers le lecteur de carte SD, puis effectuera l'opération. Cela signifie que lors de l'envoi de données à l'écran, nous ne pouvons effectuer aucune opération avec la carte SD, et vice versa.

Pour le moment, nous faisons tout dans un seul thread et utilisons la transmission de blocage via SPI à l'écran, donc il ne peut y avoir de transactions simultanées avec la carte SD et l'écran LCD. Dans tous les cas, il y a une forte probabilité que nous chargeons toutes les ressources au moment du lancement.

Modification de ESP-IDF


Si nous essayons d'initialiser l'interface de la carte SD après l'initialisation de l'affichage, nous rencontrerons un problème qui rend impossible le chargement d'Odroid Go. ESP-IDF v4.0 ne prend pas en charge l'accès partagé au bus SPI lorsqu'il est utilisé avec une carte SD. Récemment, les développeurs ont ajouté cette fonctionnalité, mais elle n'est pas encore dans une version stable, nous allons donc apporter nous-mêmes une petite modification à l'IDF.

Commentez la ligne 303 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;
}

Après avoir effectué cette modification, nous verrons toujours une erreur lors de l'initialisation, mais cela ne provoquera plus le redémarrage de l'ESP32, car le code d'erreur ne se propage pas ci-dessus.

Initialisation




Nous devons indiquer à IDF quelles broches ESP32 sont connectées au lecteur MicroSD afin qu'il configure correctement le pilote SPI sous-jacent, qui communique réellement avec le lecteur.

Les notes générales VSPI.XXXX sont à nouveau utilisées dans le diagramme , mais nous pouvons les parcourir jusqu'aux numéros de contact réels sur ESP32.

L'initialisation est similaire à l'initialisation LCD, mais au lieu de la structure de configuration générale SPI, nous utilisons sdspi_slot_config_t , qui est destiné à une carte SD connectée via le bus SPI. Nous configurons les numéros de contact et les propriétés de montage de carte correspondants dans le système FatFS.

La documentation IDF ne recommande pas d'utiliser la fonction esp_vfs_fat_sdmmc_mountdans le code du programme terminé. Il s'agit d'une fonction wrapper qui effectue de nombreuses opérations pour nous, mais jusqu'à présent, elle fonctionne assez normalement, et probablement rien ne changera à l'avenir.

Le paramètre "/ sdcard" de cette fonction définit le point de montage virtuel de la carte SD, que nous utiliserons ensuite comme préfixe lors de l'utilisation de fichiers. Si nous avions un fichier nommé "test.txt" sur notre carte SD, le chemin que nous utiliserions pour le lier serait "/sdcard/test.txt".

Après l'initialisation de l'interface de la carte SD, l'interaction avec les fichiers est triviale: on peut simplement utiliser des appels standard aux fonctions POSIX , ce qui est très pratique.

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



J'ai créé un sprite 64x64 en Aseprite (terrible) qui utilise seulement deux couleurs: complètement noir (pixel désactivé) et complètement blanc (pixel activé). Aseprite n'a pas la possibilité d'enregistrer la couleur RGB565 ou d'exporter en tant que bitmap brut (c'est-à-dire sans compression et en-têtes d'image), j'ai donc exporté l'image-objet au format PNG temporaire.

Ensuite, en utilisant ImageMagick, j'ai converti les données en un fichier PPM, qui a transformé l'image en données brutes non compressées avec un simple en-tête. Ensuite, j'ai ouvert l'image dans un éditeur hexadécimal, supprimé l'en-tête et converti la couleur 24 bits en 16 bits, supprimant toutes les occurrences 0x000000 à 0x0000 , et toutes les occurrences 0xFFFFFF à 0xFFFF. L'ordre des octets ici n'est pas un problème, car 0x0000 et 0xFFFF ne changent pas lors du changement de l'ordre des octets.

Le fichier brut peut être téléchargé à partir d'ici .

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);

Tout d'abord, nous ouvrons le fichier de clé contenant des octets bruts et le lisons dans le tampon. À l'avenir, nous chargerons les ressources de sprite différemment, mais pour une démo, cela suffit.

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;
}

Pour dessiner un sprite, nous parcourons itérativement son contenu. Si le pixel est blanc, nous le dessinons dans la couleur sélectionnée par les boutons. S'il est noir, nous le considérons comme un fond et ne dessinons pas.


L'appareil photo de mon téléphone déforme considérablement les couleurs. Et désolé de l'avoir secouée.

Pour tester l'enregistrement de l'image, nous allons déplacer la clé à un certain endroit sur l'écran, changer sa couleur, puis écrire le tampon d'image sur la carte SD afin qu'elle puisse être visualisée sur l'ordinateur.

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);
}

Appuyez sur la touche Menu pour enregistrer le contenu du tampon d'image dans un fichier appelé framebuf . Ce sera un tampon d'image brut, donc les pixels resteront toujours au format RGB565 avec l'ordre des octets inversé. Nous pouvons à nouveau utiliser ImageMagick pour convertir ce format en PNG pour le visualiser sur un ordinateur.

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

Bien sûr, nous pouvons implémenter la lecture / écriture au format BMP / PNG et nous débarrasser de tout ce tracas avec ImageMagick, mais ce n'est qu'un code de démonstration. Jusqu'à présent, je n'ai pas décidé quel format de fichier je veux utiliser pour stocker les sprites.


Il est la! Le tampon de trame Odroid Go s'affiche sur l'ordinateur de bureau.

Références



Partie 5: batterie


Odroid Go possède une batterie lithium-ion, nous pouvons donc créer un jeu auquel vous pouvez jouer en déplacement. C'est une idée tentante pour quelqu'un qui a joué le premier Gameboy enfant.

Par conséquent, nous avons besoin d'un moyen de demander le niveau de batterie de l'Odroid Go. La batterie est connectée au contact de l'ESP32, nous pouvons donc lire la tension pour avoir une idée approximative du temps de fonctionnement restant.

Schème



Le diagramme montre IO36 connecté à la tension VBAT après avoir été tiré à la terre par une résistance. Deux résistances ( R21 et R23 ) forment un diviseur de tension similaire à celui utilisé sur la croix du gamepad; les résistances ont à nouveau la même résistance de sorte que la tension est la moitié de l'original.

En raison du diviseur de tension, IO36 lira une tension égale à la moitié de VBAT . Cela est probablement dû au fait que les contacts ADC de l'ESP32 ne peuvent pas lire la haute tension de la batterie lithium-ion (4,2 V à la charge maximale). Quoi qu'il en soit, cela signifie que pour obtenir la vraie tension, vous devez doubler la tension lue à partir de l'ADC (ADC).

Lors de la lecture de la valeur de IO36, nous obtenons une valeur numérique, mais perdons la valeur analogique qu'elle représente. Nous avons besoin d'un moyen d'interpréter une valeur numérique avec un ADC sous la forme d'une tension analogique physique.

IDF vous permet de calibrer l'ADC, qui essaie de donner un niveau de tension basé sur la tension de référence. Cette tension de référence ( Vref ) est de 1100 mV par défaut, mais en raison des caractéristiques physiques, chaque appareil est légèrement différent. ESP32 dans Odroid Go a un Vref défini manuellement, «flashé» dans eFuse, que nous pouvons utiliser comme un Vref plus précis.

La procédure sera la suivante: d'abord, nous allons configurer l'étalonnage ADC, et lorsque nous voulons lire la tension, nous prendrons un certain nombre d'échantillons (par exemple, 20) pour calculer les lectures moyennes; nous utilisons ensuite l'IDF pour convertir ces lectures en tension. Le calcul de la moyenne élimine le bruit et donne des lectures plus précises.

Malheureusement, il n'y a pas de connexion linéaire entre la tension et la charge de la batterie. Lorsque la charge diminue, la tension baisse, lorsqu'elle augmente, elle augmente, mais de manière imprévisible. Tout ce que l'on peut dire: si la tension est inférieure à 3,6 V environ, la batterie est déchargée, mais il est étonnamment difficile de convertir avec précision le niveau de tension en pourcentage de la charge de la batterie.

Pour notre projet, ce n'est pas particulièrement important. Nous pouvons implémenter une approximation approximative pour informer le joueur de la nécessité de charger rapidement l'appareil, mais nous n'en souffrirons pas, en essayant d'obtenir le pourcentage exact.

LED d'état



Sur le panneau avant sous l'écran Odroid Go, il y a une LED bleue (LED), que nous pouvons utiliser à n'importe quelle fin. Vous pouvez leur montrer que l'appareil est allumé et fonctionne, mais dans ce cas, lorsque vous jouez dans l'obscurité, une LED bleu vif brillera sur votre visage. Par conséquent, nous l'utiliserons pour indiquer une faible charge de la batterie (bien que je préfère une couleur rouge ou ambre pour cela).

Pour utiliser la LED, vous devez définir IO2 comme sortie, puis lui appliquer un signal haut ou bas pour allumer et éteindre la LED.

Je pense qu'une résistance de 2 kΩ ( résistance de limitation de courant ) sera suffisante pour que nous ne brûlions pas la LED et fournissions trop de courant à partir de la broche GPIO.

La LED a une résistance assez faible, donc si 3,3 V lui est appliqué, alors nous la brûlerons en changeant le courant. Pour se protéger contre cela, une résistance est généralement connectée en série avec la LED.

Cependant, les résistances de limitation de courant pour les LED sont généralement bien inférieures à 2 kΩ, donc je ne comprends pas pourquoi la résistance R7 est une telle résistance.

Initialisation


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");
}

D'abord, nous définissons la LED GPIO comme sortie afin de pouvoir la commuter si nécessaire. Ensuite, nous configurons la broche ADC, comme nous l'avons fait dans le cas d'une croix - avec une largeur de bit de 12 et une atténuation minimale.

esp_adc_cal_characterize effectue des calculs pour nous de caractériser l'ADC afin que nous puissions plus tard convertir les lectures numériques en stress physique.

Lecture de la batterie


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;
}

Nous prenons vingt échantillons bruts de l'ADC au contact de l'ADC, puis les divisons pour obtenir la valeur moyenne. Comme mentionné ci-dessus, cela contribue à réduire le bruit des lectures.

Ensuite, nous utilisons esp_adc_cal_raw_to_voltage pour convertir la valeur brute en tension réelle. En raison du diviseur de tension mentionné ci-dessus, nous doublons la valeur de retour: la valeur lue sera la moitié de la tension réelle de la batterie.

Au lieu de trouver des moyens délicats de convertir cette tension en un pourcentage de la charge de la batterie, nous retournerons une tension simple. Laissez la fonction appelante décider d'elle-même ce qu'elle doit faire de la tension - que ce soit pour la transformer en pourcentage de la charge, ou simplement l'interpréter comme une valeur élevée ou faible.

La valeur est renvoyée en millivolts, la fonction appelante doit donc effectuer la conversion appropriée. Cela empêche le débordement du flotteur.

Réglage LED


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

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

Ces deux fonctions simples suffisent pour utiliser la LED. Nous pouvons soit allumer ou éteindre la lumière. Laissez la fonction appelante décider quand le faire.

Nous pourrions créer une tâche qui surveillerait périodiquement la tension de la batterie et changerait en conséquence l'état de la LED, mais je ferais mieux d'interroger la tension de la batterie dans notre cycle principal, puis de décider comment régler la tension de la batterie à partir de là.

Démo


uint32_t batteryLevel = Odroid_ReadBatteryLevel();

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

Nous pouvons simplement demander le niveau de la batterie dans le cycle principal, et si la tension est inférieure à la valeur seuil, allumez la LED, indiquant le besoin de charge. Sur la base des matériaux étudiés, je peux dire que 3600 mV (3,6 V) est un bon signe d'une faible charge des batteries lithium-ion, mais les batteries elles-mêmes sont complexes.

Références



Partie 6: son


La dernière étape pour obtenir une interface complète avec tout le matériel Odroid Go est d'écrire une couche sonore. Ayant fini avec cela, nous pouvons commencer à évoluer vers une programmation plus générale du jeu, moins liée à la programmation pour Odroid. Toutes les interactions avec les périphériques seront effectuées via les fonctions Odroid .

En raison de mon manque d'expérience en programmation sonore et du manque de bonne documentation de la part d'IDF, lorsque je travaillais sur un projet, la mise en œuvre du son prenait le plus de temps.

En fin de compte, il ne fallait pas tellement de code pour jouer le son. La plupart du temps a été consacré à la conversion des données audio en ESP32 souhaité et à la configuration du pilote audio ESP32 pour correspondre à la configuration matérielle.

Bases du son numérique


Le son numérique se compose de deux parties: l' enregistrement et la lecture .

Record


Pour enregistrer du son sur un ordinateur, nous devons d'abord le convertir de l'espace d'un signal continu (analogique) en l'espace d'un signal discret (numérique). Cette tâche est accomplie à l'aide d'un convertisseur analogique-numérique (ADC) (dont nous avons parlé lorsque nous avons travaillé avec la croix dans la partie 2).

L'ADC reçoit un échantillon de l' onde entrante et numérise la valeur, qui peut ensuite être enregistrée dans un fichier.

Jouer


Un fichier audio numérique peut être renvoyé de l'espace numérique à l'espace analogique à l'aide d'un convertisseur numérique-analogique (DAC) . DAC ne peut reproduire des valeurs que dans une certaine plage. Par exemple, un DAC 8 bits avec une source de 3,3 V peut produire des tensions analogiques dans la plage de 0 à 3,3 mV par pas de 12,9 mV (3,3 V divisé par 256).

Le DAC prend des valeurs numériques et les reconvertit en tension, qui peut être transmise à un amplificateur, un haut-parleur ou tout autre appareil capable de recevoir un signal audio analogique.

Taux d'échantillonnage


Lors de l'enregistrement d'un son analogique via l'ADC, des échantillons sont prélevés à une certaine fréquence et chaque échantillon est un «instantané» du signal sonore à un moment donné. Ce paramètre est appelé la fréquence d'échantillonnage et est mesuré en hertz .

Plus la fréquence d'échantillonnage est élevée, plus nous recréons avec précision les fréquences du signal d'origine. Le théorème de Nyquist-Shannon (Kotelnikov) stipule (en termes simples) que la fréquence d'échantillonnage doit être deux fois la fréquence de signal la plus élevée que nous voulons enregistrer.

L'oreille humaine peut entendre approximativement dans la plage de 20 Hz à 20 kHz , donc la fréquence d'échantillonnage de 44,1 kHz est le plus souvent utilisée pour recréer de la musique de haute qualité, qui est légèrement plus de deux fois la fréquence maximale que l'oreille humaine peut reconnaître. Cela garantit qu'un ensemble complet de fréquences d'instruments et de voix sera recréé.

Cependant, chaque échantillon occupe de l'espace dans le fichier, nous ne pouvons donc pas sélectionner la fréquence d'échantillonnage maximale. Cependant, si vous n'échantillonnez pas assez rapidement, vous pouvez perdre des informations importantes. La fréquence d'échantillonnage sélectionnée doit dépendre des fréquences présentes dans le son recréé.

La lecture doit être effectuée à la même fréquence d'échantillonnage que la source, sinon le son et sa durée seront différents.

Supposons que dix secondes de son soient enregistrées à une fréquence d'échantillonnage de 16 kHz. Si vous le jouez avec une fréquence de 8 kHz, alors sa tonalité sera plus basse et la durée sera de vingt secondes. Si vous le jouez avec une fréquence d'échantillonnage de 32 kHz, la tonalité audible sera plus élevée et le son lui-même durera cinq secondes.

Cette vidéo montre la différence de taux d'échantillonnage avec des exemples.

Peu profond


La fréquence d'échantillonnage n'est que la moitié de l'équation. Le son a également une profondeur de bits , c'est-à-dire le nombre de bits par échantillon.

Lorsque l'ADC capture un échantillon d'un signal audio, il doit convertir cette valeur analogique en numérique, et la plage de valeurs capturées dépend du nombre de bits utilisés. 8 bits (256 valeurs), 16 bits (65 526 valeurs), 32 bits (4 294 967 296 valeurs), etc.

Le nombre de bits par échantillon est lié à la plage dynamique du son, c'est-à-dire avec les parties les plus bruyantes et les plus silencieuses. La profondeur de bits la plus courante pour la musique est de 16 bits.

Pendant la lecture, il est nécessaire de fournir la même profondeur de bits que la source, sinon le son et sa durée changeront.

Par exemple, vous avez un fichier audio avec quatre échantillons stockés sur 8 bits: [0x25, 0xAB, 0x34, 0x80]. Si vous essayez de les lire comme s'ils étaient en 16 bits, vous n'obtiendrez que deux échantillons: [0x25AB, 0x3480]. Cela entraînera non seulement des valeurs incorrectes d'échantillons sonores, mais divisera également de moitié le nombre d'échantillons, et donc la durée du son.

Il est également important de connaître le format des échantillons. 8 bits non signé, 8 bits non signé, 16 bits non signé, 16 bits non signé, etc. Habituellement, 8 bits ne sont pas signés et 16 bits sont signés. S'ils sont confus, le son sera considérablement déformé.

Cette vidéo montre la différence de profondeur de bits avec des exemples.

Fichiers WAV


Le plus souvent, les données audio brutes sur un ordinateur sont stockées au format WAV , qui a un en-tête simple qui décrit le format audio (fréquence d'échantillonnage, profondeur de bits, taille, etc.), suivi des données audio elles-mêmes.

Le son n'est pas compressé du tout (contrairement aux formats comme MP3), nous pouvons donc facilement le lire sans avoir besoin d'une bibliothèque de codecs.

Le principal problème avec les fichiers WAV est qu'en raison du manque de compression, ils peuvent être assez volumineux. La taille du fichier est directement liée à la durée, la fréquence d'échantillonnage et la profondeur de bits.

Taille = Durée (en secondes) x Taux d'échantillonnage (échantillons / s) x Profondeur de bits (bit / échantillon)

La fréquence d'échantillonnage affecte le plus la taille du fichier, donc le moyen le plus simple d'économiser de l'espace est de sélectionner une valeur suffisamment faible. Nous allons créer un son old-school, donc une fréquence d'échantillonnage basse nous convient.

I2S


L'ESP32 possède des périphériques, grâce auxquels il est relativement simple de fournir une interface avec un équipement audio: Inter-IC Sound (I2S) .

Le protocole I2S est assez simple et se compose de seulement trois signaux: un signal d'horloge, un choix de canaux (gauche ou droite), ainsi que la ligne de données elle-même.

La fréquence d'horloge dépend de la fréquence d'échantillonnage, de la profondeur de bits et du nombre de canaux. Les battements sont remplacés pour chaque bit de données.Par conséquent, pour une bonne reproduction du son, vous devez régler la fréquence d'horloge en conséquence.

Fréquence d'horloge = Fréquence d'échantillonnage (échantillons / s) x Profondeur de bits (bits / échantillon) x Nombre de canaux

Le pilote du microcontrôleur I2S ESP32 a deux modes possibles: il peut soit émettre des données vers les contacts connectés à un récepteur I2S externe, qui peut décoder le protocole et transférer des données vers l'amplificateur, soit il peut transférer des données vers le DAC ESP32 interne émettant un signal analogique qui peut être transmis à amplificateur.

Odroid Go n'a pas de décodeur I2S sur la carte, nous devrons donc utiliser le DAC ESP32 8 bits interne, c'est-à-dire que nous devons utiliser un son 8 bits. L'appareil dispose de deux DAC, l'un connecté à IO25 , l'autre à IO26 .

La procédure ressemble à ceci:

  1. Nous transférons les données audio vers le pilote I2S
  2. Le pilote I2S envoie les données audio au DAC interne 8 bits
  3. Le DAC interne émet un signal analogique
  4. Le signal analogique est transmis à l'amplificateur sonore



Si nous regardons le circuit audio dans le circuit Odroid Go , nous verrons deux broches GPIO ( IO25 et IO26 ) connectées aux entrées de l'amplificateur de son ( PAM8304A ). L'IO25 est
également connecté au signal / SD de l' amplificateur, c'est-à-dire au contact qui allume ou éteint l'amplificateur (un signal faible signifie un arrêt). Les sorties de l'amplificateur sont connectées à un haut-parleur ( P1 ).

N'oubliez pas que IO25 et IO26 sont des sorties de DAC ESP32 8 bits, c'est-à-dire qu'un DAC est connecté à IN- et l'autre à IN + .

IN- et IN + sontentrées différentielles de l' amplificateur de son. Les entrées différentielles sont utilisées pour réduire le bruit provoqué par les interférences électromagnétiques . Tout bruit présent dans un signal sera également présent dans un autre. Un signal est soustrait d'un autre, ce qui élimine le bruit.

Si vous regardez les spécifications de l’amplificateur de son , il dispose d’un circuit d’application typique , qui est la manière recommandée par le fabricant d’utiliser l’amplificateur.


Il recommande de connecter IN- à la masse, IN + au signal d'entrée et / SD au signal marche / arrêt. S'il y a un bruit de 0,005 V, alors avec IN- 0V + 0,005V sera lu , et avec IN + - VIN + 0,005V . Les signaux d'entrée doivent être soustraits les uns des autres et obtenir la vraie valeur du signal ( VIN ) sans bruit.

Cependant, les concepteurs d'Odroid Go n'ont pas utilisé la configuration recommandée.

Une fois de plus en regardant le circuit Odroid Go, nous voyons que les concepteurs ont connecté la sortie DAC à IN- et que la même sortie DAC est connectée à / SD . / DAKOTA DU SUD- Il s'agit d'un signal d'arrêt avec un niveau bas actif, donc pour que l'amplificateur fonctionne, vous devez définir un signal haut.

Cela signifie que pour utiliser l'amplificateur, nous ne devons pas utiliser l' IO25 comme DAC, mais comme sortie GPIO avec un signal toujours élevé. Cependant, dans ce cas, un signal haut est réglé sur IN- , ce qui n'est pas recommandé par la spécification de l'amplificateur (il doit être mis à la terre). Ensuite, nous devons utiliser le DAC connecté à IO26 , car notre sortie I2S doit être alimentée en IN + . Cela signifie que nous n'atteindrons pas la réduction de bruit nécessaire, car IN- n'est pas connecté à la terre. Un bruit doux émane constamment des haut-parleurs.

Nous devons garantir la configuration correcte du pilote I2S, car nous voulons utiliser uniquement le DAC connecté à IO26 . Si nous utilisions un DAC connecté à IO25 , il éteindrait constamment le signal de l'amplificateur et le son serait terrible.

En plus de cette bizarrerie, lors de l'utilisation d'un DAC interne 8 bits, le pilote I2S dans l'ESP32 nécessite la transmission d'échantillons 16 bits, mais envoie uniquement l'octet de poids fort au DAC 8 bits. Par conséquent, nous devons prendre notre son 8 bits et le coller dans un tampon deux fois plus grand, tandis que le tampon sera à moitié vide. Ensuite, nous le transmettons au pilote I2S et il transmet au DAC l'octet de poids fort de chaque échantillon. Malheureusement, cela signifie que nous devons «payer» pour 16 bits, mais nous ne pouvons utiliser que 8 bits.

Multitâche


Malheureusement, le jeu ne peut pas fonctionner sur un seul cœur, comme je le voulais à l'origine, car il semble y avoir un bug dans le pilote I2S.

Le pilote I2S doit utiliser DMA (comme le pilote SPI), c'est-à-dire que nous pourrions simplement lancer le transfert d'I2S, puis continuer notre travail pendant que le pilote I2S transmet des données audio.

Mais à la place, le CPU est bloqué pendant la durée du son, ce qui est totalement inadapté au jeu. Imaginez que vous appuyez sur le bouton de saut, puis que l'image-objet du joueur interrompt son mouvement pendant 100 ms pendant la lecture du son de saut.

Pour résoudre ce problème, nous pouvons profiter du fait qu'il y a deux cœurs à bord de l'ESP32. Nous pouvons créer une tâche (c'est-à-dire un fil) dans le deuxième noyau, qui traitera de la reproduction sonore. Grâce à cela, nous pouvons transférer le pointeur vers le tampon sonore de la tâche principale du jeu à la tâche sonore, et la tâche sonore initie le transfert de I2S et est bloquée pendant la durée de la lecture du son. Mais la tâche principale sur le premier noyau (avec traitement d'entrée et rendu) continuera de s'exécuter sans blocage.

Initialisation


Sachant cela, nous pouvons lancer correctement le pilote I2S. Pour ce faire, vous n'avez besoin que de quelques lignes de code, mais la difficulté est de savoir quels paramètres vous devez définir pour une bonne reproduction du son.

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);
	}
}

Tout d'abord, nous configurons l' IO25 (qui est connecté au signal de mise hors tension de l'amplificateur) en tant que sortie afin qu'il puisse contrôler l'amplificateur sonore, et lui appliquons un signal élevé pour allumer l'amplificateur.

Ensuite, nous configurons et installons le pilote I2S lui-même. Je vais analyser chaque partie de la configuration ligne par ligne, car chacune des lignes nécessite une explication:

  • mode
    • nous définissons le pilote comme un maître (contrôlant le bus), un émetteur (car nous transférons des données aux destinataires), et le configurons pour utiliser le DAC 8 bits intégré (car la carte Odroid Go n'a pas de DAC externe).
  • taux d'échantillonnage
    • 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. , .

Ensuite, nous créons une file d'attente - c'est ainsi que FreeRTOS envoie des données entre les tâches. Nous mettons les données dans la file d'attente d'une tâche et les extrayons de la file d'attente d'une autre tâche. Créez une structure appelée QueueData qui combine le pointeur vers le tampon audio et la longueur du tampon en une seule structure qui peut être mise en file d'attente.

Ensuite, créez une tâche qui s'exécute sur le deuxième cœur. Nous le connectons à la fonction PlayTask , qui effectue la lecture du son. La tâche elle-même est une boucle sans fin qui vérifie constamment s'il y a des données dans la file d'attente. S'ils le sont, elle les envoie au pilote I2S afin qu'ils puissent être lus. Il bloquera l'appel i2s_write, et cela nous convient, car la tâche est effectuée sur un noyau distinct du thread principal du jeu.

Un appel à i2s_zero_dma_buffer est requis pour que, une fois la lecture terminée, il ne reste plus aucun son des haut-parleurs. Je ne sais pas s'il s'agit d'un bogue du pilote I2S ou du comportement attendu, mais sans cela, une fois la mémoire tampon de son terminée, le haut-parleur émet un signal parasite.

Jouer son


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

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

	xQueueSendToBack(gQueue, &data, portMAX_DELAY);
}

Étant donné que la configuration complète est déjà terminée, l'appel à la fonction de lecture du tampon audio lui-même est extrêmement simple, car le travail principal est effectué dans une autre tâche. Nous plaçons le pointeur sur le tampon et la longueur du tampon dans la structure QueueData , puis le mettons dans la file d'attente utilisée par la fonction PlayTask .

En raison de ce schéma de fonctionnement, un tampon audio doit terminer la lecture avant de pouvoir démarrer le second tampon. Par conséquent, si un saut et une prise de vue se produisent simultanément, le premier son sera joué avant le second, et non simultanément avec lui.

Très probablement, à l'avenir, je mélangerai différents sons de trame dans le tampon sonore qui est transmis au pilote I2S. Cela vous permettra de jouer plusieurs sons en même temps.

Démo


Nous générerons nos propres effets sonores à l'aide de jsfxr , un outil spécialement conçu pour générer le type de sons de jeu dont nous avons besoin. Nous pouvons directement définir la fréquence d'échantillonnage et la profondeur de bits, puis générer le fichier WAV.

J'ai créé un simple effet sonore de saut qui ressemble au son du saut de Mario. Il a une fréquence d'échantillonnage de 5012 (comme nous l'avons configuré lors de l'initialisation) et une profondeur de bits de 8 (car le DAC est de 8 bits).


Au lieu d'analyser le fichier WAV directement dans le code, nous ferons quelque chose de similaire à ce que nous avons fait pour charger le sprite dans la démo de la partie 4: nous supprimerons l'en-tête WAV du fichier en utilisant l'éditeur hexadécimal. Grâce à cela, le fichier lu sur la carte SD ne sera que des données brutes. De plus, nous ne lirons pas la durée du son, nous l'écrirons dans le code. À l'avenir, nous chargerons les ressources sonores différemment, mais cela suffit pour la démo.

Le fichier brut peut être téléchargé à partir d'ici .

// 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);
    }
}

Nous chargeons les données 8 bits dans le tampon soundEffect 8 bits , puis copions ces données dans le tampon soundBuffer 16 bits , où les données seront stockées dans les huit bits de poids fort. Je le répète - cela est nécessaire en raison des caractéristiques de la mise en œuvre de Tsahal.

Après avoir créé un tampon 16 bits, nous pouvons jouer le son d'un clic sur un bouton. Il serait logique d'utiliser le bouton de volume pour cela.

int lastState = 0;

for (;;)
{
	[...]

	int thisState = input.volume;

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

	lastState = thisState;

	[...]
}

Nous surveillons l'état du bouton afin que, accidentellement, en un seul clic, vous n'appeliez pas accidentellement Odroid_PlayAudio plusieurs fois.


La source


Tout le code source est ici .

Références



All Articles