Programando um jogo para um dispositivo incorporado no ESP32: drive, bateria, som

imagem


Início: sistema de montagem, entrada, display .

Parte 4: drive


O Odroid Go possui um slot para cartão microSD, que será útil para baixar recursos (sprites, arquivos de som, fontes) e, possivelmente, até para salvar o estado do jogo.

O leitor de cartão é conectado via SPI, mas o IDF facilita a interação com o cartão SD abstraindo chamadas SPI e usando funções POSIX padrão como fopen , fread e fwrite . Tudo isso é baseado na biblioteca FatFs , portanto, o cartão SD deve ser formatado no formato FAT padrão.

Ele está conectado ao mesmo barramento SPI do LCD, mas usa uma linha de seleção de chip diferente. Quando precisamos ler ou gravar no cartão SD (e isso não acontece com muita frequência), o driver SPI alterna o sinal CS do visor para o leitor de cartão SD e, em seguida, executa a operação. Isso significa que, ao enviar dados para a tela, não podemos realizar nenhuma operação com o cartão SD e vice-versa.

No momento, estamos fazendo tudo em um único segmento e estamos usando o bloqueio de transmissão via SPI para a tela, para que não haja transações simultâneas com o cartão SD e com a tela LCD. De qualquer forma, existe uma alta probabilidade de carregarmos todos os recursos no momento do lançamento.

Modificação do ESP-IDF


Se tentarmos inicializar a interface do cartão SD após a inicialização do monitor, encontraremos um problema que impossibilita o carregamento do Odroid Go. O ESP-IDF v4.0 não suporta acesso compartilhado ao barramento SPI quando usado com um cartão SD. Recentemente, os desenvolvedores adicionaram essa funcionalidade, mas ainda não está em uma versão estável, portanto faremos uma pequena modificação no IDF.

Comente a linha 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;
}

Após fazer essa alteração, ainda veremos um erro durante a inicialização, mas ele não fará mais com que o ESP32 seja reiniciado, porque o código de erro não se propaga acima.

Inicialização




Precisamos informar à IDF quais pinos do ESP32 estão conectados ao leitor MicroSD para que ele configure corretamente o driver SPI subjacente, que realmente se comunica com o leitor.

As notas gerais VSPI.XXXX são novamente usadas no diagrama , mas podemos passar por elas para os números de contato reais no ESP32.

A inicialização é semelhante à inicialização do LCD, mas em vez da estrutura geral de configuração da SPI, usamos sdspi_slot_config_t , projetado para um cartão SD conectado via barramento da SPI. Configuramos os números de contato correspondentes e as propriedades de montagem do cartão no sistema FatFS.

A documentação do IDF não recomenda o uso da função esp_vfs_fat_sdmmc_mountno código do programa concluído. Essa é uma função de invólucro que realiza muitas operações para nós, mas, até o momento, funciona normalmente e provavelmente nada mudará no futuro.

O parâmetro "/ sdcard" dessa função define o ponto de montagem virtual do cartão SD, que usaremos como prefixo ao trabalhar com arquivos. Se tivéssemos um arquivo chamado "test.txt" em nosso cartão SD, o caminho que usaríamos para vincular a ele seria "/sdcard/test.txt".

Após a inicialização da interface do cartão SD, a interação com os arquivos é trivial: podemos simplesmente usar chamadas padrão para funções POSIX , o que é muito conveniente.

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



Criei um sprite de 64x64 em Aseprite (terrível) que usa apenas duas cores: completamente preto (pixel desativado) e completamente branco (pixel ativado). O Aseprite não tem a opção de salvar a cor RGB565 ou exportar como um bitmap bruto (ou seja, sem compressão e cabeçalhos de imagem), por isso eu exportei o sprite para um formato PNG temporário.

Em seguida, usando o ImageMagick, converti os dados em um arquivo PPM, que transformou a imagem em dados não compactados brutos com um cabeçalho simples. Em seguida, abri a imagem em um editor hexadecimal, excluí o cabeçalho e converti a cor de 24 bits em 16 bits, excluindo todas as ocorrências de 0x000000 a 0x0000 e todas as ocorrências de 0xFFFFFF a 0xFFFF. A ordem dos bytes aqui não é um problema, porque 0x0000 e 0xFFFF não são alterados ao alterar a ordem dos bytes.

O arquivo bruto pode ser baixado aqui .

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

Primeiro, abrimos o arquivo- chave que contém bytes não processados ​​e o lemos no buffer. No futuro, carregaremos recursos de sprite de maneira diferente, mas para uma demonstração, isso é 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 desenhar um sprite, percorremos iterativamente seu conteúdo. Se o pixel é branco, desenhámo-lo na cor selecionada pelos botões. Se for preto, consideramos um plano de fundo e não desenhamos.


A câmera do meu telefone distorce bastante as cores. E desculpe por sacudi-la.

Para testar a gravação da imagem, moveremos a tecla para algum lugar da tela, mudaremos sua cor e, em seguida, gravaremos o buffer de quadro no cartão SD para que possa ser visualizado no computador.

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

Pressionar a tecla Menu salva o conteúdo do buffer de quadros em um arquivo chamado framebuf . Como será um buffer de quadro bruto, os pixels ainda permanecerão no formato RGB565 com a ordem dos bytes invertida. Podemos novamente usar o ImageMagick para converter esse formato em PNG para visualizá-lo em um computador.

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

Obviamente, podemos implementar leitura / gravação no formato BMP / PNG e livrar-nos de todo esse barulho com o ImageMagick, mas este é apenas um código de demonstração. Até agora, ainda não decidi qual formato de arquivo eu quero usar para armazenar sprites.


Aqui está ele! O buffer de quadro do Odroid Go é exibido no computador de mesa.

Referências



Parte 5: bateria


O Odroid Go possui uma bateria de íons de lítio, para que possamos criar um jogo que você pode jogar em qualquer lugar. Essa é uma ideia tentadora para alguém que jogou o primeiro Gameboy quando criança.

Portanto, precisamos de uma maneira de solicitar o nível da bateria do Odroid Go. A bateria está conectada ao contato no ESP32, para que possamos ler a tensão para ter uma idéia aproximada do tempo restante de operação.

Esquema



O diagrama mostra IO36 conectado à tensão VBAT após ser puxado para o terra através de um resistor. Dois resistores ( R21 e R23 ) formam um divisor de tensão semelhante ao usado na cruz do gamepad; os resistores novamente têm a mesma resistência, de modo que a tensão é metade da original.

Devido ao divisor de tensão, o IO36 lê uma tensão igual à metade do VBAT . Provavelmente, isso é feito porque os contatos do ADC no ESP32 não conseguem ler a alta tensão da bateria de íon de lítio (4,2 V com carga máxima). Seja como for, isso significa que, para obter a tensão verdadeira, é necessário dobrar a tensão lida no ADC (ADC).

Ao ler o valor de IO36, obtemos um valor digital, mas perdemos o valor analógico que ele representa. Precisamos de uma maneira de interpretar um valor digital com um ADC na forma de uma tensão analógica física.

O IDF permite calibrar o ADC, que tenta fornecer um nível de tensão com base na tensão de referência. Essa tensão de referência ( Vref ) é de 1100 mV por padrão, mas devido às características físicas, cada dispositivo é um pouco diferente. O ESP32 no Odroid Go possui uma Vref definida manualmente, “piscou” no eFuse, que podemos usar como uma Vref mais precisa.

O procedimento será o seguinte: primeiro, configuraremos a calibração do ADC e, quando quisermos ler a tensão, coletaremos um certo número de amostras (por exemplo, 20) para calcular as leituras médias; então usamos o IDF para converter essas leituras em voltagem. O cálculo da média elimina o ruído e fornece leituras mais precisas.

Infelizmente, não há conexão linear entre tensão e carga da bateria. Quando a carga diminui, a tensão cai, quando aumenta, aumenta, mas de maneira imprevisível. Tudo o que se pode dizer: se a tensão estiver abaixo de 3,6 V, a bateria estará descarregada, mas é surpreendentemente difícil converter com precisão o nível de tensão em uma porcentagem da carga da bateria.

Para o nosso projeto, isso não é particularmente importante. Podemos implementar uma aproximação aproximada para informar o jogador sobre a necessidade de carregar rapidamente o dispositivo, mas não sofreremos, tentando obter a porcentagem exata.

LED de status



No painel frontal da tela do Odroid Go, há um LED azul (LED), que podemos usar para qualquer finalidade. Você pode mostrar a eles que o dispositivo está ligado e funcionando, mas, neste caso, ao reproduzir no escuro, um LED azul brilhante brilhará em seu rosto. Portanto, vamos usá-lo para indicar uma carga baixa da bateria (embora eu prefira uma cor vermelha ou âmbar).

Para usar o LED, você precisa definir IO2 como uma saída e, em seguida, aplicar um sinal alto ou baixo para ligar e desligar o LED.

Penso que um resistor de 2 kΩ ( resistor limitador de corrente ) será suficiente para não queimarmos o LED e fornecermos muita corrente do pino GPIO.

O LED tem uma resistência bastante baixa, portanto, se for aplicado 3,3 V, queimaremos alterando a corrente. Para proteger contra isso, um resistor geralmente é conectado em série com o LED.

No entanto, os resistores limitadores de corrente para LEDs geralmente são muito inferiores a 2 kΩ, então eu não entendo por que o resistor R7 é uma resistência tão grande.

Inicialização


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

Primeiro, configuramos o LED GPIO como uma saída, para que possamos trocá-lo, se necessário. Em seguida, configuramos o pino ADC, como fizemos no caso de uma cruz - com uma largura de bit de 12 e atenuação mínima.

esp_adc_cal_characterize realiza cálculos para caracterizar o ADC, para que possamos converter posteriormente as leituras digitais em estresse físico.

Leitura da bateria


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

Coletamos vinte amostras brutas do ADC do contato do ADC e as dividimos para obter o valor médio. Como mencionado acima, isso ajuda a reduzir o ruído das leituras.

Em seguida, usamos esp_adc_cal_raw_to_voltage para converter o valor bruto para a tensão real. Devido ao divisor de tensão mencionado acima, dobramos o valor de retorno: o valor de leitura será metade da tensão real da bateria.

Em vez de encontrar maneiras complicadas de converter essa voltagem em uma porcentagem da carga da bateria, retornaremos uma voltagem simples. Deixe a função de chamada decidir por si mesma o que fazer com a tensão - transformá-la em uma porcentagem da carga ou simplesmente interpretá-la como um valor alto ou baixo.

O valor é retornado em milivolts, portanto, a função de chamada precisa executar a conversão apropriada. Isso evita o estouro da bóia.

Configuração de LED


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

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

Essas duas funções simples são suficientes para usar o LED. Podemos ligar ou desligar a luz. Deixe a função de chamada decidir quando fazê-lo.

Poderíamos criar uma tarefa que monitore periodicamente a tensão da bateria e, consequentemente, altere o status do LED, mas é melhor eu interrogar a tensão da bateria em nosso ciclo principal e decidir como definir a tensão da bateria a partir daí.

Demo


uint32_t batteryLevel = Odroid_ReadBatteryLevel();

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

Podemos simplesmente solicitar o nível da bateria no ciclo principal e, se a tensão estiver abaixo do valor limite, acenda o LED, indicando a necessidade de carregamento. Com base nos materiais estudados, posso dizer que 3600 mV (3,6 V) é um bom sinal de baixa carga de baterias de íon-lítio, mas as baterias são complexas.

Referências



Parte 6: som


O passo final para obter uma interface completa para todo o hardware Odroid Go é escrever uma camada de som. Depois de terminar, podemos começar a avançar para uma programação mais geral do jogo, menos relacionada à programação para o Odroid. Toda interação com periféricos será realizada através das funções Odroid .

Devido à minha falta de experiência com programação de som e à falta de boa documentação por parte da IDF, ao trabalhar em um projeto, a implementação do som levou mais tempo.

Por fim, não foi necessário muito código para reproduzir o som. A maior parte do tempo foi gasta em como converter os dados de áudio no ESP32 desejado e em como configurar o driver de áudio ESP32 para corresponder à configuração de hardware.

Noções básicas de som digital


O som digital consiste em duas partes: gravação e reprodução .

Registro


Para gravar som em um computador, primeiro precisamos convertê-lo do espaço de um sinal contínuo (analógico) para o espaço de um sinal discreto (digital). Essa tarefa é realizada usando um conversor analógico-digital (ADC) (sobre o qual falamos quando trabalhamos com a cruz na Parte 2).

O ADC recebe uma amostra da onda recebida e digitaliza o valor, que pode ser salvo em um arquivo.

Toque


Um arquivo de som digital pode ser retornado do espaço digital para o analógico usando um Conversor Digital-Analógico (DAC) . O DAC pode reproduzir valores apenas em um determinado intervalo. Por exemplo, um DAC de 8 bits com uma fonte de 3,3 V pode emitir tensões analógicas na faixa de 0 a 3,3 mV em etapas de 12,9 mV (3,3 V dividido por 256).

O DAC pega os valores digitais e os converte novamente em voltagem, que pode ser transmitida a um amplificador, alto-falante ou qualquer outro dispositivo capaz de receber um sinal de áudio analógico.

Taxa de amostragem


Ao gravar som analógico através do ADC, as amostras são coletadas em uma certa frequência e cada amostra é um "instantâneo" do sinal sonoro em um determinado momento. Este parâmetro é chamado de frequência de amostragem e é medido em hertz .

Quanto maior a frequência de amostragem, mais precisamente recriamos as frequências do sinal original. O teorema de Nyquist-Shannon (Kotelnikov) afirma (em termos simples) que a frequência de amostragem deve ser duas vezes a maior frequência de sinal que queremos gravar.

O ouvido humano pode ouvir aproximadamente na faixa de 20 Hz a 20 kHz , portanto a frequência de amostragem de 44,1 kHz é mais frequentemente usada para recriar música de alta qualidade, que é um pouco mais que o dobro da frequência máxima que o ouvido humano pode reconhecer. Isso garante que um conjunto completo de frequências e voz do instrumento seja recriado.

No entanto, cada amostra ocupa espaço no arquivo, portanto, não podemos selecionar a taxa máxima de amostragem. No entanto, se você não coletar amostras com rapidez suficiente, poderá perder informações importantes. A frequência de amostragem selecionada deve depender das frequências presentes no som recriado.

A reprodução deve ser executada na mesma frequência de amostragem que a fonte, caso contrário, o som e sua duração serão diferentes.

Suponha que dez segundos de som foram gravados a uma frequência de amostragem de 16 kHz. Se você o reproduzir com uma frequência de 8 kHz, seu tom será mais baixo e a duração será de vinte segundos. Se você o tocar com uma frequência de amostragem de 32 kHz, o tom audível será mais alto e o som em si durará cinco segundos.

Este vídeo mostra a diferença nas taxas de amostra com exemplos.

Profundidade de bits


A frequência de amostragem é apenas metade da equação. O som também possui uma profundidade de bits , ou seja, o número de bits por amostra.

Quando o ADC captura uma amostra de um sinal de áudio, ele deve converter esse valor analógico em digital, e o intervalo de valores capturados depende do número de bits usados. 8 bits (256 valores), 16 bits (65.526 valores), 32 bits (4.294.967.296 valores) etc.

O número de bits por amostra está relacionado à faixa dinâmica do som, ou seja, com as partes mais altas e silenciosas. A profundidade de bits mais comum para música é de 16 bits.

Durante a reprodução, é necessário fornecer a mesma profundidade de bits da fonte, caso contrário, o som e sua duração serão alterados.

Por exemplo, você tem um arquivo de áudio com quatro amostras armazenadas como 8 bits: [0x25, 0xAB, 0x34, 0x80]. Se você tentar reproduzi-los como se fossem de 16 bits, obterá apenas duas amostras: [0x25AB, 0x3480]. Isso não apenas levará a valores incorretos das amostras de som, mas também reduzirá pela metade o número de amostras e, portanto, a duração do som.

Também é importante conhecer o formato das amostras. 8 bits não assinado, 8 bits não assinado, 16 bits não assinado, 16 bits não assinado, etc. Geralmente, 8 bits não são assinados e 16 bits são assinados. Se estiverem confusos, o som ficará muito distorcido.

Este vídeo mostra a diferença de profundidade de bits com exemplos.

Arquivos Wav


Na maioria das vezes, os dados de áudio não processados ​​em um computador são armazenados no formato WAV , que possui um cabeçalho simples que descreve o formato do som (frequência de amostragem, profundidade de bits, tamanho etc.), seguido pelos próprios dados de áudio.

O som não é compactado (ao contrário de formatos como MP3), para que possamos reproduzi-lo facilmente sem a necessidade de uma biblioteca de codecs.

O principal problema com os arquivos WAV é que, devido à falta de compactação, eles podem ser bem grandes. O tamanho do arquivo está diretamente relacionado à duração, taxa de amostragem e profundidade de bits.

Tamanho = Duração (em segundos) x Taxa de amostragem (amostras / s) x Profundidade de bits (bit / amostra)

A frequência de amostragem afeta mais o tamanho do arquivo; portanto, a maneira mais fácil de economizar espaço é selecionar um valor suficientemente baixo. Criaremos um som da velha escola, de modo que uma baixa frequência de amostragem nos convém.

I2S


O ESP32 possui periféricos, devido aos quais é relativamente simples fornecer uma interface com equipamento de áudio: Inter-IC Sound (I2S) .

O protocolo I2S é bastante simples e consiste em apenas três sinais: um sinal de relógio, uma escolha de canais (esquerdo ou direito) e também a própria linha de dados.

A frequência do relógio depende da frequência de amostragem, profundidade de bits e número de canais. As batidas são substituídas para cada bit de dados; portanto, para uma reprodução adequada do som, você deve definir a frequência do relógio de acordo.

Frequência do relógio = Frequência de amostragem (amostras / s) x Profundidade de bits (bits / amostra) x Número de canais

O driver I2S do microcontrolador ESP32 possui dois modos possíveis: ele pode enviar dados para contatos conectados a um receptor I2S externo, que pode decodificar o protocolo e transferir dados para o amplificador ou pode transferir dados para um DAC ESP32 interno que emite um sinal analógico que pode ser transmitido para o amplificador. amplificador.

O Odroid Go não possui nenhum decodificador I2S na placa; portanto, teremos que usar o ESP32 DAC interno de 8 bits, ou seja, devemos usar som de 8 bits. O dispositivo possui dois DACs, um conectado ao IO25 e o outro ao IO26 .

O procedimento fica assim:

  1. Transferimos dados de áudio para o driver I2S
  2. O driver I2S envia dados de áudio para o DAC interno de 8 bits
  3. O DAC interno emite um sinal analógico
  4. O sinal analógico é transmitido ao amplificador de som



Se observarmos o circuito de áudio no Odroid Go , veremos dois pinos GPIO ( IO25 e IO26 ) conectados às entradas do amplificador de som ( PAM8304A ). O IO25
também está conectado ao sinal / SD do amplificador, ou seja, o contato que liga ou desliga o amplificador (sinal baixo significa desligamento). As saídas do amplificador estão conectadas a um alto-falante ( P1 ).

Lembre-se de que IO25 e IO26 são saídas de DACs ESP32 de 8 bits, ou seja, um DAC está conectado ao IN- e o outro ao IN + .

IN- e IN + sãoentradas diferenciais do amplificador de som. As entradas diferenciais são usadas para reduzir o ruído causado pela interferência eletromagnética . Qualquer ruído presente em um sinal também estará presente em outro. Um sinal é subtraído de outro, o que elimina o ruído.

Se você observar as especificações do amplificador de som , ele possui um circuito de aplicações típicas , que é a maneira recomendada pelo fabricante de usar o amplificador.


Ele recomenda conectar IN- ao terra, IN + ao sinal de entrada e / SD ao sinal liga / desliga. Se houver um ruído de 0,005 V, em seguida, com IN- 0V + 0.005V serão lidos , e com IN + - VIN + 0.005V . Os sinais de entrada devem ser subtraídos um do outro e obter o valor real do sinal ( VIN ) sem ruído.

No entanto, os designers do Odroid Go não usaram a configuração recomendada.

Mais uma vez, olhando o circuito Odroid Go, vemos que os projetistas conectaram a saída DAC à IN - e que a mesma saída DAC está conectada ao / SD . / SD- Este é um sinal de desligamento com um nível baixo ativo; portanto, para o amplificador funcionar, é necessário definir um sinal alto.

Isso significa que, para usar o amplificador, não devemos usar o IO25 como DAC, mas como uma saída GPIO com um sinal sempre alto. No entanto, neste caso, um sinal alto é definido como IN- , o que não é recomendado pela especificação do amplificador (ele deve ser aterrado). Em seguida, devemos usar o DAC conectado ao IO26 , pois nossa saída I2S deve ser alimentada ao IN + . Isso significa que não obteremos a redução de ruído necessária, porque IN- não está conectado ao terra. O ruído suave emana constantemente dos alto-falantes.

Precisamos garantir a configuração correta do driver I2S, porque queremos usar apenas o DAC conectado ao IO26 . Se usássemos um DAC conectado ao IO25 , ele desligaria constantemente o sinal do amplificador e o som seria terrível.

Além dessa estranheza, ao usar um DAC interno de 8 bits, o driver I2S no ESP32 exige que amostras de 16 bits sejam transmitidas a ele, mas envia apenas o byte alto ao DAC de 8 bits. Portanto, precisamos pegar nosso som de 8 bits e colá-lo em um buffer duas vezes maior, enquanto o buffer ficará meio vazio. Em seguida, passamos para o driver I2S e ele passa ao DAC o byte alto de cada amostra. Infelizmente, isso significa que temos que "pagar" por 16 bits, mas só podemos usar 8 bits.

Multitarefa


Infelizmente, o jogo não pode funcionar em um núcleo, como eu originalmente queria, porque parece haver um bug no driver I2S.

O driver I2S deve usar DMA (como o driver SPI), ou seja, podemos iniciar a transferência do I2S e continuar nosso trabalho enquanto o driver I2S está transmitindo dados de áudio.

Mas, em vez disso, a CPU é bloqueada pela duração do som, o que é completamente inadequado para o jogo. Imagine que você pressiona o botão de salto e o sprite do jogador interrompe seu movimento por 100 ms enquanto o som do salto está sendo reproduzido.

Para resolver esse problema, podemos aproveitar o fato de que existem dois núcleos a bordo do ESP32. Podemos criar uma tarefa (ou seja, um thread) no segundo núcleo, que lidará com a reprodução de som. Graças a isso, podemos transferir o ponteiro para o buffer de som da tarefa principal do jogo para a tarefa de som, e a tarefa de som inicia a transferência do I2S e fica bloqueada durante a reprodução do som. Mas a tarefa principal no primeiro núcleo (com processamento e renderização de entrada) continuará sendo executada sem bloqueio.

Inicialização


Sabendo disso, podemos iniciar corretamente o driver I2S. Para fazer isso, você precisa de apenas algumas linhas de código, mas a dificuldade é descobrir quais parâmetros você precisa definir para a reprodução adequada do som.

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

Primeiro, configuramos o IO25 (que está conectado ao sinal de desligamento do amplificador) como uma saída para que ele possa controlar o amplificador de som e aplicamos um sinal alto a ele para ligar o amplificador.

Em seguida, configuramos e instalamos o próprio driver I2S. Analisarei cada parte da configuração linha por linha, porque cada uma das linhas requer explicação:

  • modo
    • definimos o driver como mestre (controle do barramento), transmissor (porque transferimos dados para os destinatários) e o configuramos para usar o DAC de 8 bits interno (porque a placa Odroid Go não possui um DAC externo).
  • taxa de amostragem
    • 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. , .

Em seguida, criamos uma fila - é assim que o FreeRTOS envia dados entre tarefas. Colocamos dados na fila de uma tarefa e os extraímos da fila de outra tarefa. Crie uma estrutura chamada QueueData que combine o ponteiro com o buffer de som e o comprimento do buffer em uma única estrutura que possa ser enfileirada.

Em seguida, crie uma tarefa que seja executada no segundo núcleo. Nós o conectamos à função PlayTask , que executa a reprodução do som. A tarefa em si é um loop sem fim que verifica constantemente se há dados na fila. Se estiverem, ela os envia ao driver I2S para que possam ser reproduzidos. Ele bloqueará a chamada i2s_write, e isso nos convém, porque a tarefa é executada em um kernel separado do thread principal do jogo. É necessária

uma chamada para i2s_zero_dma_buffer para que, após a conclusão da reprodução, não haja sons nos alto-falantes. Não sei se isso é um bug do driver I2S ou o comportamento esperado, mas sem ele, após o término da reprodução do buffer de som, o alto-falante emite um sinal de lixo.

Tocar música


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

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

	xQueueSendToBack(gQueue, &data, portMAX_DELAY);
}

Devido ao fato de toda a configuração já estar concluída, a chamada para a função de reprodução do buffer de som é extremamente simples, porque o trabalho principal é realizado em outra tarefa. Colocamos o ponteiro no buffer e o comprimento do buffer na estrutura QueueData e, em seguida, o colocamos na fila usada pela função PlayTask .

Devido a esse padrão de operação, um buffer de som deve concluir a reprodução antes de iniciar o segundo buffer. Portanto, se um salto e um disparo ocorrerem simultaneamente, o primeiro som será reproduzido antes do segundo, e não simultaneamente.

Provavelmente, no futuro, misturarei sons de quadros diferentes no buffer de som que é transmitido ao driver I2S. Isso permitirá que você reproduza vários sons simultaneamente.

Demo


Geraremos nossos próprios efeitos sonoros usando o jsfxr , uma ferramenta projetada especificamente para gerar o tipo de som do jogo que precisamos. Podemos definir diretamente a frequência de amostragem e a profundidade de bits e, em seguida, enviar o arquivo WAV.

Eu criei um efeito de som de salto simples que se assemelha ao som do salto de Mario. Possui uma frequência de amostragem de 5012 (como configuramos durante a inicialização) e uma profundidade de bits de 8 (porque o DAC é de 8 bits).


Em vez de analisar o arquivo WAV diretamente no código, faremos algo semelhante ao que fizemos para carregar o sprite na demonstração da Parte 4: removeremos o cabeçalho WAV do arquivo usando o editor hexadecimal. Graças a isso, o arquivo lido no cartão SD será apenas dados brutos. Além disso, não leremos a duração do som, escreveremos no código. No futuro, carregaremos recursos de som de maneira diferente, mas isso é suficiente para a demonstração.

O arquivo bruto pode ser baixado aqui .

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

Carregamos os dados de 8 bits no buffer soundEffect de 8 bits e copiamos esses dados no buffer soundBuffer de 16 bits , onde os dados serão armazenados nos oito bits mais altos. Repito - isso é necessário devido aos recursos da implementação do IDF.

Depois de criar um buffer de 16 bits, podemos tocar o som de um clique de um botão. Seria lógico usar o botão de volume para isso.

int lastState = 0;

for (;;)
{
	[...]

	int thisState = input.volume;

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

	lastState = thisState;

	[...]
}

Monitoramos o estado do botão para que, acidentalmente, com um clique no botão, você não chame o Odroid_PlayAudio acidentalmente várias vezes.


Fonte


Todo o código fonte está aqui .

Referências



All Articles