一、项目总体说明

1、 整体功能总览

  1. 自动对等设备发现:UDP 广播 beacon,同局域网内两台设备互相识别,超时自动丢失 peer 并重新搜索
  2. 半双工 PTT 对讲:GPIO0 (BOOT 键) 按下 = 本地拾音 + UDP 发送音频;松开 = 接收 UDP 音频并 I2S 播放
  3. 音频链路:INMP441 (I2S0 RX) 采集 → 16kHz 16bit 单声道 PCM → UDP 数据包传输 → MAX98357 (I2S1 TX) 功放外放
  4. WS2812 (GPIO48) 状态灯规则
    • 🔴 红色常亮:PTT 发射中(本地说话)
    • 🟢 绿色短时点亮:正在收到对方音频(音频包到来后保持 150ms,静音熄灭)
    • 🟡 黄色周期闪烁:正在搜索对等机、未找到 peer
    • ⚫ 熄灭:已找到 peer、当前无收发、空闲静默
  1. 音频预处理:接收端内置软件增益(当前 1.8 倍,可修改,自带限幅防削波爆音)

2、硬件与接线说明

⚠️ ESP32-S3 通用,两台必须一模一样接线,否则 I2S 不通

1. 外设清单

外设

型号

功能

I2S 通道

数字麦

INMP441

音频采集(输入)

I2S_NUM_0(RX)

D 类功放

MAX98357A

音频播放(输出)

I2S_NUM_1(TX)

PTT 按键

板载 BOOT

发话按键

GPIO0(内部上拉)

RGB LED

WS2812 单珠

状态指示

GPIO48

2. 固定引脚映射

I2S0(INMP441 麦克风)

  • I2S0 BCLK → GPIO5
  • I2S0 WS(LRCLK) → GPIO4
  • I2S0 DIN → GPIO6

INMP441 注意:SD 引脚接 GPIO6,L/R 脚拉低 = 左声道(代码配置 ONLY_LEFT)

I2S1(MAX98357 功放)

  • I2S1 BCLK → GPIO15
  • I2S1 WS(LRCLK) → GPIO16
  • I2S1 DOUT → GPIO7

MAX98357:SD 引脚可固定高电平常开;无需 MCLK(I2S_PIN_NO_CHANGE)

其他

  • BOOT PTT KEY:GPIO0(INPUT_PULLUP,按下 = LOW)
  • WS2812 DATA:GPIO48

供电提示

  • INMP441 / MAX98357 推荐 3.3V,严禁 5V 直供 INMP441
  • 扬声器:4Ω/8Ω 小喇叭,MAX98357 最大输出注意不要长时间大音量

3、 端口分配

  • 8888:UDP 音频数据流端口(传输 PCM 音频包)
  • 8889:UDP 设备发现广播端口(beacon 握手)
  • 识别令牌:ESP32S3_INTERCOM_PEER(防止局域网内其他 UDP 设备误识别)

4、 时序说明

  • 音频块:512 样本,16k 单声道 → 单块时长 = 512/16000 = 32ms
  • UDP 每 32ms 左右发送一包,低延迟局域网对讲
  • 注意:UDP 无重传,弱 WiFi 下会丢包、声音卡顿、断音(适合近距离稳定 LAN,不适合远距离 WiFi)

5、 已知特性 & 局限

  1. 半双工:不能同时说话(按住发射就听不到对方,松开才接收)
  2. UDP 不可靠,无纠错、无重传,WiFi 信号差会出现断音、咔哒噪声
  3. 仅一对设备对讲,多 ESP32-S3 同时在线会抢 peer
  4. 仅 2.4G WiFi,5G AP 无法连接
  5. 依赖 legacy I2S,不能直接升级新版 ESP32 Arduino Core

二、软件逻辑分析

1. WiFi 初始化

  • 固定 2.4G WiFi,ESP32-S3 此代码不支持 5G WiFi
  • 连接成功后同时启动两个 UDP 实例:udpAudio(8888)udpDisc(8889)

修改 WIFI_SSID / WIFI_PASS 改成你的路由器 2.4G 信息,两台设备配同一个 WiFi

2. I2S 双驱动独立运行

本方案双 I2S 硬件独立:I2S0 专门收 MIC、I2S1 专门推功放,互不抢占

  • I2S0(RX):i2s_driver_install 接收 INMP441,DMA 持续采集到mic_buf[512]
  • I2S1(TX):tx_desc_auto_clear=true,音频包写完自动清理,避免杂音残留
  • 上电执行 i2s_zero_dma_buffer 清空 DMA,消除上电 POP 爆音

3. Peer 自动发现机制

流程:

  1. 本机每 1s 向255.255.255.255广播一次识别 TOKEN
  2. 监听 8889 端口,收到合法 TOKEN → 判断不是自己 IP → 更新peerIP、刷新存活时间lastPeerRx
  3. 3 秒没有收到对端 beacon → 判定 peer 离线,清空 peerIP,重新进入搜索黄灯闪烁

限制:同一局域网内只支持一对对讲(只会保存最后一个合法 peer,多设备会冲突)

4. PTT 发射流程

BOOT(GPIO0) == LOW → isPttTx=true → LED红
→ i2s_read 从INMP441读取一帧512样本PCM
→ udpAudio发包发送到peerIP:8888

半双工:发射期间不会执行音频接收播放,避免自激啸叫

5. 音频接收 + 播放

udpAudio解析8888端口数据包 → 读到rx_buf
✅ 内置软件增益处理:
int32_t temp = raw_sample * gain;
限幅 ±32767/-32768 防止溢出削波失真
→ i2s_write送入MAX98357播放
→ 更新rxAudioLastTick,维持绿灯150ms

当前增益 = 1.8 倍,小声可提高,增益过高会破音

6. WS2812 状态机

优先级顺序:

  1. PTT 发射中 → 🔴红
  2. 空闲、无 peer → 🟡黄灯 500ms 闪烁
  3. 空闲、已有 peer:
    • 最近 150ms 收到音频 → 🟢绿
    • 超时无音频 → ⚫熄灭

三、项目源码

项目使用 Arduino IDE 开发环境,esp32-core 使用 3.0 以上版本。

#include <Arduino.h> 
#include <driver/i2s.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <Adafruit_NeoPixel.h>

ESP32 Core 自带内置

  1. #include <Arduino.h> Arduino 基础核心头文件,所有 Arduino 工程默认内置,无需额外操作。
  2. #include <driver/i2s.h> ESP32 Arduino Core 自带 ESP-IDF 底层驱动头文件(I2S 音频驱动),随 esp32 核心包一起安装。
  3. #include <WiFi.h> ESP32 内置 WiFi 驱动库,属于 ESP32 核心自带。
  4. #include <WiFiUdp.h> UDP 网络通信,和 WiFi 配套,ESP32 核心原生自带。

⚠️ 第三方库,必须手动安装

  1. #include <Adafruit_NeoPixel.h> 👉 Adafruit NeoPixel(控制 WS2812 灯带 / LED)

安装方法(Arduino IDE)

  1. 顶部菜单:项目 → 加载库 → 管理库
  2. 搜索:Adafruit NeoPixel
  3. 作者:Adafruit
  4. 点击安装(建议安装最新稳定版)


#include <Arduino.h>
#include <driver/i2s.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <Adafruit_NeoPixel.h>

// ====================== CONFIG ======================
const char* WIFI_SSID     = "wifi名称";
const char* WIFI_PASS     = "wifi密码";
const uint16_t AUDIO_PORT = 8888;    // Audio stream port
const uint16_t DISC_PORT   = 8889;    // Discovery broadcast port
const char* DISC_TOKEN     = "ESP32S3_INTERCOM_PEER";

#define SAMPLE_RATE     16000
#define BUFFER_SAMPLES  512
#define BOOT_KEY_PIN    0
#define WS2812_PIN      48
#define RX_LED_HOLD_MS  150
#define DISC_INTERVAL   1000
#define PEER_TIMEOUT    3000
#define SEARCH_BLINK_MS 500   // 黄灯闪烁周期
// ====================================================

// I2S PIN DEF (same as your reference i2s demo)
#define I2S0_BCLK  5
#define I2S0_WS    4
#define I2S0_IN    6

#define I2S1_BCLK  15
#define I2S1_WS    16
#define I2S1_OUT   7
// ====================================================


WiFiUDP udpAudio;
WiFiUDP udpDisc;
Adafruit_NeoPixel led = Adafruit_NeoPixel(1, WS2812_PIN, NEO_GRB + NEO_KHZ800);

int16_t mic_buf[BUFFER_SAMPLES];
int16_t rx_buf[BUFFER_SAMPLES];

// Peer discovery state
IPAddress peerIP;
unsigned long lastDiscSend = 0;
unsigned long lastPeerRx = 0;
unsigned long blinkTimer = 0;
bool blinkState = false;

// I2S Config (unchanged)
const i2s_config_t i2s_config_rx = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = 0,
    .dma_buf_count = 4,
    .dma_buf_len = BUFFER_SAMPLES,
    .use_apll = true,
    .tx_desc_auto_clear = false,
    .fixed_mclk = 0
    };

const i2s_config_t i2s_config_tx = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = 0,
    .dma_buf_count = 4,
    .dma_buf_len = BUFFER_SAMPLES,
    .use_apll = true,
    .tx_desc_auto_clear = true,
    .fixed_mclk = 0
    };

const i2s_pin_config_t pin_cfg_rx = {
    .mck_io_num = I2S_PIN_NO_CHANGE,
    .bck_io_num = I2S0_BCLK,
    .ws_io_num = I2S0_WS,
    .data_out_num = I2S_PIN_NO_CHANGE,
    .data_in_num = I2S0_IN
    };

const i2s_pin_config_t pin_cfg_tx = {
    .mck_io_num = I2S_PIN_NO_CHANGE,
    .bck_io_num = I2S1_BCLK,
    .ws_io_num = I2S1_WS,
    .data_out_num = I2S1_OUT,
    .data_in_num = I2S_PIN_NO_CHANGE
};

bool isPttTx = false;
unsigned long rxAudioLastTick = 0;

// ====================== FUNCTION ======================
void setLed(uint8_t r, uint8_t g, uint8_t b){
  led.setPixelColor(0, led.Color(r,g,b));
  led.show();
}

void wifiInit(){
  Serial.print("Connect WiFi: ");
  Serial.println(WIFI_SSID);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while(WiFi.status() != WL_CONNECTED){
    delay(300);
    Serial.print(".");
  }
  Serial.printf("\nWiFi Connected, Local IP: %s\n", WiFi.localIP().toString().c_str());
  udpAudio.begin(AUDIO_PORT);
  udpDisc.begin(DISC_PORT);
  Serial.printf("Audio Port:%d | Discovery Port:%d\n",AUDIO_PORT,DISC_PORT);
}

void i2sAudioInit(){
  i2s_driver_install(I2S_NUM_0, &i2s_config_rx, 0, NULL);
  i2s_set_pin(I2S_NUM_0, &pin_cfg_rx);
  i2s_zero_dma_buffer(I2S_NUM_0);

  i2s_driver_install(I2S_NUM_1, &i2s_config_tx, 0, NULL);
  i2s_set_pin(I2S_NUM_1, &pin_cfg_tx);
  i2s_zero_dma_buffer(I2S_NUM_1);
  Serial.println("I2S Audio Hardware Ready");
}

// Discovery: broadcast beacon & listen peer beacon
void peerDiscoveryTask(){
  unsigned long now = millis();
  // Send broadcast beacon
  if(now - lastDiscSend > DISC_INTERVAL){
    udpDisc.beginPacket(IPAddress(255,255,255,255), DISC_PORT);
    udpDisc.write((uint8_t*)DISC_TOKEN, strlen(DISC_TOKEN));
    udpDisc.endPacket();
    lastDiscSend = now;
  }

  // Receive peer beacon
  int pktLen = udpDisc.parsePacket();
  if(pktLen > 0){
    char buf[64] = {0};
    udpDisc.readBytes(buf, pktLen);
    // Skip self packet
    if(udpDisc.remoteIP() == WiFi.localIP()) return;
    if(strcmp(buf, DISC_TOKEN) == 0){
      // First time found peer
      if(peerIP != udpDisc.remoteIP()){
        peerIP = udpDisc.remoteIP();
        Serial.print("✅ Found Peer IP: ");
        Serial.println(peerIP);
      }
      lastPeerRx = now;
    }
  }

  // Peer timeout, clear
  if(peerIP != IPAddress(0,0,0,0) && (now - lastPeerRx > PEER_TIMEOUT)){
    Serial.println("❌ Peer offline, re-searching...");
    peerIP = IPAddress(0,0,0,0);
  }
}

void audioPttTransmit(){
  // No peer, skip transmit
  if(peerIP == IPAddress(0,0,0,0)) return;

  size_t read_len = 0;
  i2s_read(I2S_NUM_0, mic_buf, sizeof(mic_buf), &read_len, 10);
  if(read_len <= 0) return;

  udpAudio.beginPacket(peerIP, AUDIO_PORT);
  udpAudio.write((uint8_t*)mic_buf, read_len);
  udpAudio.endPacket();
}

/*
void audioReceivePlay(){
  int pkt_len = udpAudio.parsePacket();
  if(pkt_len <= 0 || pkt_len > sizeof(rx_buf)) return;

  udpAudio.readBytes((uint8_t*)rx_buf, pkt_len);
  size_t written = 0;
  i2s_write(I2S_NUM_1, rx_buf, pkt_len, &written, portMAX_DELAY);
  rxAudioLastTick = millis();
}*/

void audioReceivePlay(){
  int pkt_len = udpAudio.parsePacket();
  if(pkt_len <= 0 || pkt_len > sizeof(rx_buf)) return;

  udpAudio.readBytes((uint8_t*)rx_buf, pkt_len);

  // ========== 接收端软件增益 ==========
  int gain = 1.8;
  int sampleCnt = pkt_len / sizeof(int16_t);
  for(int i=0; i<sampleCnt; i++){
    int32_t temp = (int32_t)rx_buf[i] * gain;
    if(temp > 32767) temp = 32767;
    if(temp < -32768) temp = -32768;
    rx_buf[i] = (int16_t)temp;
  }
  // ====================================

  size_t written = 0;
  i2s_write(I2S_NUM_1, rx_buf, pkt_len, &written, portMAX_DELAY);
  rxAudioLastTick = millis();
}



// ======================================================

void setup()
{
  Serial.begin(115200);
  pinMode(BOOT_KEY_PIN, INPUT_PULLUP);
  led.begin();
  setLed(0,0,0);

  wifiInit();
  i2sAudioInit();
  Serial.println("==== WiFi LAN Intercom [Auto Discovery] Ready ====");
  Serial.println("🔍 Searching peer device...");
}

void loop()
{
  unsigned long now = millis();
  peerDiscoveryTask();

  bool keyDown = (digitalRead(BOOT_KEY_PIN) == LOW);

  if(keyDown){
    if(!isPttTx){
      isPttTx = true;
      setLed(255,0,0);
      Serial.println("PTT START TALK");
    }
    audioPttTransmit();
  }else{
    if(isPttTx){
      isPttTx = false;
      Serial.println("PTT STOP TALK");
    }
    audioReceivePlay();

    if(!isPttTx){
      // 优先级:找到对等设备 -> 接收绿灯/熄灭;未找到 -> 黄灯闪烁
      if(peerIP == IPAddress(0,0,0,0)){
        if(now - blinkTimer > SEARCH_BLINK_MS){
          blinkTimer = now;
          blinkState = !blinkState;
          if(blinkState){
            setLed(255,255,0); // Yellow
          }else{
            setLed(0,0,0);
          }
        }
      }else{
        if(millis() - rxAudioLastTick < RX_LED_HOLD_MS){
          setLed(0,255,0);
        }else{
          setLed(0,0,0);
        }
      }
    }
  }
}

四、编译与测试

1、 项目编译说明

Arduino IDE 板选择

ESP32S3 Dev Module Flash/PSRAM 按你的开发板正常配置,PSRAM 不强制开启(当前缓冲区很小,不依赖 PSRAM)

编译前修改项(两台设备填相同 WiFi)

const char* WIFI_SSID     = "wifi密码名称";
const char* WIFI_PASS     = "wifi密码";

其余参数两台设备完全不需要修改,直接烧录相同固件即可配对对讲


2、 快速上手操作步骤

    1. 两台 ESP32-S3 按完全一样的原理图焊接 INMP441、MAX98357、喇叭
    2. 修改 WiFi 名称密码,两台烧完全相同代码
    3. 上电,黄灯闪烁 = 搜索;识别 peer 后黄灯熄灭
    4. 按住 BOOT 键说话(红灯亮),松开即可收听对方(收到声音闪绿灯)

3、 调试排查清单

1. 找不到 peer、黄灯一直闪

  • ✅ 两台设备连同一个 2.4G WiFi,不要一个 2.4G 一个 5G
  • ✅ 路由器是否隔离 AP 客户端(AP 隔离开启→广播包无法互通,必须关闭)
  • ✅ 串口看是否打印Found Peer IP,收不到 beacon 优先排查 AP 隔离

2. 能找到 peer,按下说话对方无声

  • INMP441 接线、LR 引脚电平、I2S0 BCLK/WS/DIN
  • 串口观察是否持续 PTT 打印PTT START TALK
  • 示波器逻辑分析仪看 I2S0 有没有波形

3. 收到信号、绿灯闪,但喇叭无声 / 杂音大

  • MAX98357 I2S1 引脚,确认 BCLK/WS/DOUT
  • 增益不要过大,检查 MAX98357 SD 使能脚拉高
  • 上电 i2s_zero_dma_buffer 减少 POP 噪声

4. 爆音、破音

  • 增益调低;确认限幅代码生效
  • WiFi 干扰、丢包也会带来咔嗒噪声(UDP 固有)


Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐