简介:基于ESP32与ICS-43434数字麦克风、MAX98357 I2S功放实现的无线对讲机完整源码,面向物联网硬件开发者、电子爱好者和Python程序员,适用于校园、户外、小型团队等无需基站即可语音通信的场景。压缩包共29个文件,约14.57MB,核心为14个Python脚本,涵盖主控逻辑、Wi-Fi通信、音频采集与播放、外设驱动(按钮/触摸/电位器)等模块;另含5个WAV测试音频、2份PDF芯片手册、JSON配置及辅助文件,便于对照硬件调试。已有989人学习/下载。源码结构清晰,包含main.py、wifihandler、dispatcher、ics43434、max98357a等关键模块,并附有验证脚本与说明文档,开发者可直接烧录运行,也可基于模块化设计二次开发,快速构建低成本的无线语音传输系统。
1. 两片ESP32加两套I2S外设,无线对讲机的核心难点在“流”不在“响”
两片ESP32开发板,各接一只ICS-43434数字麦克风,再各接一只MAX98357功放和小喇叭,不依赖云端、不需要额外基站,就能在同一个Wi-Fi网络里组成一对可通话的无线对讲机。把麦克风、功放、ESP32三大件凑齐并不难,真正让新手卡壳的是:ICS-43434输出的是I2S数字音频流,MAX98357吃进去的也是I2S数字音频流,中间的ESP32既要消化DMA缓冲区的采集节奏,又要控制UDP包的网络发送节奏,两边节奏一旦错位,声音就会断断续续或是延迟飙升。这个设计本质上不是“把声音发出去”,而是“把音频当流水一样持续搬运”,适合已经会用ESP32点灯、读传感器,想往实时音频方向走一步的嵌入式工程师。下面的方案全部围绕真实可跑通的最小系统展开,先从I2S链路说起。
2. 搭建I2S音频链路:ICS-43434与MAX98357的接线、驱动与数据格式
2.1 引脚规划:两组I2S外设还是共用一条总线
ESP32 芯片内部通常有两组 I2S 外设,在 Arduino-ESP32 环境里分别暴露为I2S_NUM_0和I2S_NUM_1。这里建议把麦克风和功放分开挂到两组外设上:I2S0 只做接收(RX)采集 ICS-43434,I2S1 只做发送(TX)驱动 MAX98357。虽然 ESP32 的 I2S 也支持全双工模式,但把两个方向混在同一组外设上,调试时钟极性时容易互相干扰,尤其是在采样率较高的情况下。
接线时主要区分两类引脚:BCLK(位时钟)和 WS/LRC(字选择)。ICS-43434 的 WS 引脚需要硬件配置为 I2S 模式,有的模块(例如 Adafruit 的 ICS-43434 分线板)已经通过上拉电阻固定,直接接信号即可。MAX98357 的 LRC 引脚在 I2S 标准模式下与 WS 同义,只是名称不同而已。数据引脚方面,ICS-43434 输出引脚标记为SD,MAX98357 的输入标记为DIN,两者名字不同,但本质都是数据线。
下面是这套设计中我常用的一组引脚分配,供参考。注意 GPIO 编号因开发板型号不同会有变化,凡是标着“开发板丝印”的,以你手里的板子丝印为准。
| 功能 | 外设 | 设备 | BCLK | WS/LRC | DATA |
|---|---|---|---|---|---|
| 麦克风采集 | I2S0 (RX) | ICS-43434 | GPIO26 | GPIO25 | GPIO22 |
| 功放播放 | I2S1 (TX) | MAX98357 | GPIO32 | GPIO33 | GPIO27 |
| 按键PTT(可选) | GPIO/GND | 按钮 | - | - | GPIO5 |
提示:ICS-43434 的供电电压范围标称是 1.6V 到 3.6V,可以直接使用 ESP32 的 3.3V 供电;MAX98357 供电范围是 2.5V 到 5.5V,用 3.3V 时扬声器输出功率会低于 5V 供电,但对讲机场景下音量通常够用。
2.2 采样率、位深与 DMA 缓冲的取舍
对讲机语音信号的频率范围通常只需要覆盖 300Hz 到 3.4kHz,所以 8kHz 采样率在理论上就足够。但实际工程中我一般会选 16kHz 采样率,原因有两个:一是 16kHz 时 WS 引脚翻转频率更高,接收端对时钟边沿的采样容错更好;二是后期如果要加简单的噪声抑制算法,16kHz 的频域信息比 8kHz 丰富得多。
位深这里有一个容易踩的坑。ICS-43434 内部 ADC 输出的是 24 位数据,左对齐在 32 位帧里,而 MAX98357 可以接收 16 位或 32 位的 I2S 数据。如果直接把 24 位数据交给 MAX98357,音量会偏小且低字节全是无意义数据。所以在代码里要统一用 16 位输出:从 DAC 读取数据后右移 8 位,把高 16 位保存下来,再写入 TX 外设。这样做还有一个额外好处——每包传输的数据量减半,网络负载更小。
DMA 缓冲区大小直接决定延迟和美声度。ESP32 的 I2S 驱动使用环形 DMA 缓冲区,常见的配置是buffer_count=8、buffer_len=256,也就是每块缓冲区可存放 256 个 32 位样本(I2S_DAC 模式下),总共约 8 块。缓冲区越小,延迟越低,但如果采集和网络发送的速度不匹配,很容易产生 underrun(播放端数据取空)或 overrun(采集端数据溢出)。实际调参时建议先跑通默认配置,不要一上来就追求低延迟。
2.3 Arduino 框架下的 I2S 驱动最小代码
使用 Arduino-ESP32 内核自带的 I2S 库,可以直接省略厂商 SDK 的初始化细节,下面是两个外设初始化的代码。注意i2s_pin_config_t结构体里的变量名在不同内核版本中略有差异,相对稳定的写法如下。
#include <driver/i2s.h> // I2S0 采集 ICS-43434 数字麦克风 void i2s_mic_init() { i2s_config_t i2s_rx_config = { .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate = 16000, .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT, // 读 32 位左对齐,实际取高 16 位 .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, .communication_format = I2S_COMM_FORMAT_STAND_I2S, .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, .dma_buf_count = 8, .dma_buf_len = 256, .use_apll = false, .tx_desc_auto_clear = false, .fixed_mclk = 0 }; i2s_pin_config_t pin_rx = { .bck_io_num = 26, .ws_io_num = 25, .data_out_num = -1, .data_in_num = 22 }; i2s_driver_install(I2S_NUM_0, &i2s_rx_config, 0, NULL); i2s_set_pin(I2S_NUM_0, &pin_rx); }// I2S1 播放到 MAX98357 功放 void i2s_speaker_init() { i2s_config_t i2s_tx_config = { .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX), .sample_rate = 16000, .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT, // 写入 32 位帧,有效数据放高 16 位 .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, .communication_format = I2S_COMM_FORMAT_STAND_I2S, .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, .dma_buf_count = 8, .dma_buf_len = 256, .use_apll = false, .tx_desc_auto_clear = true, // TX 下应开启,避免 DMA 残留数据刷杂音 .fixed_mclk = 0 }; i2s_pin_config_t pin_tx = { .bck_io_num = 32, .ws_io_num = 33, .data_out_num = 27, .data_in_num = -1 }; i2s_driver_install(I2S_NUM_1, &i2s_tx_config, 0, NULL); i2s_set_pin(I2S_NUM_1, &pin_tx); }上面的配置里两个关键参数需要重点说明。bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT表示的是 I2S 总线上传输的帧位宽,而不是有效音频位深。ICS-43434 的数据左对齐到 32 位,读取后手动右移 8 位得到 16 位有效数据;写入 MAX98357 时再把 16 位数据左移 8 位放回 32 位帧中。tx_desc_auto_clear这个参数,在 TX 模式下建议置为true,否则 DMA 描述符里残留的旧数据会在无新数据可写时被重复播放,产生刺耳杂音。
初始化完成后,可以先用一个最简单的本地回环做验证:从 I2S0 读出数据,处理成 16 位,再写入 I2S1。如果扬声器能听到自己说话的回声(不接网络),说明整条音频通路已经打通。
void loop() { int32_t sample[512]; size_t bytes_read = 0; size_t bytes_written = 0; int16_t out[512]; esp_err_t err = i2s_read(I2S_NUM_0, sample, sizeof(sample), &bytes_read, portMAX_DELAY); if (err == ESP_OK && bytes_read > 0) { int cnt = bytes_read / 4; // 32 位样本个数 for (int i = 0; i < cnt; i++) { out[i] = (int16_t)(sample[i] >> 14); // 右移 8 位取高 16 位,再放大 2 倍补偿音量 } i2s_write(I2S_NUM_1, out, cnt * 2, &bytes_written, portMAX_DELAY); } }这里采样值右移 14 位而不是 8 位,是一个实际调试中的经验值:ICS-43434 在正常说话音量下的输出幅值远小于满量程,单纯右移 8 位获得的数据会明显偏小,听感音量过低。右移 14 位相当于在取出高 16 位的基础上额外乘了 2 倍,属于一个粗糙的固定增益补偿。真实产品里应该用 AGC 或者软音量控制,但在原型验证阶段这个补偿很有用。
3. 无线传输链路:用 UDP 承载 20ms 一包的语音流
3.1 为什么对讲机场景必须选 UDP 而不是 TCP
对讲机音频对实时性的要求远高于可靠性。TCP 为了保证数据完整,会在丢包后重传已经错过时间窗口的数据包,这些包到达播放端时已经“过期”,播放出来就是一顿一顿的噪音,同时 TCP 的拥塞控制会把延迟抖动进一步放大。UDP 没有重传,丢包最多造成一个短促的间隙,听感上往往只像一个字被切掉一点,远比重传引发的卡顿易接受。
在局域网环境下,UDP 丢包率本身很低,所以更不用担心可靠性问题。常见做法是两个 ESP32 直连同一个路由器,或者其中一个 ESP32 开 SoftAP、另一个以 STA 模式连接。UDP 单播即可满足点对点通话;如果后续要做一呼多响的群组对讲,改成广播地址并让接收端过滤特定端口的数据就能实现,代码改动很小。
这里的“无线链路”方案不依赖任何外网服务,所有数据都在本地局域网内转发。
3.2 包长、发送节奏与抖动缓冲区的下限
选定 16kHz、16 位单声道后,码率是固定的:每秒 32000 字节。音频要打包成 UDP 数据报发送,包长直接决定发送频率和延迟上限。我一般选择 20ms 为一个音频帧,即每包 640 字节。20ms 是语音通信行业里普遍认可的一个平衡点:包太短(如 5ms)会导致每秒发包 200 个,Wi-Fi 的节能模式和路由器的转发能力都吃紧;包太长(如 100ms)虽然网络效率高,但明显感觉说话有拖沓感。
这里有一组计算可以直观感受带宽占用。每包音频负载 640 字节,加上 UDP 头 8 字节、IP 头 20 字节,链路层实际约 670 字节左右,每秒 50 包,合计不足 300kbps。对于 Wi-Fi 的链路速度而言非常充裕。
接收端要注意抖动缓冲区的设计。UDP 包从发送端到接收端并不是严格等间隔到达的,Wi-Fi 的 beacon 间隔、路由器调度都会造成抖动。接收端用 ESP32 的 I2S DMA 缓冲本身就能吸收一部分抖动,所以不一定要额外实现 FIFO 队列:当 I2S 写数据的速度略快于 UDP 包到达速度时,DMA 缓冲会自然填补间隙,直至 underrun。如果测试中发现频繁出现断音,优先增大dma_buf_count而不是急于实现应用层队列。
3.3 发送端与接收端的 UDP 音频代码实现
这里用 Arduino 的WiFiUdp库实现发送和接收。发送端放在 loop 里,读取 20ms 音频后直接beginPacket发出。
#include <WiFi.h> #include <WiFiUdp.h> WiFiUDP udp; IPAddress peerIP(192, 168, 1, 200); // 对端 ESP32 的 IP const uint16_t PORT = 50001; // 读取 20ms 音频并发送 void send_audio_packet() { int16_t pcm[320]; // 20ms * 16kHz = 320 个样本 size_t bytes_read = 0; esp_err_t err = i2s_read(I2S_NUM_0, pcm, sizeof(pcm), &bytes_read, pdMS_TO_TICKS(50)); if (err != ESP_OK || bytes_read == 0) { return; } int bytes_to_send = (bytes_read / 4) * 2; // 32 位转 16 位后的字节数 // 发送前手动把每个 32 位样本的高 16 位提取出来 // 这里为简化演示,已假设 pcm 缓冲区预先做过缩放转换 udp.beginPacket(peerIP, PORT); udp.write((const uint8_t *)pcm, bytes_to_send); udp.endPacket(); } void loop() { send_audio_packet(); delay(5); // 适当等待,避免阻塞 I2S 读操作 }这段代码有一个隐含的关键点:i2s_read请求读sizeof(pcm)也就是 640 字节,在 16kHz、32 位帧格式下正好对应 20ms 音频。pdMS_TO_TICKS(50)作为超时,避免 I2S 数据不足时无限阻塞整个网络处理。如果改用portMAX_DELAY,在 DMA 缓冲为空时 loop 会停滞,可能导致 UDP 接收端无法及时处理入站数据包。
接收端代码更短,核心是parsePacket读取网络数据后直接i2s_write:
void recv_audio_packet() { int packet_size = udp.parsePacket(); if (packet_size == 0) { return; } uint8_t buf[700]; int len = udp.read(buf, sizeof(buf)); if (len <= 0) { return; } // 将 16 位 PCM 数据左移回 32 位帧高位 int16_t *pcm = (int16_t *)buf; int sample_cnt = len / 2; int32_t frame[700]; for (int i = 0; i < sample_cnt; i++) { frame[i] = ((int32_t)pcm[i]) << 14; // 与发送端右移量保持一致 } size_t bytes_written = 0; i2s_write(I2S_NUM_1, frame, sample_cnt * 4, &bytes_written, pdMS_TO_TICKS(50)); }接收端注意buf大小要留足余量,700 字节对 640 字节的音频包加 UDP/IP 头绰绰有余。i2s_write前的移位操作是为了把 16 位数据还原成 32 位帧结构。如果你在初始化时配置了I2S_BITS_PER_SAMPLE_16BIT,就不需要这个移位步骤,但前面说过 16 位帧格式下 ICS-43434 的 24 位数据会被截断,音质损失明显,所以两端的帧位宽必须和收发数据的格式完全一致。
4. 双侧对称工作:把采集、发送、接收、播放排成任务
4.1 半双工 PTT 还是全双工模式
无线对讲机最常见的交互是半双工:按住按钮说话,松开按钮收听。半双工的好处不只是符合使用习惯,更重要的是从根源上避免了回声问题——本地扬声器播放的声音不会在麦克风采集时被误发出去。代码上,PTT 按键只需要作为发送流程的使能条件,松开时不读取 I2S 麦克风数据即可。
全双工模式不需要按键,两边的 ESP32 同时执行采集发送和接收播放。这在技术上是可行的,因为 I2S0 与 I2S1 是相互独立的 DMA 通道,Wi-Fi 协议栈也支持并发收发。但全双工打开后,本地扬声器的声音会直接灌进本地麦克风,形成强回声。如果要做全双工,必须叠加回声抑制算法,否则体验远不如半双工。
建议第一版直接做半双工。原型验证全双工链路是否通,可以用 5.2 提到的 ducking 方案临时压制回声,而不是一开始就上自适应滤波器。
4.2 FreeRTOS 任务切分与优先级设置
即使 loop 里同时写发送和接收逻辑也能工作,但音频处理和网络收包一旦互相阻塞,延迟就会抖动。更稳定的做法是把两件事拆成独立任务,并指定运行核心。
ESP32 是双核 MCU,I2S 读取和 UDP 收发都涉及外设中断与 DMA,把它们放到不同核心上可以减少调度冲突。下面是一个典型的任务分配方式:
// 发送任务:采集 -> UDP 发送 void task_send(void *param) { while (1) { if (ptt_pressed()) // 读取 GPIO5 { send_audio_packet(); } delay(2); } } // 接收任务:UDP 接收 -> I2S 播放 void task_recv(void *param) { while (1) { recv_audio_packet(); delay(1); } } void setup_wireless_tasks() { xTaskCreatePinnedToCore(task_send, "send_task", 8192, NULL, 2, NULL, 1); xTaskCreatePinnedToCore(task_recv, "recv_task", 8192, NULL, 2, NULL, 0); }优先级选 2 是经验值:高于loopTask(Arduino 的默认任务优先级通常是 1),低于 Wi-Fi 协议栈内部任务(通常在 3 到 5 之间)。这样既保证音频任务能抢占低优先级的串口打印等逻辑,又不会干扰 Wi-Fi 驱动的底层收发。任务栈大小 8192 字节也值得注意,WiFiUdp库内部缓冲区较大,栈给得太小会直接复位重启。
4.3 链路验证方法:从正弦波到语音
接入真实人声之前,先用正弦波验证整条数据通路。发送端循环生成一个 1kHz 正弦波并把数据送进 UDP 发送函数,接收端把收到的数据同时写成串口波形。没有逻辑分析仪时,可以在接收端串口里每隔一定字节打印当前样本值,看到规律性变化的数值就说明链路是通的。
还有一种强有力的验证方式是本地环回测试:在发送端代码里同时初始化 I2S0 和 I2S1,然后通过 UDP 把采集到的音频先发送到对端,对端再原样转发回来播放。这个方案同时验证了两个板的采集、无线收发、播放全链路,比各自单独测试更接近真实工作状态。
5. 延迟预算、回音抑制与一个可复用的测延迟技巧
5.1 40ms 延迟预算怎么分配
对讲机可接受的端到端延迟一般是 100ms 以内,但我建议按 40ms 作为设计目标。其中采集端 I2S DMA 缓冲约 8ms,UDP 发送等待和调度约 5ms,Wi-Fi 空中传输约 1 到 3ms,接收端 DMA 缓冲约 8ms,剩下的约 16ms 留给任务调度抖动。这个预算下即使路由器在 beacon 间隙出现 10ms 级别的抖动,整体仍在 60ms 以内,通话感很自然。
压缩这个延迟的关键在于减少 DMA 缓冲区大小,但减少缓冲区意味着 underrun 风险增加。一个实测有效的做法是把dma_buf_count从 8 降到 4,同时把dma_buf_len降到 128,然后连续通话几分钟观察是否有爆音。如果没有,就再进一步;如果出现爆音,说明接收端播放速度高于网络供给速度,需要回退一档。
5.2 全双工下最简单的回声抑制:ducking
全双工模式下,一个直观但非常有效的回声压制方法是音量衰减:当本端检测到麦克风有语音输入时,立刻把扬声器播放音量压低。这个技术叫 ducking,通话系统中常用于压低背景音乐,在对讲机全双工场景下同样适用。
float speaker_gain = 1.0f; // 在接收任务里,写入 I2S 前应用增益 void apply_speaker_gain(int32_t *frame, int cnt, float gain) { for (int i = 0; i < cnt; i++) { frame[i] = (int32_t)(frame[i] * gain); } }检测麦克风语音能量的代码很简单:每 10ms 统计一次当前缓冲区的峰值绝对值,超过某个阈值就判定为“本端在说话”,此时将speaker_gain快速拉低到 0.15;当能量低于阈值持续 100ms 后再恢复为 1.0。这个策略能显著减轻“自己听到自己回声”的感觉,并不能完全消除,但成本几乎为零,不占用额外 CPU。
5.3 环回测延迟法:不算绝对时间也能得到单向延迟
双向对讲时很难精确测量单向延迟,因为你无法同时让两端以同一时钟计时。这里有一个很实用的环回技巧:发送端 A 发一个带时间戳标记的 UDP 包给接收端 B,B 收到后不做任何处理,立即把同一包原样发回 A。A 测量从发出到收到回包的时间差,除以 2,就得到端到端的单向延迟。
// A 端:发送测试包并计算往返时间 unsigned long t_start = millis(); udp.beginPacket(peerIP, PORT); udp.write((const uint8_t *)"PING", 4); udp.endPacket(); while (millis() - t_start < 1000) { if (udp.parsePacket() > 0) { unsigned long rtt = millis() - t_start; Serial.printf("单程延迟约 %lu ms\n", rtt / 2); break; } } // B 端:收到任何数据包都原样回发 int len = udp.parsePacket(); if (len > 0) { udp.read(buf, sizeof(buf)); udp.beginPacket(udp.remoteIP(), udp.remotePort()); udp.write(buf, len); udp.endPacket(); }这个测试包含的是 Wi-Fi 空中的双向传输时间,物理链路上是同一跳,所以除以 2 得到的是“点到点单向延迟”,I2S 采集和播放的缓冲延迟不包含在内。实际对讲时的感知延迟还要再加上两端 DMA 缓冲的时间,所以测量值通常比人耳感知值低一些。调试时只要这个测量值稳定在 15ms 以内,说明网络侧没有瓶颈,后续优化的重点应该放在 I2S DMA 配置上。
本文还有配套的精品资源,点击获取