一种从 Arduino MKR WiFi 1010 无线传输未压缩 44.1kHz 32bit I2S 音频的方法






4.78/5 (5投票s)
本文描述了修改 Arduino NINA 固件以无线传输 I2S 麦克风数据。
引言
Arduino MKR WiFi 1010 是一款强大的物联网设备,集成了 ARM Cortex-M0 (SAMD21) 和 ESP32 (NINA-W102) 处理器,体积小巧。它提供了 WiFi 和 蓝牙 连接库,可以轻松创建使用 IP 协议通信并作为蓝牙客户端的物联网节点。另一个值得关注的特点是双通道 I2S 端口 以及用于从该端口读取音频的相应库。I2S 端口可用于从支持的 I2S 麦克风设备读取高达 192kHz 采样率的 32/24 位音频。
在使用 `WiFiNINA` 库尝试无线传输这种高数据速率的 I2S 音频时,会遇到的一个障碍是 SAMD21 和 NINA-W102 之间基于 SPI 的命令/响应协议引入的延迟。这限制了数据发送到任何远程端口的速率。压缩音频数据是一种选择,但这会损失 I2S 所提供的音频保真度。
本文描述了对基于 ESP-IDF 的 Arduino NINA-W102 固件的修改,以实现通过 UDP 数据包将未压缩的 44.1kHz 32 位音频发送到任何监听的 IP 地址和端口。文章还提供了编译和刷写修改后固件到 NINA-W102 的说明。文中也描述了相应的 MKR WiFi 1010 代码。对于不想自行编译的读者,提供了预编译的修改后固件的链接。
克隆和构建原始固件
原始 NINA-W102 固件的 git 仓库位于 此处,并附有构建说明。一旦 `make` 命令成功,构建好的固件将位于“*\\build*”文件夹中。Windows 用户在 Windows Subsystem for Linux 中运行的 Ubuntu 上构建项目会更容易。请不要忘记使用下面的示例命令将工具链添加到 `PATH`。
export PATH=$PATH:/mnt/d/LinuxFiles/esp/xtensa-esp32-elf/bin
export IDF_PATH=/mnt/d/LinuxFiles/GIT/esp-idf
make
如果 `make` 命令输出成功,最后一行将显示一个刷写命令的示例,看起来像这样:
python.exe D:\LinuxFiles\GIT\esp-idf\components\esptool_py\esptool\esptool.py
--chip esp32
--port COM5 --baud 115200 --before default_reset --after hard_reset write_flash -z
--flash_mode dio --flash_freq 40m --flash_size detect 0x1000 bootloader/bootloader.bin
0xf000 phy_init_data.bin 0x30000 nina-fw.bin 0x8000 partitions.bin
为了使此刷写命令生效,有一个 SerialNINAPassthrough 草图,顾名思义,当将其上传到 MKR WiFi 1010 时,它会将串行数据直接从 MKR WiFi 1010 串行 COM 端口传递到 NINA-W102,以便使用 esptool.py 进行刷写。
在 `esptool.py` 刷写命令成功后,通过快速按两次重置按钮可以重新激活 MKR WiFi 1010 的常规引导加载程序模式。引导加载程序模式将允许上传针对 MKR WiFi 1010 的 Arduino 草图,因为设备上的 SerialNINAPassthrough 草图会阻止这样做。建议使用这个新刷写的固件再次测试 `WiFiNINA` 库。一旦这个过程就绪,就可以开始固件修改了。
原始固件描述
NINA-W102 固件的入口点位于 main/sketch.ino.cpp,它像任何 Arduino 草图一样具有 `setup()` 和 `loop()` 函数。在固件的 `loop()` 函数中,实现了 SAMD21 和 NINA-W102 之间通信所使用的协议。`SPIS.transfer()` 函数处理来自 SAMD21 的 SPI 数据,将其作为 `uint8_t` 数组在 `commandBuffer` 中提供,而 `CommandHandler.handle()` 处理此 `commandBuffer`,然后使用原生的 esp 库执行请求的命令,之后创建一个 `uint8_t` 数组响应,该响应将通过相同的 `SPIS.transfer()` 函数发送回 SAMD21。
void loop() {
// wait for a command
memset(commandBuffer, 0x00, SPI_BUFFER_LEN);
int commandLength = SPIS.transfer(NULL, commandBuffer, SPI_BUFFER_LEN);
if (commandLength == 0) {
return;
}
if (debug) {
dumpBuffer("COMMAND", commandBuffer, commandLength);
}
// process
memset(responseBuffer, 0x00, SPI_BUFFER_LEN);
int responseLength = CommandHandler.handle(commandBuffer, responseBuffer);
SPIS.transfer(responseBuffer, NULL, responseLength);
if (debug) {
dumpBuffer("RESPONSE", responseBuffer, responseLength);
}
}
通过查看 main/CommandHandler.cpp 和 `CommandHandlerClass::handle()` 函数,读者可以理解 `CommandHandler` 对象如何将 `commandBuffer` 转换为使用 esp 库执行的某个操作,以及 `responseBuffer` 如何被更新。
使用原始固件发送 UDP 数据包
要将 UDP 数据包发送到 IP 地址和端口,MKR WiFi 1010 应连接到 WiFi 网络。这可以按照 `setup()` 函数中下面示例代码的规定进行。
#include <WiFiNINA.h>
...
void setup() {
...
while (status != WL_CONNECTED) {
Serial.print("Attempting to connect to SSID: ");
Serial.println(ssid);
status = WiFi.begin(“MySSID”, “SomePassword”);
delay(5000);
}
...
}
...
在成功连接到网络后,并包含下面示例代码后,它会将字节数组数据包发送到远程监听器的端口。
#include <WiFiUdp.h>
...
WiFiUDP Udp;
...
void loop() {
...
uint8_t packetData[512];
uint32_t packetLength = 512;
Udp.beginPacket("192.168.1.102", 8002);
Udp.write(packetData, packetLength);
Udp.endPacket();
...
}
使用 WiFiUdp 类发送 UDP 数据包时遇到的问题
上面描述的,使用 `WiFiUdp` 类发送 UDP 数据包的问题可能还没有向读者显现。`beginPacket()`, `write()`, 和 `endPacket()` 都被转换为 SPI 命令和响应,因此在 SAMD21 上运行的这三个函数必须通过 SPI 向 NINA-W102 发送相应的命令,然后通过 SPI 等待 NINA-W102 的响应才能继续进行。这会引入延迟,并且 UDP 数据包发送速率存在上限。
本文的目标是以 44.1kHz 采样率和每样本 32 位传输 I2S 数据到 UDP 监听器,因此涉及的瓶颈将导致尝试以如此高数据速率发送 UDP 数据包的程序崩溃。这可以通过这里描述的微小固件修改来解决。
在 MKR WiFi 1010 中读取 I2S 音频数据
MKR WiFi 1010 的 I2S 库 可以包含为 `#include <I2S.h>`,并可用于将 I2S 设备初始化为支持的采样率和每样本位数,以及读取流式数据。下图显示了如何将一个示例 I2S 设备连接到 MKR WiFi 1010。
要将 I2S 设备初始化为 44.1kHz 采样率和 32 位每样本,可以将以下行添加到 `setup()` 函数中。
#include <I2S.h>
...
void setup() {
...
if (!I2S.begin(I2S_PHILIPS_MODE, 44100, 32)) {
Serial.println("Failed to initialize I2S!");
while (1);
}
...
}
...
#include <I2S.h>
...
void loop() {
...
int size = I2S.available();
byte I2SData[size];
I2S.read(I2SData, size);
if(size == 0){
return;
}
//further processing
...
}
...
在每次循环中可用的 `I2SData` 中的数据,现在可以被压缩并发送到任何监听的 UDP 端口,或者在描述的固件修改后无损地发送。
固件修改描述
保留所有原始固件功能,例如连接到 WiFi 网络的函数,是可取的,因为易于使用的 _WiFiNINA.h_ 库仍然可以用于这些工作流程。在 MKR WiFi 1010 连接到 WiFi 网络后,可以有条件地绕过命令/响应 SPI 协议以更快地发送 UDP 数据包。
引入了一种新的 SPI 命令/响应协议命令,用于设置 UDP 数据包(包含音频数据)将发送到的目标 IP 地址和端口,并且该目标 IP 地址和端口将在 NINA-W102 上运行的自定义固件中记住。这将是最后的命令,之后 SPI 传输可以单向工作,将音频数据直接发送到 NINA-W102 并作为 UDP 数据包转发。由于不会等待 SPI 总线的响应,延迟被消除,从而实现了高数据速率的数据包传输到网络上的 UDP 监听端口。
用于设置 UDP 监听器 IP 地址和端口的自定义命令
选择了一个 8 字节的命令来容纳 IP 地址(4 字节)、端口(2 字节)和唯一的起始和结束标识符(2 字节)。
当下面的代码(要添加到 NINA-W102 固件的 `loop()` 中)检测到具有唯一起始和结束标识符的 8 字节命令时,可以提取并记住 IP 地址和端口。
...
char sendToIpAddress[50];
uint16_t sendToPort = 0;
...
void loop() {
// wait for a command
memset(commandBuffer, 0x00, SPI_BUFFER_LEN);
int commandLength = SPIS.transfer(NULL, commandBuffer, SPI_BUFFER_LEN);
if (commandLength == 0) {
return;
}
if (commandLength == 8 && commandBuffer[0] == 0xD1 && commandBuffer[7] == 0xD2) {
sprintf(&sendToIpAddress[0], "%d.%d.%d.%d",
commandBuffer[1], commandBuffer[2] , commandBuffer[3], commandBuffer[4]);
sendToPort = 0;
sendToPort = sendToPort^commandBuffer[5];
sendToPort = sendToPort^(commandBuffer[6]<<8);
memset(responseBuffer, 0x00, SPI_BUFFER_LEN);
responseBuffer[0] = 'A';
responseBuffer[1] = 'B';
SPIS.transfer(responseBuffer, NULL, 2);
return;
}
...
}
...
发送此新自定义命令的方法将在后面的章节中给出。一旦固件知道要将 UDP 数据包发送到哪个端点,就会设置标志,并可以被动地通过 SPI 接收数据并进行转发。
直接发送 UDP 数据包
NINA-W102 的固件在包含 `#include<WiFiUdp.h>` 后有一个 `WiFiUDP` 类,可用于将 UDP 数据包发送到任何 IP 地址和端口。这可以通过以下代码行完成:
...
#include "CommandHandler.h"
#include <WiFiUdp.h>
...
WiFiUDP udp;
...
void loop() {
udp.beginPacket(sendToIpAddress, sendToPort);
udp.write(commandBuffer, commandLength);
udp.endPacket();
}
...
这三个函数,由于它们直接在 NINA-W102 上运行,不会有任何不必要的开销。
发送接收到的音频数据
通过 SPI 从 SAMD21 发送的音频数据长度为 256 字节或 512 字节,将在 `commandBuffer` 中提供,就像其他命令一样,但它包含音频数据而不是代表命令。一旦收到这些数据,就可以在修改后的固件的 `loop()` 中直接转发。这可以通过以下代码完成:
...
#include "CommandHandler.h"
#include <WiFiUdp.h>
...
WiFiUDP udp;
...
char sendToIpAddress[50];
uint16_t sendToPort = 0;
...
void loop() {
memset(commandBuffer, 0x00, SPI_BUFFER_LEN);
int commandLength = SPIS.transfer(NULL, commandBuffer, SPI_BUFFER_LEN);
if (commandLength == 0) {
return;
}
...
if (WiFi.status() == WL_CONNECTED && sendToPort != 0 && commandLength > 128) {
udp.beginPacket(sendToIpAddress, sendToPort);
udp.write(commandBuffer, commandLength);
udp.endPacket();
return;
}
...
}
...
完整的固件修改代码
上述修改的完整快照如下所示:
...
#include "CommandHandler.h"
#include <WiFiUdp.h>
...
WiFiUDP udp;
...
char sendToIpAddress[50];
uint16_t sendToPort = 0;
...
void loop() {
memset(commandBuffer, 0x00, SPI_BUFFER_LEN);
int commandLength = SPIS.transfer(NULL, commandBuffer, SPI_BUFFER_LEN);
if (commandLength == 0) {
return;
}
// custom command
if (commandLength == 8 && commandBuffer[0] == 0xD1 && commandBuffer[7] == 0xD2) {
sprintf(&sendToIpAddress[0], "%d.%d.%d.%d",
commandBuffer[1], commandBuffer[2] , commandBuffer[3], commandBuffer[4]);
sendToPort = 0;
sendToPort = sendToPort^commandBuffer[5];
sendToPort = sendToPort^(commandBuffer[6]<<8);
memset(responseBuffer, 0x00, SPI_BUFFER_LEN);
responseBuffer[0] = 'A';
responseBuffer[1] = 'B';
SPIS.transfer(responseBuffer, NULL, 2);
return;
}
//
...
if (WiFi.status() == WL_CONNECTED && sendToPort != 0 && commandLength > 128) {
udp.beginPacket(sendToIpAddress, sendToPort);
udp.write(commandBuffer, commandLength);
udp.endPacket();
return;
}
...
}
...
在对固件进行这些更改、编译并上传到 NINA-W102 后,可以编写 MKR WiFi 1010 的代码以发送 I2S 数据,并将其作为 UDP 数据包流式传输。
MKR WiFi 1010 发送音频数据包的代码
要通过 SPI 向 NINA-W102 发送或接收任何任意数据数组,需要直接访问 SPI 总线。可以通过包含 `#include<utility/spi_drv.h>` 来实现直接访问,这使得 `SpiDrv` 类可用。
直接使用 SpiDrv 发送和接收数据
下面的代码提供了一个通过 SPI 向 NINA-W102 发送字节的示例。
...
#include <utility/spi_drv.h>
#include <WiFiNINA.h>
...
void loop() {
...
byte spiData[4];
WAIT_FOR_SLAVE_SELECT();
SpiDrv::spiTransfer(spiData[0]);
SpiDrv::spiTransfer(spiData[1]);
SpiDrv::spiTransfer(spiData[2]);
SpiDrv::spiTransfer(spiData[3]);
SpiDrv::spiSlaveDeselect();
...
}
...
下面的代码提供了一个通过 SPI 发送字节并从 NINA-W102 读取一些响应字节的示例。
...
#include <utility/spi_drv.h>
#include <WiFiNINA.h>
...
void loop() {
...
byte spiData[1];
// Send
WAIT_FOR_SLAVE_SELECT();
SpiDrv::spiTransfer(spiData[0]);
SpiDrv::spiSlaveDeselect();
// Wait for 2 byte reply
SpiDrv::waitForSlaveReady();
SpiDrv::spiSlaveSelect();
char responseByte1 = SpiDrv::spiTransfer(DUMMY_DATA);
char responseByte2 = SpiDrv::spiTransfer(DUMMY_DATA);
SpiDrv::spiSlaveDeselect();
...
}
...
发送用于设置目标 IP 地址和端口的自定义命令
如本文前一节所述,修改后的固件已准备好检测包含 IP 地址和端口的 8 字节命令。下面的函数发送一个 8 字节的命令,并等待一个特定值的 2 字节响应。
void setIPAddressPortCommand() {
WAIT_FOR_SLAVE_SELECT();
SpiDrv::spiTransfer(0xD1);
sendIPAddress(LISTENER_IP);
sendPort(LISTENER_PORT);
SpiDrv::spiTransfer(0xD2);
SpiDrv::spiSlaveDeselect();
SpiDrv::waitForSlaveReady();
SpiDrv::spiSlaveSelect();
// Wait for reply
char responseByte1 = SpiDrv::spiTransfer(DUMMY_DATA);
char responseByte2 = SpiDrv::spiTransfer(DUMMY_DATA);
SpiDrv::spiSlaveDeselect();
if (responseByte1 == 'A' && responseByte2 == 'B') {
Serial.print("Have set listener ipaddress and port: ");
Serial.print(LISTENER_IP);
Serial.print(":");
Serial.print(LISTENER_PORT);
Serial.println();
return;
}
while (true) {
Serial.print("Listener ipaddress and port not set, aborted!");
delay(2000);
}
}
收到的响应将有助于确认刷写的自定义固件是否按预期工作,如果未按预期工作,可能需要重新编译和重新刷写固件。一旦此命令成功执行,固件将准备好接收和转发 SAMD21 发送的任何任意 SPI 数据到此自定义命令设置的任何 IP 地址和端口。
通过 SPI 发送任意 Int32 数组
下面的函数将通过 SPI 发送任何任意 `int32_t` 缓冲区,并且不等待任何响应。
void sendIntValue(int value) {
byte a,b,c,d;
a=(value&0xFF);
b=((value>>8)&0xFF);
c=((value>>16)&0xFF);
d=((value>>24)&0xFF);
SpiDrv::spiTransfer(a);
SpiDrv::spiTransfer(b);
SpiDrv::spiTransfer(c);
SpiDrv::spiTransfer(d);
}
void sendBuffer(int32_t* buffer, int length) {
WAIT_FOR_SLAVE_SELECT();
for(int i=0; i<length; i++) {
int value = buffer[i];
if(value != 0) {
value = value ^ CYPHER_KEY;
sendIntValue(value);
}
}
SpiDrv::spiSlaveDeselect();
}
此时,最好准备一个 UDP 监听客户端,连接到 IP 地址和端口,以验证正在发送和接收的数据。
通过 SPI 发送 I2S 音频数据字节数组
作为达到这一点之前的最后一步,下面的代码解释了如何通过 SPI 将 I2S 数据发送到 NINA-W102,以无延迟地转发到 UDP 端口。
...
#include<utility/spi_drv.h>
#include<WiFiNINA.h>
#include<I2S.h>
...
void setup() {
...
if (!I2S.begin(I2S_PHILIPS_MODE, 44100, 32)) {
Serial.println("Failed to initialize I2S!");
while (1);
}
...
}
...
byte i2sDataBuffer[1024];
void loop() {
int size = I2S.available();
I2S.read(i2sDataBuffer, size);
if (size < 4) {
return;
}
sendBuffer((int32_t*)i2sDataBuffer, size/4);
}
...
MKR WiFi 1010 代码完整示例
下面是所有内容整合在一起的完整快照。
...
#include<utility/spi_drv.h>
#include<WiFiNINA.h>
#include<I2S.h>
...
void sendIPAddress(String ipAddress) {
IPAddress ip;
ip.fromString(ipAddress);
SpiDrv::spiTransfer(ip[0]);
SpiDrv::spiTransfer(ip[1]);
SpiDrv::spiTransfer(ip[2]);
SpiDrv::spiTransfer(ip[3]);
}
void sendPort(uint16_t port) {
byte a=(port&0xFF);
byte b=((port>>8)&0xFF);
SpiDrv::spiTransfer(a);
SpiDrv::spiTransfer(b);
}
void setIPAddressPortCommand() {
WAIT_FOR_SLAVE_SELECT();
SpiDrv::spiTransfer(0xD1);
sendIPAddress(LISTENER_IP);
sendPort(LISTENER_PORT);
SpiDrv::spiTransfer(0xD2);
SpiDrv::spiSlaveDeselect();
SpiDrv::waitForSlaveReady();
SpiDrv::spiSlaveSelect();
// Wait for reply
char responseByte1 = SpiDrv::spiTransfer(DUMMY_DATA);
char responseByte2 = SpiDrv::spiTransfer(DUMMY_DATA);
SpiDrv::spiSlaveDeselect();
if (responseByte1 == 'A' && responseByte2 == 'B') {
Serial.print("Have set listener ipaddress and port: ");
Serial.print(LISTENER_IP);
Serial.print(":");
Serial.print(LISTENER_PORT);
Serial.println();
return;
}
while (true) {
Serial.print("Listener ipaddress and port not set, aborted!");
delay(2000);
}
}
...
void setup() {
// Connect wifi
...
setIPAddressPortCommand();
if (!I2S.begin(I2S_PHILIPS_MODE, 44100, 32)) {
Serial.println("Failed to initialize I2S!");
while (1);
}
...
}
...
void sendIntValue(int value) {
byte a,b,c,d;
a=(value&0xFF);
b=((value>>8)&0xFF);
c=((value>>16)&0xFF);
d=((value>>24)&0xFF);
SpiDrv::spiTransfer(a);
SpiDrv::spiTransfer(b);
SpiDrv::spiTransfer(c);
SpiDrv::spiTransfer(d);
}
void sendBuffer(int32_t* buffer, int length) {
WAIT_FOR_SLAVE_SELECT();
for(int i=0; i<length; i++) {
int value = buffer[i];
if(value != 0) {
value = value ^ CYPHER_KEY;
sendIntValue(value);
}
}
SpiDrv::spiSlaveDeselect();
}
...
byte i2sDataBuffer[1024];
void loop() {
int size = I2S.available();
I2S.read(i2sDataBuffer, size);
if (size < 4) {
return;
}
sendBuffer((int32_t*)i2sDataBuffer, size/4);
}
...
接收、解码和播放音频的客户端程序
以这种方式传输的音频的解码和播放示例超出了本文的范围,但请关注另一篇关于使用 NAudio 在 Windows 上制作此类客户端的文章,即将发布。
应用
由于以这种方式传输的音频将没有失真、压缩损失,并保持高保真度,因此可以进行进一步处理,例如噪声源三角定位和环境音频监控,即使只是为了好玩!
在任何可能的应用中使用 MKR WiFi 1010 和无线路由器进行描述的方法的缺点是,为了避免 UDP 数据包丢失(导致信号质量下降),路由器需要靠近 MKR WiFi 1010。
链接
自定义固件和相应的 MKR WiFi 1010 草图分别可在 git 仓库 此处 和 此处 找到。附带的预编译自定义固件二进制文件可用于使用 _esptool.py_ 刷写到 NINA-W102,而无需自行编译自定义固件。
历史
- 2022年10月17日:初始版本