Программирование игры для embedded-устройства на ESP32: накопитель, аккумулятор, звук +8


image


Начало: система сборки, ввод, дисплей.

Часть 4: накопитель


Odroid Go имеет слот карты MicroSD, который будет полезен для загрузки ресурсов (спрайтов, звуковых файлов, шрифтов), а возможно, даже для сохранения состояния игры.

Устройство чтения карт подключено по SPI, но IDF упрощает взаимодействие с SD-картой благодаря абстрагированию вызовов SPI и использованию стандартных функций POSIX наподобие fopen, fread и fwrite. В основе всего этого лежит библиотека FatFs, поэтому SD-карта должна быть отформатированной в стандартом формате FAT.

Она подключена к той же шине SPI, что и ЖК-дисплей, но использует другую линию выбора чипа. Когда нам нужно выполнить чтение или запись на SD-карту (а такое случается не очень часто), драйвер SPI будет переключать сигнал CS с дисплея на устройство чтения SD-карты, а затем выполнять операцию. Это значит, что во время отправки данных на дисплей мы не можем выполнять никаких операций с SD-картой, и наоборот.

На данный момент мы выполняем всё в одном потоке и используем блокирующую передачу по SPI на дисплей, поэтому одновременных транзакций с SD-картой и с ЖК-дисплеем быть не может. В любом случае, есть большая вероятность того, мы будем загружать все ресурсы во время запуска.

Модификация ESP-IDF


Если мы попытаемся инициализировать интерфейс SD-карты после инициализации дисплея, то столкнёмся с проблемой, приводящей к невозможности загрузки Odroid Go. ESP-IDF версии v4.0 не поддерживает общий доступ к шине SPI при её использовании с SD-картой. Недавно разработчики добавили эту функциональность, но её пока нет в стабильном релизе, поэтому мы самостоятельно внесём в IDF небольшую модификацию.

Закомментируем строку 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;
}

После внесения этого изменения мы всё равно будем видеть ошибку при инициализации, но она больше не будет вызывать перезапуска ESP32, потому что код ошибки не распространяется выше.

Инициализация




Нам нужно сообщить IDF, какие контакты ESP32 подключены к устройству чтения MicroSD, чтобы он правильно сконфигурировал лежащий в основе библиотеки драйвер SPI, который на самом деле выполняет обмен данными с устройством чтения.

На схеме снова используются общие пометки VSPI.XXXX, но мы можем пройти по ним до настоящих номеров контактов на ESP32.

Инициализация похожа на инициализацию ЖК-дисплея, но вместо общей структуры конфигурации SPI мы используем sdspi_slot_config_t, предназначенную для SD-карты, подключенной по шине SPI. Мы конфигурируем соответствующие номера контактов и свойства монтирования карты в системе FatFS.

Документация IDF не рекомендует использовать функцию esp_vfs_fat_sdmmc_mount в коде готовой программы. Это функция-обёртка, выполняющая за нас множество операций, но пока она работает вполне нормально, и, вероятно, в дальнейшем ничего не изменится.

Параметр"/sdcard" этой функции задаёт виртуальную точку монтирования SD-карты, которую мы затем будем использовать в качестве префикса при работе с файлами. Если бы у нас на SD-карте был файл с именем «test.txt», то путь, который мы бы использовали для ссылки на него, имел вид "/sdcard/test.txt".

После завершения инициализации интерфейса SD-карты взаимодействие с файлами выполняется тривиально: мы можем просто использовать стандартные вызовы функций POSIX, что очень удобно.

Файловая система по умолчанию использует формат имён файлов 8.3, то есть отдаёт восемь символов под имя файла и три символа под его расширение. Если сохранить файл иначе, то вызов fopen совершить не удастся. Можно включить поддержку длинных имён файлов через make menuconfig, но мы пока не будем ничего менять, не забывая об ограничении 8.3.

Демо



Я создал в Aseprite (ужасный) спрайт размером 64x64, в котором используются только два цвета: совершенно чёрный (пиксель отключен) и совершенно белый (пиксель включен). В Aseprite нет опции сохранения цвета RGB565 или экспорта в виде сырой битовой карты (т.е.е без сжатия и заголовков изображений), поэтому я экспортировал спрайт во временный формат PNG.

Затем я с помощью ImageMagick преобразовал данные в файл PPM, который превратил изображение в сырые несжатые данные с простым заголовком. Далее я открыл изображение в шестнадцатеричном редакторе, удалил заголовок и преобразовал 24-битный цвет в 16-битный, удалив все вхождения 0x000000 на 0x0000, а все вхождения 0xFFFFFF на 0xFFFF. Порядок следования байтов здесь не является проблемой, потому что 0x0000 и 0xFFFF не меняются при смене порядка байтов.

Сырой файл можно скачать отсюда.

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

Сначала мы открываем файл key, содержащий сырые байты, и считываем его в буфер. В будущем мы будем выполнять загрузку ресурсов спрайтов иначе, но для демо этого вполне достаточно.

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

Для отрисовки спрайта мы итеративно обходим его содержимое. Если пиксель белый, то мы рисуем его тем цветом, который выбрали кнопками. Если он чёрный, то мы считаем его фоном и не отрисовываем.


Камера моего телефона сильно искажает цвета. И простите за её тряску.

Чтобы протестировать запись изображения, мы переместим ключ в какое-нибудь место экрана, изменим его цвет, а затем запишем буфер кадров на SD-карту, чтобы можно было просмотреть его на компьютере.

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

Нажатие клавиши Menu сохраняет содержимое буфера кадров в файл под названием framebuf. Это будет сырой буфер кадров, поэтому пиксели по-прежнему останутся в формате RGB565 с перевёрнутым порядком байтов. Мы можем снова воспользоваться ImageMagick для преобразования этого формата в PNG, чтобы просмотреть его на компьютере.

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

Разумеется, мы можем реализовать считывание/запись в формат BMP/PNG и избавиться от всей этой возни с ImageMagick, но это всего лишь код демо. Пока я не решил, какой формат файлов хочу использовать для хранения спрайтов.


Вот и он! Буфер кадров Odroid Go отображается на настольном компьютере.

Ссылки



Часть 5: аккумулятор


Odroid Go имеет литий-ионный аккумулятор, поэтому мы можем создать игру, в которую можно играть на ходу. Это заманчивая идея для того, кто в детстве играл на первом Gameboy.

Следовательно, нам нужен способ запрашивать уровень заряда аккумулятора Odroid Go. Аккумулятор подключён к контакту на ESP32, поэтому мы можем считывать напряжение, чтобы иметь приблизительное представление об оставшемся времени работы.

Схема



На схеме показан IO36, соединённый с VBAT напряжения после стягивания на землю через резистор. Два резистора (R21 и R23) образуют делитель напряжения, аналогичный использованному на крестовине геймпада; резисторы снова имеют одинаковое сопротивление, чтобы напряжение было вдвое меньше исходного.

Из-за делителя напряжения IO36 будет считывать напряжение, равное половине VBAT. Наверно, это сделано потому, что контакты АЦП на ESP32 не могут считывать высокое напряжение литий-ионного аккумулятора (4,2 В при максимальном заряде). Как бы то ни было, это означает, что для получения истинного напряжения нужно удваивать напряжение, считываемое с АЦП (ADC).

При считывании значения IO36 мы получим цифровое значение, но потеряем представляемое им аналоговое значение. Нам нужен способ для интерпретации цифрового значения с АЦП виде физического аналогового напряжения.

IDF позволяет выполнять калибровку АЦП, которая пытается выдать уровень напряжения на основании эталонного напряжения. Это эталонное напряжение (Vref) по умолчанию равно 1100 мВ, но из-за физических характеристик каждое устройство немного отличается. ESP32 в Odroid Go имеет заданное вручную Vref, «прошитое» в eFuse, которое мы можем использовать как более точное Vref.

Процедура будет заключаться в следующем: сначала мы выполним конфигурирование калибровки АЦП, а когда захотим считать напряжение, то будем брать определённое количество сэмплов (например, 20), чтобы вычислить средние показания; затем мы используем IDF для преобразования этих показаний в напряжение. Вычисление среднего позволяет устранить шум и даёт более точные показания.

К сожалению, между напряжением и зарядом аккумулятора нет линейной связи. При снижении заряда напряжение падает, при повышении — поднимается, но непредсказуемым образом. Всё, что можно сказать: если напряжение ниже примерно 3,6 В, то аккумулятор разряжается, но на удивление сложно точно преобразовать уровень напряжения в процент заряда аккумулятора.

Для нашего проекта это не особо важно. Мы можем реализовать грубую аппроксимацию, чтобы дать игроку понять о необходимости скорой зарядки устройства, но не будем мучиться, пытаясь получить точный процент.

Светодиод состояния



На передней панели под экраном Odroid Go есть синий светодиод (LED), который мы можем использовать для любых целей. Можно показывать им, что устройство включено и работает, но в таком случае при игре в темноте вам в лицо будет светить ярко-синий светодиод. Поэтому мы будем использовать его для индикации низкого заряда аккумулятора (хотя я бы предпочёл для этого красный или янтарный цвет).

Чтобы использовать светодиод, нужно установить IO2 в качестве выхода, а затем подавать на него высокий или низкий сигнал для включения и отключения светодиода.

Думаю, что резистора на 2 кОм (резистора ограничения тока) будет достаточно для того, чтобы мы не сожгли светодиод и не подали слишком большой ток с контакта GPIO.

Светодиод имеет довольно низкое сопротивление, поэтому если на него подать 3,3 В, то мы сожжём его изменением тока. Для защиты от этого обычно последовательно со светодиодом подключается резистор.

Однако резисторы ограничения тока для светодиодов обычно гораздо меньше 2 кОм, поэтому я не понимаю, зачем резистору R7 такое сопротивление.

Инициализация


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

Сначала мы устанавливаем GPIO светодиода в качестве выхода, чтобы при необходимости мы могли его переключать. Затем мы конфигурируем контакт АЦП, как делали это в случае с крестовиной — с битовой шириной 12 и минимальным затуханием.

esp_adc_cal_characterize выполняет за нас вычисления для характеризации АЦП так, чтобы мы могли позже преобразовать цифровые показания в физическое напряжение.

Считывание аккумулятора


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

Мы берём двадцать сырых сэмплов АЦП с контакта АЦП, а затем делим их, чтобы получить среднее значение. Как говорилось выше, это помогает снизить шум показаний.

Затем мы используем esp_adc_cal_raw_to_voltage для преобразования сырого значения в настоящее напряжение. Из-за упомянутого выше делителя напряжения мы удваиваем возвращаемое значение: считываемое значение будет в два раза меньше действительного напряжения аккумулятора.

Вместо того, чтобы придумывать хитрые способы преобразования этого напряжения в какой-то процент заряда аккумулятора, мы будем возвращать простое напряжение. Пусть вызывающая функция сама решает, что ей делать с напряжением — превратить ли в процент заряда, или просто интерпретировать его как высокое или низкое значение.

Значение возвращается в милливольтах, поэтому вызывающей функции нужно выполнить соответствующее преобразование. Это предотвращает переполнение float.

Настройка светодиода


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

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

Этих двух простых функций достаточно для использования светодиода. Мы можем или включить, или выключить свет. Пусть вызывающая функция сама решает, когда это нужно делать.

Мы могли бы создать задачу (task), которая бы периодически отслеживала напряжение аккумулятора и соответствующим образом переключала состояние светодиода, но я лучше буду опрашивать напряжение аккумулятора в нашем основном цикле, а потом оттуда уже решать, как задать напряжение аккумулятора.

Демо


uint32_t batteryLevel = Odroid_ReadBatteryLevel();

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

Мы можем просто запрашивать уровень аккумулятора в основном цикле, и если напряжение ниже порогового значения, включать светодиод, сообщающий о необходимости зарядки. На основании изученных материалов могу сказать, что 3600 мВ (3,6 В) — хороший признак низкого заряда литий-ионных аккумуляторов, но сами аккумуляторы устроены сложны.

Ссылки



Часть 6: звук


Последним шагом к получению полного интерфейса ко всему оборудованию Odroid Go будет написание слоя звука. Закончив с этим, мы сможем начать двигаться к более общему программированию игры, меньше связанному с программированием под Odroid. Всё взаимодействие с периферией будет выполняться через функции Odroid.

Из-за отсутствия у меня опыта работы с программированием звука и нехватки хорошей документации со стороны IDF, при работе над проектом реализация звука заняла больше всего времени.

В конечном итоге, для воспроизведения звука потребовалось совсем не так много кода. Бо?льшая часть времени была потрачена на разбирательство с тем, как преобразовать звуковые данные нужным ESP32 образом и как сконфигурировать аудиодрайвер ESP32, чтобы он соответствовал конфигурации оборудования.

Основы цифрового звука


Цифровой звук состоит из двух частей: записи и воспроизведения.

Запись


Для записи звука на компьютере нам сначала нужно преобразовать его из пространства непрерывного (аналогового) сигнала в пространство дискретного (цифрового) сигнала. Эта задача выполняется при помощи аналого-цифрового преобразователя (Analog-to-Digital Converter, ADC) (о котором мы говорили, когда работали с крестовиной в Части 2).

АЦП получает сэмпл входящей волны и оцифровывает значение, которое затем можно сохранить в какой-нибудь файл.

Воспроизведение


Цифровой звуковой файл можно вернуть из цифрового пространства в аналоговое при помощи цифро-аналогового преобразователя (Digital-to-Analog Converter, DAC). ЦАП может воспроизводить значения только в определённом диапазоне. Например, 8-битный ЦАП с источником напряжением 3,3 В может выводить аналоговые напряжения в диапазоне от 0 до 3,3 мВ с шагом 12,9 мВ (3,3 В, разделённые на 256).

ЦАП берёт цифровые значения и преобразует их обратно в напряжение, которое можно передать на усилитель, динамик или любое другое устройство, способное получать аналоговый звуковой сигнал.

Частота сэмплирования


При записи аналогового звука через АЦП сэмплы берутся с определённой частотой, а каждый сэмпл является «снимком» звукового сигнала в момент времени. Этот параметр называется частотой сэмплирования и измеряется в герцах.

Чем выше частота сэмплирования, тем точнее мы воссоздаём частоты исходного сигнала. Теорема Найквиста-Шеннона (Котельникова) гласит (если говорить простыми словами), что частота сэмплирования должна быть вдвое больше наибольшей частоты сигнала, которую мы хотим записать.

Человеческое ухо способно слышать примерно в диапазоне от 20 Гц до 20 кГц, поэтому для воссоздания музыки высокого качества чаще всего используется частота дискретизации 44,1 кГц, что чуть больше удвоенной максимальной частоты, которую может распознать человеческое ухо. Это гарантирует, что будет воссоздан полный набор частот инструментов и голосом.

Однако каждый сэмпл занимает место в файле, поэтому мы не можем выбирать максимальную частоту сэмплирования. Однако если сэмплировать недостаточно быстро, можно потерять важную информацию. Выбираемая частота сэмплирования должна зависеть от присутствующих в воссоздаваемом звуке частот.

Воспроизведение должно выполняться с той же частотой сэмплирования, что и у источника, иначе звук и его длительность будут отличаться.

Допустим, десять секунд звука были записаны с частотой сэмплирования 16 кГц. Если воспроизвести его с частотой 8 кГц, то его тон будет ниже, а длительность составит двадцать секунд. Если воспроизводить его с частотой дискретизации 32 кГц, то слышимый тон будет выше, а сам звук будет длиться пять секунд.

В этом видео показана разница частот сэмплирования с примерами.

Битовая глубина


Частота сэмплирования — это только половина уравнения. У звука есть ещё и битовая глубина, то есть количество битов на сэмпл.

Когда АЦП выполняет захват сэмпла аудиосигнала, он должен превратить это аналоговое значение в цифровое, и диапазон захватываемых значений зависит от количества использованных бит. 8 бит (256 значений), 16 бит (65 526 значений), 32 бита (4 294 967 296 значений) и т.д.

Количество битов на сэмпл связано с динамическим диапазоном (Dynamic Range) звука, т.е. с самыми громкими и с самыми тихими частями. Наиболее распространённая битовая глубина для музыки — 16 бит.

При воспроизведении необходимо обеспечить такую же битовую глубину, что и у источника, иначе звук и его длительность изменятся.

Например, у вас есть аудиофайл с четырьмя сэмплами, хранящимися как 8 бит: [0x25, 0xAB, 0x34, 0x80]. Если попытаться воспроизвести их так, как будто они 16-битные, то получится только два сэмпла: [0x25AB, 0x3480]. Это не только приведёт к неправильным значениям сэмплов звука, но и вдвое уменьшит количество сэмплов, а значит, и длительность звука.

Также важно знать формат сэмплов. 8-битные без знака, 8-битные со знаком, 16-битные без знака, 16-битные со знаком, и т.д. Обычно 8-битные бывают беззнаковыми, а 16-битные — со знаком. Если их перепутать, то звук будет сильно искажён.

В этом видео показана разница битовых глубин с примерами.

Файлы WAV


Чаще всего сырые аудиоданные в компьютере хранятся в формате WAV, который имеет простой заголовок, описывающий формат звука (частоту сэмплирования, битовую глубину, размер и т.п.), за которыми следуют сами аудиоданные.

Звук никак не сжат (в отличие от форматов наподобие MP3), благодаря чему мы можем легко воспроизвести его без необходимости библиотеки кодека.

Основная проблема файлов WAV в том, что из-за отсутствия сжатия они могут быть довольно большими. Размер файла напрямую связан с длительностью, частотой сэмплирования и битовой глубиной.

Размер = Длительность (в секундах) x Частота сэмплирования (сэмплов/с) x Битовая глубина (бит/сэмпл)

Сильнее всего влияет на размер файла частота сэмплирования, поэтому самый простой способ экономии места — выбрать достаточно низкое её значение. Мы будем создавать олдскульный звук, поэтому низкая частота сэмплирования нам подходит.

I2S


ESP32 имеет периферию, благодаря которой обеспечить интерфейс с аудиоборудованием относительно просто: Inter-IC Sound (I2S).

Протокол I2S довольно прост и состоит всего из трёх сигналов: тактового сигнала, выбора каналов (левого или правого), а также линии самих данных.

Тактовая частота зависит от частоты сэмплирования, битовой глубины и количества каналов. Такты сменяются для каждого бита данных, поэтому для правильного воспроизведения звука нужно соответствующим образом задать тактовую частоту.

Тактовая частота = Частота сэмплирования (сэмплы/с) x Битовая глубина (бит/сэмпл) x Количество каналов

Драйвер I2S микроконтроллера ESP32 имеет два возможных режима: он может или выводить данные на контакты, подключённые к внешнему ресиверу I2S, который может декодировать протокол и передавать данные усилителю, или он может передавать данные во внутренний ЦАП ESP32, выводящий аналоговый сигнал, который можно передать на усилитель.

Odroid Go не имеет на плате никакого декодера I2S, поэтому нам придётся использовать внутренний 8-битный ЦАП ESP32, то есть мы должны использовать 8-битный звук. У устройства есть два ЦАП, один подключён к IO25, другой — к IO26.

Процедура выглядит так:

  1. Мы передаём аудиоданные драйверу I2S
  2. Драйвер I2S отправляет аудиоданные 8-битному внутреннему ЦАП
  3. Внутренний ЦАП выводит аналоговый сигнал
  4. Аналоговый сигнал передаётся на усилитель звука
  5. Усилитель звука отправляет выходной сигнал на динамик
  6. Динамик издаёт шум

Схема



Если мы взглянем на аудиоцепь на схеме Odroid Go, то увидим два контакта GPIO (IO25 и IO26), соединённые со входами усилителя звука (PAM8304A). IO25
также подключен к сигналу /SD усилителя, то есть контакту, включающему или отключающему усилитель (низкий сигнал означает отключение). Выходы усилителя присоединены к одному динамику (P1).

Помните, что IO25 и IO26 являются выходами 8-битных ЦАП ESP32, то есть один ЦАП подключен к IN-, а другой — к IN+.

IN- и IN+ являются дифференциальными входами усилителя звука. Дифференциальные входы используются для снижения шума, вызванного электромагнитными помехами. Любой шум, присутствующий в одном сигнале, будет также присутствовать и в другом. Один сигнал вычитается из другого, что позволяет устранить шум.

Если взглянуть на спецификацию усилителя звука, то в ней есть цепь Typical Applications Circuit, которая является рекомендуемым производителем способом использования усилителя.


Он рекомендует подключить IN- к заземлению, IN+ — ко входному сигналу, а /SD — к сигналу включения/отключения. Если присутствует шум 0,005 В, то с IN- будет считываться 0V + 0.005V, а с IN+VIN + 0.005V. Входные сигналы нужно вычесть друг из друга и получить истинное значение сигнала (VIN) без шума.

Однако проектировщики Odroid Go не воспользовались рекомендуемой конфигурацией.

Ещё раз взглянув на схему Odroid Go, мы видим, что проектировщики подключили выход ЦАП к IN- и что тот же выход ЦАП подключён к /SD. /SD — это сигнал выключения с активным низким уровнем, поэтому для работы усилителя нужно задать высокий сигнал.

Это означает, что для использования усилителя мы должны использовать IO25 не в качестве ЦАП, а как выход GPIO со всегда высоким сигналом. Однако при этом высокий сигнал задаётся на IN-, что не рекомендуется спецификацией усилителя (он должен быть заземлён). Затем мы должны использовать ЦАП, подключенный к IO26, поскольку наш выход I2S должен подаваться на IN+. Это значит, что мы не добьёмся нужного устранения шума, потому что IN- не подключен к заземлению. Из динамиков постоянно исходит мягкий шум.

Нам нужно обеспечить правильную конфигурацию драйвера I2S, потому что мы хотим использовать только ЦАП, подключенный к IO26. Если бы мы использовали ЦАП, подключенный к IO25, то он постоянно бы переключал сигнал выключения усилителя, и звук был бы ужасным.

В дополнение к этой странности при использовании 8-битного внутреннего ЦАП драйвер I2S в ESP32 требует, чтобы ему передавались 16-битные сэмплы, но отправляет 8-битному ЦАП только старший байт. Поэтому нам нужно взять наш 8-битный звук и вставить его в буфер вдвое большего размера, а буфер при этом будет наполовину пуст. Затем мы передаём его драйверу I2S и он передаёт ЦАП старший байт каждого сэмпла. К сожалению, это значит, что мы должны «платить» за 16 бит, но можем использовать только 8 бит.

Многозадачность


К сожалению, игра не может работать на одном ядре, как мне изначально хотелось, потому что в драйвере I2S, похоже, есть баг.

Драйвер I2S должен использовать DMA (как и драйвер SPI), то есть мы могли бы просто инициировать передачу I2S, а затем продолжить свою работу, пока драйвер I2S выполняет передачу аудиоданных.

Но вместо этого ЦП блокируется на время длительности звука, что для игры совершенно не подходит. Представьте, что вы нажимаете на кнопку прыжка, а затем спрайт игрока приостанавливает своё движение на 100 мс, пока воспроизводится звук прыжка.

Чтобы решить эту проблему, мы можем воспользоваться тем, что на борту ESP32 есть два ядра. Мы можем создать задачу (task) (т.е. поток) во втором ядре, которая будет заниматься воспроизведением звука. Благодаря этому мы сможем передавать указатель на буфер звука из основной задачи игры в звуковую задачу, а звуковая задача инициирует передачу I2S и заблокируется на время воспроизведения звука. Но основная задача на первом ядре (с обработкой ввода и рендерингом) продолжит выполнение без блокировки.

Иициализация


Зная это, мы можем должным образом инициировать драйвер I2S. Для этого нужно всего несколько строк кода, но сложность заключается в том, чтобы выяснить, какие параметры надо задать для правильного воспроизведения звука.

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

Сначала мы конфигурируем IO25 (который подключен к сигналу выключения усилителя) как выход, чтобы он мог управлять усилителем звука, и подаём на него высокий сигнал, чтобы включить усилитель.

Далее мы конфигурируем и устанавливаем сам драйвер I2S. Я разберу каждую часть конфигурации построчно, потому что каждая из строк требует объяснений:

  • mode
    • мы задаём драйвер в качестве мастера (управляющего шиной), передатчика (потому что мы передаём данные к получателям), и настраиваем его на использование встроенного 8-битного ЦАП (потому что на плате Odroid Go нет внешнего ЦАП).
  • sample_rate
    • Мы используем частоту сэмплирования 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. Когда я поменял их на другие значения, то заметил странные эффекты звука.

Затем мы создадим очередь — это способ, которым FreeRTOS пересылает данные между задачами. Мы помещаем данные в очередь одной задачи и извлекаем их из очереди другой задачи. Создадим struct под названием QueueData, объединяющую указатель на буфер звука и длину буфера в единую структуру, которую можно поместить в очередь.

Далее создаём задачу, выполняемую на втором ядре. Мы подключаем её к функции PlayTask, которая выполняет воспроизведение звука. Сама задача — это бесконечный цикл, который постоянно проверяет, есть ли в очереди какие-нибудь данные. Если они есть, она отправляет их драйверу I2S, чтобы их можно было воспроизвести. Она будет блокировать вызов i2s_write, и это нас устраивает, потому что задача выполняется на отдельном от основного потока игры ядре.

Вызов i2s_zero_dma_buffer требуется для того, чтобы после завершения воспроизведения не осталось звуков, воспроизводимых из динамиков. Не знаю, баг ли это драйвера I2S или ожидаемое поведение, но без него после завершения воспроизведения буфера звука динамик издаёт мусорный сигнал.

Воспроизведение звука


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

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

	xQueueSendToBack(gQueue, &data, portMAX_DELAY);
}

Благодаря тому, что вся настройка уже выполнена, сам вызов функции воспроизведения буфера звука чрезвычайно прост, ведь основная работа делается в другой задаче. Мы засовываем указатель на буфер и длину буфера в структуру QueueData, а затем помещаем её в очередь, используемую функцией PlayTask.

Из-за такой схемы работы один буфер звука должен завершить воспроизведение, прежде чем сможет начать второй буфер. Поэтому если одновременно происходят прыжок и стрельба, то первый звук будет воспроизводиться перед вторым, а не одновременно с ним.

Скорее всего, в будущем я буду микшировать разные звуки кадра в буфер звука, который передаётся драйверу I2S. Это позволит одновременно воспроизводить несколько звуков.

Demo


Мы сгенерируем собственные звуковые эффекты при помощи jsfxr — инструмента, специально предназначенного для генерации того типа игровых звуков, который нам нужен. Мы можем напрямую задать частоту сэмплирования и битовую глубину, а затем вывести файл WAV.

Я создал простой звуковой эффект прыжка, который напоминает звук прыжка Марио. Он имеет частоту сэмплирования 5012 (как мы и сконфигурировали во время инициализации) и битовую глубину 8 (потому что ЦАП 8-битный).


Вместо парсинга файла WAV непосредственно в коде, мы сделаем нечто подобное тому, что мы делали для загрузки спрайта в демо Части 4: при помощи шестнадцатеричного редактора удалим из файла заголовок WAV. Благодаря этому считываемый с SD-карты файл будет только сырыми данными. Также мы не будем считывать длительность звука, пропишем его в коде. В будущем мы будем загружать звуковые ресурсы иначе, но для демо этого достаточно.

Сырой файл можно скачать отсюда.

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

Мы загружаем 8-битные данные в 8-битный буфер soundEffect, а затем копируем эти данные в 16-битный буфер soundBuffer, где данные будут храниться в старших восьми битах. Повторюсь — это необходимо из-за особенностей реализации IDF.

Создав 16-битный буфер, мы можем воспроизводить звук нажатием кнопки. Логично будет использовать для этого кнопку громкости.

int lastState = 0;

for (;;)
{
	[...]

	int thisState = input.volume;

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

	lastState = thisState;

	[...]
}

Мы отслеживаем состояние кнопки, чтобы случайно при одном нажатии кнопки не вызвать случайно Odroid_PlayAudio несколько раз.


Исходный код


Весь исходный код находится здесь.

Ссылки





К сожалению, не доступен сервер mySQL