为ESP32上的嵌入式设备编程游戏:驱动器,电池,声音

图片


开始:组装系统,输入,显示

第4部分:驱动器


Odroid Go有一个microSD卡插槽,这对于下载资源(精灵,声音文件,字体),甚至可能保存游戏状态很有用。

读卡器通过SPI连接,但是IDF通过抽象化SPI调用并使用标准POSIX函数(例如fopenfreadfwrite)使与SD卡的交互变得容易。所有这些都是基于FatFs库的,因此SD卡必须以标准FAT格式格式化。

它与LCD连接到相同的SPI总线,但是使用不同的芯片选择线。当我们需要读写SD卡时(这种情况很少发生),SPI驱动程序会将CS信号从显示器切换到SD卡读卡器,然后执行操作。这意味着在将数据发送到显示器时,我们无法使用SD卡执行任何操作,反之亦然。

目前,我们在一个线程中完成所有操作,并且正在使用通过SPI阻止到显示器的传输,因此SD卡和LCD显示器不能同时进行事务处理。无论如何,我们很有可能在启动时加载所有资源。

修改ESP-IDF


如果在显示初始化后尝试初始化SD卡的接口,则会遇到无法加载Odroid Go的问题。与SD卡一起使用时,ESP-IDF v4.0不支持对SPI总线的共享访问。最近,开发人员添加了此功能,但尚未稳定发布,因此我们将自己对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上的实际联系电话。

初始化类似于LCD的初始化,但是我们使用sdspi_slot_config_t代替了常规的SPI配置结构,该结构是为通过SPI总线连接的SD卡设计的。我们在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颜色或导出为原始位图的选项(即没有压缩和图像标题),因此我将Sprite导出为临时PNG格式。

然后,使用ImageMagick,将数据转换为PPM文件,该文件将图像转换为带有简单标头的原始未压缩数据。接下来,我在十六进制编辑器中打开图像,删除标题并将24位颜色转换为16位,删除所有出现的0x0000000x0000,以及所有出现的0xFFFFFF0xFFFF此处的字节顺序不成问题,因为更改字节顺序时0x00000xFFFF不会更改。

原始文件可以从此处下载

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

首先,我们打开包含原始字节密钥文件,并将其读入缓冲区。将来,我们将以不同的方式加载Sprite资源,但是对于演示来说,这已经足够了。

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

按菜单键会将帧缓冲区的内容保存到一个名为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电压。两个电阻(R21R23)形成一个分压器,类似于游戏手柄的交叉部分。电阻再次具有相同的电阻,因此电压是原始电压的一半。

由于分压器,IO36将读取等于VBAT一半的电压。之所以这样做是因为ESP32上的ADC触点无法读取锂离子电池的高电压(最大充电时为4.2 V)。就是说,这意味着要获得真实电压,您需要将从ADC(ADC)读取的电压加倍。

读取IO36的值时我们获得了一个数字值,但丢失了它代表的模拟值。我们需要一种使用物理模拟电压形式的ADC来解释数字值的方法。

IDF允许您校准ADC,ADC尝试根据参考电压提供电压电平。默认情况下,该参考电压(Vref)为1100 mV,但由于物理特性,每个设备都略有不同。 Odroid Go中的ESP32有一个手动定义的Vref,在eFuse中“闪烁”,我们可以将其用作更准确的Vref。

步骤如下:首先,我们将配置ADC校准,并且当我们想要读取电压时,我们将抽取一定数量的样本(例如20个)来计算平均读数。然后我们使用IDF将这些读数转换为电压。计算平均值可消除噪音并获得更准确的读数。

不幸的是,电压和电池电量之间没有线性连接。当电荷减少时,电压下降,当电荷增加时,电压上升,但是以一种无法预测的方式上升。可以这么说:如果电压低于约3.6 V,则电池会放电,但出乎意料的是,要准确地将电压电平转换为电池电量的百分比,会非常困难。

对于我们的项目,这并不是特别重要。我们可以实施一个粗略的近似值,以使玩家知道需要为设备快速充电的方法,但是我们不会受苦于尝试获得确切的百分比。

状态指示灯



在Odroid Go屏幕下方的前面板上,有一个蓝色LED(LED),我们可以将其用于任何目的。您可以向他们显示设备已打开并且可以正常工作,但是在这种情况下,在黑暗中播放时,明亮的蓝色LED会在您的脸上发光。因此,我们将用它来指示电池电量低(尽管我更喜欢红色或琥珀色)。

要使用LED,您需要将IO2设置为输出,然后向其施加高电平或低电平信号以打开和关闭LED。

我认为一个2kΩ的电阻限流电阻)就足够了,这样我们就不会烧毁LED并从GPIO引脚提供太多电流。

LED具有相当低的电阻,因此如果对其施加3.3 V电压,则我们将通过改变电流来对其进行燃烧。为了防止这种情况,通常在LED上串联一个电阻。

但是,LED的限流电阻通常小于2kΩ,因此我不理解为什么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 LED设置为输出,以便在必要时可以对其进行切换。然后,我们配置ADC引脚,就像在交叉情况下一样-位宽为12,衰减最小。

esp_adc_cal_characterize为我们执行计算以表征ADC的特性,以便以后可以将数字读数转换为物理应力。

电池读取


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

我们从ADC的触点中获取20个原始ADC样本,然后将它们除以得到平均值。如上所述,这有助于减少读数的噪音。

然后,我们使用esp_adc_cal_raw_to_voltage将原始值转换为实际电压。由于上述分压器,我们将返回值加倍:读取值将为实际电池电压的一半。

我们不会想办法将电压转换为电池电量的百分比,而是返回一个简单的电压。让调用函数自己决定如何处理电压-是将其转换为电荷的百分比,还是简单地将其解释为高值或低值。

该值以毫伏为单位返回,因此调用函数需要执行适当的转换。这样可以防止浮子溢出。

LED设定


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

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

这两个简单的功能足以使用LED。我们可以打开或关闭灯。让调用函数决定何时执行。

我们可以创建一个任务,该任务将定期监视电池电压并相应地切换LED的状态,但是我最好在主循环中询问电池电压,然后决定如何从那里设置电池电压。

演示版


uint32_t batteryLevel = Odroid_ReadBatteryLevel();

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

我们可以简单地在主循环中请求电池电量,如果电压低于阈值,则打开LED,表明需要充电。根据研究的材料,我可以说3600 mV(3.6 V)是锂离子电池电量低的一个好兆头,但是电池本身很复杂。

参考文献



第六部分:声音


获得与所有Odroid Go硬件的完整接口的最后一步是编写声音层。完成此操作后,我们可以开始着手于游戏的更通用编程,而与Odroid编程无关。与外围设备的所有交互将通过Odroid功能执行

由于我缺乏声音编程方面的经验,并且IDF缺乏好的文档,因此在进行项目工作时,声音的实现花费了最多的时间。

最终,播放声音不需要太多代码。大部分时间都花在了如何将音频数据转换为所需的ESP32以及如何配置ESP32音频驱动程序以匹配硬件配置上。

数字声音基础


数字声音包括两个部分:录制播放

记录


为了在计算机上记录声音,我们首先需要将其从连续(模拟)信号的空间转换为离散(数字)信号的空间。使用模数转换器(ADC)(在第2部分中讨论叉号时已经讨论过)来完成此任务

ADC接收输入波样本并将其数字化,然后将其保存到文件中。


可以使用数模转换器(DAC)将数字声音文件从数字空间返回到模拟空间DAC只能在一定范围内重现值。例如,具有3.3 V电源的8位DAC可以以12.9 mV的步长(3.3 V除以256)输出0至3.3 mV范围内的模拟电压。

DAC提取数字值并将其转换回电压,然后可以将其传输到放大器,扬声器或任何其他能够接收模拟音频信号的设备。

采样率


通过ADC记录模拟声音时,将以特定频率进行采样,并且每个采样都是声音信号在某个时间点的“快照”。此参数称为采样频率,以赫兹为单位

采样频率越高,我们越能准确地再现原始信号的频率。奈奎斯特-香农(Kotelnikov)定理指出(简单而言),采样频率应该是我们要记录的最高信号频率的两倍。

人耳可以听到的声音范围大约为20 Hz至20 kHz,因此44.1 kHz的采样频率最常用于再现高质量音乐,这是人耳可以识别的最大频率的两倍多。这样可以确保重新创建完整的乐器频率和声音集。

但是,每个样本都占用文件中的空间,因此我们无法选择最大采样率。但是,如果采样速度不够快,则可能会丢失重要信息。选择的采样频率应取决于再现的声音中存在的频率。

播放应与音源以相同的采样频率进行,否则声音及其持续时间将有所不同。

假设以16 kHz的采样频率记录了十秒钟的声音。如果以8 kHz的频率播放,则其音调会降低,持续时间将为20秒。如果以32 kHz的采样频率播放,则可听到的声音会更高,声音本身会持续五秒钟。

该视频通过示例显示了采样率的差异。

位深


采样频率仅为等式的一半。声音还具有位深度,即每个样本的位数。

当ADC捕获音频信号的样本时,它必须将此模拟值转换为数字值,并且捕获值的范围取决于所使用的位数。 8位(256个值),16位(65,526个值),32位(4,294,967,296个值)等

每个样本的位数与声音动态范围有关,即声音最大,最安静的部分。音乐最常见的位深度是16位。

在播放过程中,必须提供与信号源相同的位深度,否则声音及其持续时间会改变。

例如,您有一个音频文件,其中四个样本存储为8位:[0x25、0xAB,0x34、0x80]。如果尝试将它们当作16位来播放,则只会得到两个样本:[0x25AB,0x3480]。这不仅会导致声音样本的值不正确,还会使样本数量减半,从而使声音的持续时间减半。

了解样本的格式也很重要。8位无符号,8位无符号,16位无符号,16位无符号等 通常8位是无符号的,而16位是有符号的。如果感到困惑,声音会大大失真。

该视频通过示例显示了位深度差异。

WAV文件


通常,计算机上的原始音频数据以WAV格式存储,该格式具有描述声音格式(采样频率,位深,大小等)的简单标头,然后是音频数据本身。

声音根本没有被压缩(与MP3等格式不同),因此我们可以轻松地播放它而无需编解码器库。

WAV文件的主要问题是由于缺少压缩,因此它们可能很大。文件大小与持续时间,采样率和位深度直接相关。

大小=持续时间(以秒为单位)x采样率(样本/ s)x位深度(位/样本)

采样频率对文件大小的影响最大,因此节省空间的最简单方法是选择一个足够低的值。我们将创建一种老式的声音,因此低采样频率适合我们。

I2S


ESP32具有外围设备,因此提供与音频设备的接口相对简单:IC间声音(I2S)

I2S协议非常简单,仅包含三个信号:时钟信号,通道选择(左或右)以及数据线本身。

时钟频率取决于采样频率,位深和通道数。节拍被替换为数据的每一位,因此,为了正确再现声音,必须相应地设置时钟频率。

时钟频率=采样频率(样本/秒)x位深度(比特/样本)x通道数

ESP32微控制器的I2S驱动器有两种可能的模式:它可以将数据输出到与外部I2S接收器相连的触点,后者可以对协议进行解码并将数据传输到放大器,或者可以将数据传输到内部ESP32 DAC,从而输出可以传输到的模拟信号。放大器。

Odroid Go板上没有任何I2S解码器,因此我们必须使用内部8位ESP32 DAC,也就是说,我们必须使用8位声音。该器件具有两个DAC,一个连接到IO25另一个连接IO26

该过程如下所示:

  1. 我们将音频数据传输到I2S驱动程序
  2. I2S驱动程序将音频数据发送到8位内部DAC
  3. 内部DAC输出模拟信号
  4. 模拟信号被传输到声音放大器



如果我们看一下Odroid Go电路中的音频电路,将会看到两个GPIO引脚(IO25IO26)连接到声音放大器(PAM8304A的输入IO25
也连接到放大器的信号/ SD,即打开或关闭放大器的触点(低信号表示关闭)。放大器的输出连接到一个扬声器(P1)。

请记住,IO25IO26是8位ESP32 DAC的输出,即,一个DAC连接到IN-,另一个DAC连接IN +

IN-IN +声音放大器的差分输入。差分输入用于减少电磁干扰引起的噪声。一个信号中存在的任何噪声也将在另一信号中存在。一个信号从另一个信号中减去,从而消除了噪声。

如果您查看声音放大器规格,则它具有典型应用电路,这是制造商推荐的使用放大器的方法。


他建议将IN-接地,将IN +连接到输入信号,将/ SD连接到开/关信号。如果有0.005 V的噪声,然后用IN- 0V + 0.005V将被读取,并用IN + - VIN + 0.005V。输入信号必须彼此相减并获得真实信号值(VIN)且无噪声。

但是,Odroid Go的设计人员没有使用推荐的配置。

再次查看Odroid Go电路,我们看到设计人员将DAC输出连接到IN-,并且相同的DAC输出连接到/ SD/ SD-这是一个低电平有效的关断信号,因此要使放大器工作,您需要设置一个高信号。

这意味着要使用放大器,我们不能将IO25用作DAC,而必须用作始终具有高信号的GPIO输出。但是,在这种情况下,高信号设置为IN-,这在放大器的规格中不建议这样做(必须接地)。然后,我们必须使用连接到IO26的DAC ,因为我们的I2S输出必须馈入IN +。这意味着我们将无法实现必要的降噪效果,因为IN-没有接地。扬声器不断发出柔和的声音。

我们需要确保I2S驱动程序的正确配置,因为我们只想使用连接到IO26的DAC 如果我们使用连接到IO25的DAC ,它将不断关闭放大器的信号,并且声音会很糟糕。

除了这种怪异之外,当使用8位内部DAC时,ESP32中的I2S驱动器需要向其发送16位样本,但仅将高字节发送到8位DAC。因此,我们需要获取8位声音并将其粘贴到两倍大的缓冲区中,而缓冲区将为一半。然后,将其传递给I2S驱动器,并将每个采样的高字节传递给DAC。不幸的是,这意味着我们必须“支付” 16位,但是我们只能使用8位。

多任务


不幸的是,游戏无法像我最初想要的那样在一个内核上运行,因为I2S驱动程序似乎存在错误。

I2S驱动程序必须使用DMA(类似于SPI驱动程序),也就是说,我们可以只启动I2S的传输,然后在I2S驱动程序正在传输音频数据时继续我们的工作。

但是,相反,CPU在声音持续时间内一直处于阻塞状态,这完全不适合游戏。想象一下,您按下跳转按钮,然后在播放跳转声音时,播放器的精灵将其移动暂停100毫秒。

为了解决这个问题,我们可以利用ESP32上有两个内核这一事实。我们可以在第二个核心中创建一个任务(即线程),该任务将处理声音再现。因此,我们可以将指针从游戏的主要任务转移到声音缓冲区,再转移到声音任务,声音任务会启动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驱动程序本身。我将逐行解析配置的每个部分,因为每一行都需要解释:

  • 模式
    • 我们将驱动程序设置为主驱动器(控制总线),将其设置为发送器(因为我们将数据传输到接收者),并将其配置为使用内置的8位DAC(因为Odroid Go板上没有外部DAC)。
  • 采样率
    • 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在任务之间发送数据的方式。我们将数据放在一个任务的队列中,然后从另一任务的队列中提取数据。创建一个名为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驱动程序。这将允许您同时播放多个声音。

演示版


我们将使用jsfxr生成自己的声音效果,该工具专门设计用于生成所需的游戏声音类型。我们可以直接设置采样频率和位深,然后输出WAV文件。

我创建了一个简单的跳跃声音效果,类似于马里奥的跳跃声音。它的采样频率为5012(在初始化时配置),位深度为8(因为DAC为8位)。


与其直接在代码中直接解析WAV文件,我们将执行类似于在第4部分的演示中加载Sprite的操作:我们将使用十六进制编辑器从文件中删除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缓冲区中,数据将存储在高8位中。我再说一遍-由于IDF实现的功能,这是必要的。

创建了16位缓冲区后,我们可以播放单击按钮的声音。为此使用音量按钮将是合乎逻辑的。

int lastState = 0;

for (;;)
{
	[...]

	int thisState = input.volume;

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

	lastState = thisState;

	[...]
}

我们监视按钮的状态,以便一键按下Odroid_PlayAudio不会被意外调用多次。


资源


所有源代码都在这里

参考文献



All Articles