Programación de un juego para un dispositivo integrado en ESP32: unidad, batería, sonido

imagen


Inicio: sistema de montaje, entrada, pantalla .

Parte 4: conducir


Odroid Go tiene una ranura para tarjeta microSD, que será útil para descargar recursos (sprites, archivos de sonido, fuentes) y posiblemente incluso para guardar el estado del juego.

El lector de tarjetas está conectado a través de SPI, pero IDF facilita la interacción con la tarjeta SD al abstraer las llamadas SPI y usar funciones POSIX estándar como fopen , fread y fwrite . Todo esto se basa en la biblioteca FatFs , por lo que la tarjeta SD debe formatearse en el formato FAT estándar.

Está conectado al mismo bus SPI que el LCD, pero utiliza una línea de selección de chip diferente. Cuando necesitamos leer o escribir en la tarjeta SD (y esto no sucede muy a menudo), el controlador SPI cambiará la señal CS de la pantalla al lector de tarjetas SD y luego realizará la operación. Esto significa que al enviar datos a la pantalla, no podemos realizar ninguna operación con la tarjeta SD, y viceversa.

En este momento, estamos haciendo todo en un solo hilo y estamos utilizando la transmisión de bloqueo a través de SPI a la pantalla, por lo que no puede haber transacciones simultáneas con la tarjeta SD y con la pantalla LCD. En cualquier caso, existe una alta probabilidad de que carguemos todos los recursos en el momento del lanzamiento.

Modificación de la ESP-IDF


Si intentamos inicializar la interfaz de la tarjeta SD después de la inicialización de la pantalla, encontraremos un problema que hace imposible cargar Odroid Go. ESP-IDF v4.0 no admite acceso compartido al bus SPI cuando se usa con una tarjeta SD. Recientemente, los desarrolladores han agregado esta funcionalidad, pero aún no está en una versión estable, por lo que nosotros mismos haremos una pequeña modificación.

Comente la línea 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;
}

Después de hacer este cambio, aún veremos un error durante la inicialización, pero ya no hará que el ESP32 se reinicie, porque el código de error no se propaga arriba.

Inicialización




Necesitamos decirle a IDF qué pines ESP32 están conectados al lector MicroSD para que configure correctamente el controlador SPI subyacente, que realmente se comunica con el lector.

Las notas generales VSPI.XXXX se usan nuevamente en el diagrama , pero podemos revisarlas hasta los números de contacto reales en ESP32.

La inicialización es similar a la inicialización de la pantalla LCD, pero en lugar de la estructura general de configuración SPI, utilizamos sdspi_slot_config_t , diseñado para una tarjeta SD conectada a través del bus SPI. Configuramos los números de contacto correspondientes y las propiedades de montaje de la tarjeta en el sistema FatFS.

La documentación de IDF no recomienda usar la función esp_vfs_fat_sdmmc_mounten el código del programa terminado. Esta es una función de envoltura que realiza muchas operaciones para nosotros, pero hasta ahora funciona con bastante normalidad, y probablemente nada cambiará en el futuro.

El parámetro "/ sdcard" de esta función establece el punto de montaje virtual de la tarjeta SD, que luego utilizaremos como prefijo cuando trabajemos con archivos. Si tuviéramos un archivo llamado "test.txt" en nuestra tarjeta SD, la ruta que usaríamos para vincularlo sería "/sdcard/test.txt".

Después de la inicialización de la interfaz de la tarjeta SD, la interacción con los archivos es trivial: simplemente podemos usar llamadas estándar a las funciones POSIX , lo cual es muy conveniente.

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



Creé un sprite de 64x64 en Aseprite (terrible) que usa solo dos colores: completamente negro (píxel deshabilitado) y completamente blanco (píxel habilitado). Aseprite no tiene la opción de guardar el color RGB565 o exportarlo como un mapa de bits sin procesar (es decir, sin compresión y encabezados de imagen), por lo que exporté el sprite a un formato PNG temporal.

Luego, usando ImageMagick, convertí los datos a un archivo PPM, que convirtió la imagen en datos sin comprimir sin formato con un encabezado simple. Luego, abrí la imagen en un editor hexadecimal, eliminé el encabezado y convertí el color de 24 bits a 16 bits, eliminando todas las ocurrencias 0x000000 a 0x0000 , y todas las ocurrencias 0xFFFFFF a 0xFFFF. El orden de bytes aquí no es un problema, porque 0x0000 y 0xFFFF no cambian al cambiar el orden de bytes.

El archivo sin formato se puede descargar desde aquí .

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

Primero, abrimos el archivo de clave que contiene bytes sin procesar y lo leemos en el búfer. En el futuro, cargaremos los recursos de sprites de manera diferente, pero para una demostración esto es suficiente.

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

Para dibujar un sprite, recorremos iterativamente su contenido. Si el píxel es blanco, lo dibujamos en el color seleccionado por los botones. Si es negro, lo consideramos un fondo y no dibujamos.


La cámara de mi teléfono tiene distorsiones de color. Y perdón por sacudirla.

Para probar la grabación de la imagen, moveremos la tecla a algún lugar de la pantalla, cambiaremos su color y luego escribiremos el buffer de cuadros en la tarjeta SD para que pueda verse en la computadora.

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

Al presionar la tecla Menú, se guarda el contenido del búfer de cuadros en un archivo llamado framebuf . Este será un búfer de cuadros sin formato, por lo que los píxeles permanecerán en formato RGB565 con el orden de bytes invertido. Podemos usar nuevamente ImageMagick para convertir este formato a PNG para verlo en una computadora.

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

Por supuesto, podemos implementar la lectura / escritura en formato BMP / PNG y deshacernos de todo este alboroto con ImageMagick, pero esto es solo un código de demostración. Hasta ahora no he decidido qué formato de archivo quiero usar para almacenar sprites.


¡Aquí está él! El búfer de cuadro Odroid Go se muestra en la computadora de escritorio.

Referencias



Parte 5: batería


Odroid Go tiene una batería de iones de litio, por lo que podemos crear un juego que puedas jugar sobre la marcha. Esta es una idea tentadora para alguien que jugó el primer Gameboy cuando era niño.

Por lo tanto, necesitamos una forma de solicitar el nivel de batería del Odroid Go. La batería está conectada al contacto en el ESP32, por lo que podemos leer el voltaje para tener una idea aproximada del tiempo de funcionamiento restante.

Esquema



El diagrama muestra IO36 conectado al voltaje VBAT después de ser llevado a tierra a través de una resistencia. Dos resistencias ( R21 y R23 ) forman un divisor de voltaje similar al que se usa en la cruz del gamepad; Las resistencias vuelven a tener la misma resistencia, de modo que el voltaje es la mitad del original.

Debido al divisor de voltaje, IO36 leerá un voltaje igual a la mitad de VBAT . Esto probablemente se haga porque los contactos ADC en el ESP32 no pueden leer el alto voltaje de la batería de iones de litio (4.2 V con carga máxima). Sea como fuere, esto significa que para obtener el voltaje verdadero, debe duplicar la lectura de voltaje del ADC (ADC).

Al leer el valor de IO36, obtenemos un valor digital, pero perdemos el valor analógico que representa. Necesitamos una manera de interpretar un valor digital con un ADC en forma de un voltaje analógico físico.

IDF le permite calibrar el ADC, que intenta dar un nivel de voltaje basado en el voltaje de referencia. Este voltaje de referencia ( Vref ) es de 1100 mV por defecto, pero debido a las características físicas, cada dispositivo es ligeramente diferente. ESP32 en Odroid Go tiene un Vref definido manualmente, "flasheado" en eFuse, que podemos usar como un Vref más preciso.

El procedimiento será el siguiente: primero, configuraremos la calibración ADC, y cuando queramos leer el voltaje, tomaremos un cierto número de muestras (por ejemplo, 20) para calcular las lecturas promedio; entonces usamos el IDF para convertir estas lecturas a voltaje. El cálculo del promedio elimina el ruido y brinda lecturas más precisas.

Desafortunadamente, no hay una conexión lineal entre el voltaje y la carga de la batería. Cuando la carga disminuye, el voltaje cae, cuando aumenta, aumenta, pero de manera impredecible. Todo lo que se puede decir: si el voltaje es inferior a aproximadamente 3,6 V, entonces la batería se descarga, pero es sorprendentemente difícil convertir con precisión el nivel de voltaje en un porcentaje de la carga de la batería.

Para nuestro proyecto, esto no es particularmente importante. Podemos implementar una aproximación aproximada para que el jugador sepa sobre la necesidad de cargar rápidamente el dispositivo, pero no sufriremos, tratando de obtener el porcentaje exacto.

LED de estado



En el panel frontal debajo de la pantalla Odroid Go hay un LED azul (LED), que podemos usar para cualquier propósito. Puede mostrarles que el dispositivo está encendido y funcionando, pero en este caso, cuando juega en la oscuridad, un LED azul brillante brillará en su cara. Por lo tanto, lo usaremos para indicar una carga baja de la batería (aunque preferiría un color rojo o ámbar para esto).

Para usar el LED, debe configurar IO2 como salida y luego aplicarle una señal alta o baja para encender y apagar el LED.

Creo que una resistencia de 2 kΩ ( resistencia limitadora de corriente ) será suficiente para que no quememos el LED y suministremos demasiada corriente desde el pin GPIO.

El LED tiene una resistencia bastante baja, por lo que si se le aplican 3,3 V, lo quemaremos cambiando la corriente. Para protegerse contra esto, generalmente se conecta una resistencia en serie con el LED.

Sin embargo, las resistencias limitadoras de corriente para los LED suelen ser mucho menos de 2 kΩ, por lo que no entiendo por qué la resistencia R7 es tal resistencia.

Inicialización


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

Primero configuramos el LED GPIO como salida para que podamos cambiarlo si es necesario. Luego configuramos el pin ADC, como lo hicimos en el caso de una cruz, con un ancho de bits de 12 y atenuación mínima.

esp_adc_cal_characterize realiza cálculos para que podamos caracterizar el ADC de modo que luego podamos convertir las lecturas digitales en estrés físico.

Lectura de la batería


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

Tomamos veinte muestras sin procesar del ADC del contacto del ADC, y luego las dividimos para obtener el valor promedio. Como se mencionó anteriormente, esto ayuda a reducir el ruido de las lecturas.

Luego usamos esp_adc_cal_raw_to_voltage para convertir el valor bruto al voltaje real. Debido al divisor de voltaje mencionado anteriormente, duplicamos el valor de retorno: el valor de lectura será la mitad del voltaje real de la batería.

En lugar de encontrar formas difíciles de convertir este voltaje a un porcentaje de la carga de la batería, devolveremos un voltaje simple. Deje que la función de llamada decida por sí misma qué hacer con el voltaje, ya sea convertirlo en un porcentaje de la carga o simplemente interpretarlo como un valor alto o bajo.

El valor se devuelve en milivoltios, por lo que la función de llamada debe realizar la conversión adecuada. Esto evita el desbordamiento del flotador.

Ajuste de LED


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

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

Estas dos funciones simples son suficientes para usar el LED. Podemos encender o apagar la luz. Deje que la función de llamada decida cuándo hacerlo.

Podríamos crear una tarea que monitoree periódicamente el voltaje de la batería y, en consecuencia, cambie el estado del LED, pero sería mejor interrogar el voltaje de la batería en nuestro ciclo principal, y luego decidir cómo configurar el voltaje de la batería desde allí.

Manifestación


uint32_t batteryLevel = Odroid_ReadBatteryLevel();

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

Simplemente podemos solicitar el nivel de batería en el ciclo principal, y si el voltaje está por debajo del valor umbral, encienda el LED, lo que indica la necesidad de cargarlo. Con base en los materiales estudiados, puedo decir que 3600 mV (3.6 V) es una buena señal de una baja carga de baterías de iones de litio, pero las baterías en sí son complejas.

Referencias



Parte 6: sonido


El paso final para obtener una interfaz completa para todo el hardware Odroid Go es escribir una capa de sonido. Una vez terminado esto, podemos comenzar a avanzar hacia una programación más general del juego, menos relacionada con la programación para Odroid. Toda interacción con periféricos se realizará a través de las funciones Odroid .

Debido a mi falta de experiencia con la programación de sonido y la falta de buena documentación por parte de IDF, cuando trabajé en un proyecto, la implementación del sonido tomó más tiempo.

Finalmente, no se requería tanto código para reproducir el sonido. La mayor parte del tiempo se dedicó a cómo convertir los datos de audio al ESP32 deseado y cómo configurar el controlador de audio ESP32 para que coincida con la configuración del hardware.

Conceptos básicos de sonido digital


El sonido digital consta de dos partes: grabación y reproducción .

Grabar


Para grabar sonido en una computadora, primero debemos convertirlo desde el espacio de una señal continua (analógica) al espacio de una señal discreta (digital). Esta tarea se logra utilizando un convertidor analógico a digital (ADC) (del que hablamos cuando trabajamos con la cruz en la Parte 2).

El ADC recibe una muestra de la onda entrante y digitaliza el valor, que luego se puede guardar en un archivo.

Jugar


Se puede devolver un archivo de sonido digital del espacio digital al analógico utilizando un convertidor digital a analógico (DAC) . DAC puede reproducir valores solo en un cierto rango. Por ejemplo, un DAC de 8 bits con una fuente de 3.3 V puede generar voltajes analógicos en el rango de 0 a 3.3 mV en pasos de 12.9 mV (3.3 V dividido por 256).

El DAC toma valores digitales y los convierte de nuevo en voltaje, que puede transmitirse a un amplificador, altavoz o cualquier otro dispositivo capaz de recibir una señal de audio analógica.

Tasa de muestreo


Al grabar sonido analógico a través del ADC, las muestras se toman a una frecuencia determinada, y cada muestra es una "instantánea" de la señal de sonido en un momento determinado. Este parámetro se llama frecuencia de muestreo y se mide en hercios .

Cuanto mayor es la frecuencia de muestreo, más exactamente recreamos las frecuencias de la señal original. El teorema de Nyquist-Shannon (Kotelnikov) establece (en términos simples) que la frecuencia de muestreo debe ser el doble de la frecuencia de señal más alta que queremos registrar.

El oído humano puede escuchar aproximadamente en el rango de 20 Hz a 20 kHz , por lo que la frecuencia de muestreo de 44,1 kHz se usa con mayor frecuencia para recrear música de alta calidad., que es un poco más del doble de la frecuencia máxima que el oído humano puede reconocer. Esto asegura que se recreará un conjunto completo de frecuencias de instrumentos y voz.

Sin embargo, cada muestra ocupa espacio en el archivo, por lo que no podemos seleccionar la frecuencia de muestreo máxima. Sin embargo, si no toma muestras lo suficientemente rápido, puede perder información importante. La frecuencia de muestreo seleccionada debe depender de las frecuencias presentes en el sonido recreado.

La reproducción debe realizarse a la misma frecuencia de muestreo que la fuente; de ​​lo contrario, el sonido y su duración serán diferentes.

Supongamos que se grabaron diez segundos de sonido a una frecuencia de muestreo de 16 kHz. Si lo juegas con una frecuencia de 8 kHz, entonces su tono será más bajo y la duración será de veinte segundos. Si lo toca con una frecuencia de muestreo de 32 kHz, el tono audible será más alto y el sonido en sí mismo durará cinco segundos.

Este video muestra la diferencia en las frecuencias de muestreo con ejemplos.

Profundidad de bits


La frecuencia de muestreo es solo la mitad de la ecuación. El sonido también tiene una profundidad de bits , es decir, el número de bits por muestra.

Cuando el ADC captura una muestra de una señal de audio, debe convertir este valor analógico a digital, y el rango de valores capturados depende del número de bits utilizados. 8 bits (256 valores), 16 bits (65,526 valores), 32 bits (4,294,967,296 valores), etc.

El número de bits por muestra está relacionado con el rango dinámico del sonido, es decir. con las partes más ruidosas y silenciosas. La profundidad de bits más común para la música es de 16 bits.

Durante la reproducción, es necesario proporcionar la misma profundidad de bits que la fuente; de ​​lo contrario, el sonido y su duración cambiarán.

Por ejemplo, tiene un archivo de audio con cuatro muestras almacenadas como 8 bits: [0x25, 0xAB, 0x34, 0x80]. Si intenta reproducirlos como si fueran de 16 bits, obtendrá solo dos muestras: [0x25AB, 0x3480]. Esto no solo conducirá a valores incorrectos de muestras de sonido, sino que también reducirá a la mitad el número de muestras y, por lo tanto, la duración del sonido.

También es importante conocer el formato de las muestras. 8 bits sin signo, 8 bits sin signo, 16 bits sin signo, 16 bits sin signo, etc. Por lo general, 8 bits no están firmados y 16 bits están firmados. Si están confundidos, el sonido estará muy distorsionado.

Este video muestra la diferencia de profundidad de bits con ejemplos.

Archivos wav


Muy a menudo, los datos de audio sin procesar en una computadora se almacenan en el formato WAV , que tiene un encabezado simple que describe el formato de sonido (frecuencia de muestreo, profundidad de bits, tamaño, etc.), seguido de los datos de audio en sí.

El sonido no está comprimido en absoluto (a diferencia de los formatos como MP3), por lo que podemos reproducirlo fácilmente sin la necesidad de una biblioteca de códecs.

El principal problema con los archivos WAV es que, debido a la falta de compresión, pueden ser bastante grandes. El tamaño del archivo está directamente relacionado con la duración, la frecuencia de muestreo y la profundidad de bits.

Tamaño = Duración (en segundos) x Velocidad de muestreo (muestras / s) x Profundidad de bits (bit / muestra)

La frecuencia de muestreo afecta más al tamaño del archivo, por lo que la forma más fácil de ahorrar espacio es seleccionar un valor suficientemente bajo. Crearemos un sonido de la vieja escuela, por lo que una baja frecuencia de muestreo nos conviene.

I2S


ESP32 tiene periféricos, por lo que es relativamente simple proporcionar una interfaz con equipo de audio: Inter-IC Sound (I2S) .

El protocolo I2S es bastante simple y consta de solo tres señales: una señal de reloj, una selección de canales (izquierda o derecha) y también la línea de datos en sí.

La frecuencia del reloj depende de la frecuencia de muestreo, la profundidad de bits y el número de canales. Los ritmos se reemplazan por cada bit de datos, por lo tanto, para una reproducción de sonido adecuada, debe configurar la frecuencia del reloj en consecuencia.

Frecuencia de reloj = Frecuencia de muestreo (muestras / s) x Profundidad de bits (bits / muestra) x Número de canales

El controlador I2S del microcontrolador ESP32 tiene dos modos posibles: puede enviar datos a los contactos conectados a un receptor I2S externo, que puede decodificar el protocolo y transferir datos al amplificador, o puede transferir datos al DAC ESP32 interno que emite una señal analógica que puede transmitirse a amplificador.

Odroid Go no tiene ningún decodificador I2S en el tablero, por lo que tendremos que usar el DAC ESP32 interno de 8 bits, es decir, debemos usar sonido de 8 bits. El dispositivo tiene dos DAC, uno conectado a IO25 y el otro a IO26 .

El procedimiento se ve así:

  1. Transferimos datos de audio al controlador I2S
  2. El controlador I2S envía datos de audio a DAC interno de 8 bits
  3. Señal analógica de salidas DAC internas
  4. La señal analógica se transmite al amplificador de sonido.



Si observamos el circuito de audio en el circuito Odroid Go , veremos dos pines GPIO ( IO25 e IO26 ) conectados a las entradas del amplificador de sonido ( PAM8304A ). IO25
también está conectado a la señal / SD del amplificador, es decir, el contacto que enciende o apaga el amplificador (señal baja significa apagado). Las salidas del amplificador están conectadas a un altavoz ( P1 ).

Recuerde que IO25 e IO26 son salidas de DAC ESP32 de 8 bits, es decir, un DAC está conectado a IN- y el otro a IN + .

IN- e IN + sonentradas diferenciales del amplificador de sonido. Las entradas diferenciales se utilizan para reducir el ruido causado por la interferencia electromagnética . Cualquier ruido presente en una señal también estará presente en otra. Una señal se resta de otra, lo que elimina el ruido.

Si observa las especificaciones del amplificador de sonido , tiene un circuito de aplicaciones típicas , que es la forma recomendada por el fabricante para utilizar el amplificador.


Recomienda conectar IN- a tierra, IN + a la señal de entrada y / SD a la señal de encendido / apagado. Si hay un ruido de 0.005 V, entonces se leerá IN- 0V + 0.005V , y con IN + - VIN + 0.005V . Las señales de entrada deben sustraerse entre sí y obtener el valor de señal verdadero ( VIN ) sin ruido.

Sin embargo, los diseñadores de Odroid Go no utilizaron la configuración recomendada.

Una vez más, mirando el circuito Odroid Go, vemos que los diseñadores conectaron la salida DAC a IN- y que la misma salida DAC está conectada a / SD . / DAKOTA DEL SUR- Esta es una señal de apagado con un nivel bajo activo, por lo que para que el amplificador funcione, debe configurar una señal alta.

Esto significa que para usar el amplificador, no debemos usar el IO25 como un DAC, sino como una salida GPIO con una señal siempre alta. Sin embargo, en este caso, una señal alta se establece en IN- , lo cual no es recomendado por la especificación del amplificador (debe estar conectado a tierra). Luego debemos usar el DAC conectado a IO26 , ya que nuestra salida I2S debe alimentarse a IN + . Esto significa que no lograremos la reducción de ruido necesaria, ya que IN- no está conectado a tierra. El ruido suave emana constantemente de los altavoces.

Necesitamos asegurar la configuración correcta del controlador I2S, porque queremos usar solo el DAC conectado a IO26 . Si utilizáramos un DAC conectado a IO25 , apagaría constantemente la señal del amplificador y el sonido sería terrible.

Además de esta rareza, cuando se usa un DAC interno de 8 bits, el controlador I2S en el ESP32 requiere que se le transmitan muestras de 16 bits, pero solo envía el byte alto al DAC de 8 bits. Por lo tanto, necesitamos tomar nuestro sonido de 8 bits y pegarlo en un búfer el doble de grande, mientras que el búfer estará medio vacío. Luego lo pasamos al controlador I2S y le pasa al DAC el byte alto de cada muestra. Desafortunadamente, esto significa que tenemos que "pagar" por 16 bits, pero solo podemos usar 8 bits.

Multitarea


Desafortunadamente, el juego no puede funcionar en un núcleo, como originalmente quería, porque parece haber un error en el controlador I2S.

El controlador I2S debe usar DMA (como el controlador SPI), es decir, podríamos iniciar la transferencia de I2S y luego continuar nuestro trabajo mientras el controlador I2S transmite datos de audio.

Pero en cambio, la CPU está bloqueada durante la duración del sonido, lo que no es apto para el juego. Imagine que presiona el botón de salto, y luego el sprite del jugador detiene su movimiento durante 100 ms mientras se reproduce el sonido de salto.

Para resolver este problema, podemos aprovechar el hecho de que hay dos núcleos a bordo del ESP32. Podemos crear una tarea (es decir, un hilo) en el segundo núcleo, que se ocupará de la reproducción de sonido. Gracias a esto, podemos transferir el puntero al búfer de sonido desde la tarea principal del juego a la tarea de sonido, y la tarea de sonido inicia la transferencia de I2S y se bloquea durante la reproducción del sonido. Pero la tarea principal en el primer núcleo (con procesamiento y renderizado de entrada) continuará ejecutándose sin bloqueo.

Inicialización


Sabiendo esto, podemos iniciar correctamente el controlador I2S. Para hacer esto, solo necesita unas pocas líneas de código, pero la dificultad es descubrir qué parámetros necesita establecer para una reproducción de sonido adecuada.

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

Primero, configuramos IO25 (que está conectado a la señal de apagado del amplificador) como una salida para que pueda controlar el amplificador de sonido y aplicarle una señal alta para encender el amplificador.

A continuación, configuramos e instalamos el controlador I2S. Analizaré cada parte de la configuración línea por línea, porque cada una de las líneas requiere explicación:

  • modo
    • configuramos el controlador como maestro (control del bus), un transmisor (porque transferimos datos a los destinatarios) y lo configuramos para usar el DAC incorporado de 8 bits (porque la placa Odroid Go no tiene un DAC externo).
  • tasa_muestra
    • 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. , .

Luego creamos una cola: esta es la forma en que FreeRTOS envía datos entre tareas. Ponemos los datos en la cola de una tarea y los extraemos de la cola de otra tarea. Cree una estructura llamada QueueData que combine el puntero al búfer de sonido y la longitud del búfer en una sola estructura que se puede poner en cola.

A continuación, cree una tarea que se ejecute en el segundo núcleo. Lo conectamos a la función PlayTask , que realiza la reproducción de sonido. La tarea en sí es un bucle sin fin que verifica constantemente para ver si hay datos en la cola. Si es así, los envía al controlador I2S para que puedan reproducirse. Bloqueará la llamada i2s_write, y esto nos conviene, porque la tarea se realiza en un núcleo separado del hilo principal del juego.

Se requiere una llamada a i2s_zero_dma_buffer para que después de que se complete la reproducción no queden sonidos de los altavoces. No sé si esto es un error del controlador I2S o el comportamiento esperado, pero sin él, una vez que el búfer de sonido ha terminado de reproducirse, el altavoz emite una señal de basura.

Reproducir sonido


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

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

	xQueueSendToBack(gQueue, &data, portMAX_DELAY);
}

Debido al hecho de que toda la configuración ya se ha completado, la llamada a la función de reproducción del búfer de sonido en sí es extremadamente simple, porque el trabajo principal se realiza en otra tarea. Ponemos el puntero en el búfer y la longitud del búfer en la estructura QueueData , y luego lo colocamos en la cola utilizada por la función PlayTask .

Debido a este patrón de operación, un búfer de sonido debe completar la reproducción antes de que pueda iniciar el segundo búfer. Por lo tanto, si se produce un salto y un disparo simultáneamente, el primer sonido se reproducirá antes que el segundo, y no simultáneamente con él.

Lo más probable es que en el futuro mezcle diferentes sonidos de cuadro en el búfer de sonido que se transmite al controlador I2S. Esto te permitirá reproducir múltiples sonidos simultáneamente.

Manifestación


Generaremos nuestros propios efectos de sonido usando jsfxr , una herramienta diseñada específicamente para generar el tipo de sonidos de juego que necesitamos. Podemos establecer directamente la frecuencia de muestreo y la profundidad de bits, y luego generar el archivo WAV.

Creé un simple efecto de sonido de salto que se asemeja al sonido del salto de Mario. Tiene una frecuencia de muestreo de 5012 (como la configuramos durante la inicialización) y una profundidad de 8 bits (porque el DAC es de 8 bits).


En lugar de analizar el archivo WAV directamente en el código, haremos algo similar a lo que hicimos para cargar el sprite en la demostración de la Parte 4: eliminaremos el encabezado WAV del archivo usando el editor hexadecimal. Gracias a esto, el archivo leído desde la tarjeta SD será solo información en bruto. Además, no leeremos la duración del sonido, lo escribiremos en el código. En el futuro, cargaremos recursos de sonido de manera diferente, pero esto es suficiente para la demostración.

El archivo sin formato se puede descargar desde aquí .

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

Cargamos los datos de 8 bits en el búfer soundEffect de 8 bits , y luego copiamos estos datos en el búfer soundBuffer de 16 bits , donde los datos se almacenarán en los ocho bits más altos. Repito: esto es necesario debido a las características de la implementación de IDF.

Después de haber creado un búfer de 16 bits, podemos reproducir el sonido de un clic de un botón. Sería lógico usar el botón de volumen para esto.

int lastState = 0;

for (;;)
{
	[...]

	int thisState = input.volume;

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

	lastState = thisState;

	[...]
}

Monitoreamos el estado del botón para que accidentalmente, con un clic del botón, no llame accidentalmente a Odroid_PlayAudio varias veces.


Fuente


Todo el código fuente está aquí .

Referencias



All Articles