ESP32-S3 + ES7210 四通道麦克风录音上传方案

ESP32-S3 + ES7210 四通道麦克风录音上传方案

一、项目概述

本方案实现 ESP32-S3 + ES7210(4 通道音频 ADC) 的完整录音链路:

  1. ESP32-S3 通过 I2C 初始化 ES7210,配置为 16kHz / 16bit / 4-slot TDM 采集;
  2. 连续采 5 秒 四通道交织音频数据,存入 PSRAM(640000 字节);
  3. 在设备内存中把交织数据切分成 4 个单声道通道,各自加上 44 字节 WAV 头,生成 4 个标准的单声道 WAV 文件(每个 160044 字节);
  4. 分 4 次 HTTP POST 上传到电脑上的 Python 接收服务器,服务器落盘即为成品 WAV(无需再做任何解析)。

重要经验(本工程排障后的结论) :ES7210 必须工作在 TDM I2S/Left-Justified 模式(寄存器 0x12 = 0x02)。此前使用 0x12 = 0x01(TDM DSP/PCM)时,串口诊断显示 ch2、ch4 恒为静音;改为 0x12 = 0x02 后四通道全部正常出声。若你的板子出现"两路无声",优先检查 0x12 的值与 ESP32 侧 Philips TDM 的匹配。


二、硬件连接

2.1 引脚对照表

ESP32-S3 (GPIO) 方向 ES7210 / 外设 说明
GPIO4 I2C SDA CDATA (SDA) I2C 数据,100 kHz
GPIO5 I2C SCL CCLK (SCL) I2C 时钟,100 kHz
GPIO15 I2S MCLK MCLK 主时钟输出 256×Fs
GPIO18 I2S BCLK SCLK 位时钟(主模式输出)
GPIO17 I2S LRCK LRCK 字时钟/帧同步(主模式输出)
GPIO16 I2S DIN (输入) SDOUT1/TDMOUT 串行数据输入(ES7210 输出端)
GPIO48 --- WS2812 状态灯 板载 NeoPixel(录音完成后呼吸灯)
  • ES7210 的 SDOUT2/TDMIN 不需要连接(TDM 模式全部数据走 SDOUT1)。
  • 采集流程为 ESP32 主模式:ESP32 产生 MCLK/BCLK/LRCK,ES7210 作为从机输出数据。

2.2 ES7210 周边电路注意

  • I2C 地址 :AD1/AD0 引脚电平组合决定,本方案使用 7-bit 地址 0x40(AD1=0, AD0=0)。若读不到,检查 AD1/AD0 上拉/下拉,或挨个测试 0x40~0x43。
  • 麦克风输入 :4 颗驻极体/模拟麦克风分别接 差分对 MIC1P/MIC1N ~ MIC4P/MIC4N(接法请参照 ES7210 数据手册典型应用电路,P/N 之间按规格接入耦合电容等)。
  • 麦克风偏置 :ES7210 提供 MICBIAS12 (供 MIC1/2)与 MICBIAS34 (供 MIC3/4)两路偏置,本方案按 VDDM=3.3V 配置为 2.87V(寄存器 0x41/0x42 = 0x70)。若你的板子 VDDM 不同,请对照数据手册调整。
  • 供电:VDDA / VDDD / VDDP / VDDM 参考数据手册(本方案假设 3.3V),模拟地与数字地建议单点接地,避免通道串扰和底噪。

2.3 系统拓扑

复制代码
         ┌──────────────────────────────┐
         │          电脑(服务器)        │
         │  python es7210_wifi_server.py │ <-- 接收 4 个 WAV
         └──────────────┬───────────────┘
                        │ 同一 WiFi(HTTP,端口 8080)
         ┌──────────────┴───────────────┐
         │  ESP32-S3 (I2S 主/ I2C 主)     │
         │  GPIO16 <- SDOUT1 (TDM 数据)  │
         │  GPIO15/18/17 -> MCLK/BCLK/LRCK
         └──────────────┬───────────────┘
                        │ I2C (GPIO4/5)
         ┌──────────────┴───────────────┐
         │  ES7210 (4ch ADC, 从模式)      │
         │  MIC1~MIC4 差分输入 + MICBIAS  │
         └──────────────────────────────┘

三、TDM 数据格式说明

  • ES7210 输出:TDM I2S/Left-Justified(寄存器 0x12 = 0x02),每个 LRCK 周期依次输出 4 个时隙(slot)。

  • 每个时隙 16bit / 无填充位 ,因此一帧 = 4 通道 × 2 字节 = 8 字节:

    一帧(8 字节)= [CH1:2B][CH2:2B][CH3:2B][CH4:2B] (16bit 小端)

  • ESP32 侧使用 Philips TDM :I2S_DATA_BIT_WIDTH_16BIT + I2S_SLOT_BIT_WIDTH_16BIT + SLOT0~3,与 ES7210 的 I2S/LJ 帧布局匹配(注意:不要用 0x12=0x01 的 DSP 帧布局,会出现 2/4 路静音)。

  • 数据量:

    • 采样率 16000 Hz,时长 5 s,单通道样本数 80000,单通道 PCM16 数据 160000 字节;
    • 四通道交织 raw 总大小 = 160000 × 4 = 640000 字节;
    • 每通道 WAV = 44 字节头 + 160000 字节 = 160044 字节。

四、目录结构

复制代码
es7210_4ch/
├── es7210_wifi_server.py          # 电脑端接收服务器(本 README 第六节)
├── es7210_4ch_mic_record.ino      # ESP32 采集固件(本 README 第五节)
└── received/                      # 服务器运行后自动创建,存放上传的 4 个 WAV

五、ESP32 固件(Arduino .ino)

把下面的代码保存为 es7210_4ch_mic_record.ino。

编译前提:

  • Arduino-ESP32 3.x(ESP-IDF 5.x 内核),板型选 "ESP32S3 Dev Module";
  • 开启 PSRAM(Tools → PSRAM → "OPI PSRAM");
  • 安装库:Adafruit NeoPixel(状态灯用,可选)。
cpp 复制代码
/*
 * ESP32-S3 + ES7210 4-channel 16kHz/16bit TDM capture
 * Capture interleaved audio, split into four mono WAV files, upload each file.
 *
 * Target: Arduino-ESP32 3.x / ESP-IDF 5.x
 *
 * Workflow:
 *   1. Capture 5 seconds of 4-channel, 16 kHz, 16-bit TDM data.
 *      One TDM frame = 4 slots x 16 bits = 8 bytes.
 *   2. Split interleaved samples into four mono PCM16 WAV files.
 *   3. Upload each WAV file with a separate HTTP POST.
 *
 * TDM format:
 *   ESP32: Philips TDM
 *   ES7210: TDM I2S/LJ mode
 *
 * Note:
 *   The MICBIAS values below are set for the common VDDM = 3.3V case.
 *   Verify this against your ES7210 board's actual supply and microphone wiring.
 */

#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <math.h>
#include <string.h>

#include "driver/i2s_tdm.h"
#include "esp_err.h"
#include <Adafruit_NeoPixel.h>

// ---------- User settings ----------
static const char *WIFI_SSID = "Xiaomi_3FD0";
static const char *WIFI_PASS = "1234567890";
static const char *SERVER_IP = "192.168.31.68";
static const uint16_t SERVER_PORT = 8080;

// ---------- Pin mapping: verify against your actual wiring ----------
static constexpr int PIN_I2C_SDA = 4;
static constexpr int PIN_I2C_SCL = 5;
static constexpr int PIN_I2S_MCLK = 15;
static constexpr int PIN_I2S_BCLK = 18;
static constexpr int PIN_I2S_LRCK = 17;
static constexpr int PIN_I2S_DIN  = 16;

// ---------- Capture settings ----------
static constexpr uint32_t SAMPLE_RATE = 16000;
static constexpr uint32_t RECORD_SECONDS = 5;
static constexpr size_t CHANNELS = 4;
static constexpr size_t BYTES_PER_SAMPLE = 2;
static constexpr size_t TDM_FRAME_BYTES = CHANNELS * BYTES_PER_SAMPLE;

static constexpr size_t SAMPLES_PER_CH =
    SAMPLE_RATE * RECORD_SECONDS;                  // 80000

static constexpr size_t CH_DATA_BYTES =
    SAMPLES_PER_CH * BYTES_PER_SAMPLE;             // 160000

static constexpr size_t WAV_HEADER_BYTES = 44;
static constexpr size_t WAV_TOTAL_BYTES =
    WAV_HEADER_BYTES + CH_DATA_BYTES;              // 160044

static constexpr size_t RECORD_BYTES =
    SAMPLES_PER_CH * BYTES_PER_SAMPLE * CHANNELS;  // 640000

// ---------- WS2812 status LED ----------
static constexpr int WS2812_PIN = 48;
static constexpr int WS2812_COUNT = 1;

// ---------- Buffers ----------
static uint8_t *recording = nullptr;
static uint8_t *wavBuf = nullptr;

static i2s_chan_handle_t rxHandle = nullptr;
static bool captureUploadFinished = false;

static uint8_t ledBrightness = 0;
static int ledDirection = 1;
static uint32_t lastLedUpdate = 0;

static Adafruit_NeoPixel statusLed(
    WS2812_COUNT, WS2812_PIN, NEO_GRB + NEO_KHZ800);

static uint8_t es7210Address = 0x40;

// ------------------------------------------------------------------
// ES7210 I2C
// ------------------------------------------------------------------

static bool writeReg(uint8_t reg, uint8_t value) {
  Wire.beginTransmission(es7210Address);

  if (Wire.write(reg) != 1) {
    Wire.endTransmission();
    return false;
  }

  if (Wire.write(value) != 1) {
    Wire.endTransmission();
    return false;
  }

  return Wire.endTransmission() == 0;
}

static bool readReg(uint8_t reg, uint8_t &value) {
  Wire.beginTransmission(es7210Address);
  Wire.write(reg);

  if (Wire.endTransmission(false) != 0) {
    return false;
  }

  if (Wire.requestFrom(es7210Address, (uint8_t)1) != 1) {
    return false;
  }

  value = Wire.read();
  return true;
}

/*
 * Configure ES7210 for 16-bit, 4-slot TDM I2S.
 *
 * Register 0x11 = 0x60:
 *   16-bit sample width; protocol bits select I2S.
 *
 * Register 0x12 = 0x02:
 *   TDM I2S/Left-Justified mode.
 *
 * Registers 0x4B/0x4C = 0x00:
 *   Clear power-down bits for both ADC pairs.
 */
static bool configureES7210() {
  const struct {
    uint8_t reg;
    uint8_t val;
  } initTable[] = {
    {0x00, 0xFF}, {0x00, 0x32},

    {0x09, 0x30},
    {0x0A, 0x30},

    // HPF settings
    {0x23, 0x2A},
    {0x22, 0x0A},
    {0x20, 0x0A},
    {0x21, 0x2A},

    // Clock and sample-rate settings retained from the supplied configuration
    {0x02, 0xC1},
    {0x04, 0x01},
    {0x05, 0x00},
    {0x07, 0x20},

    // 16-bit I2S protocol
    {0x11, 0x60},

    // Four-channel TDM I2S/LJ mode
    {0x12, 0x02},

    // Analog system: normal operation, commonly used with VDDA = 3.3V
    {0x40, 0x43},

    // MICBIAS: 2.87V setting for the common VDDM = 3.3V case
    {0x41, 0x70},
    {0x42, 0x70},

    // Four microphone gains
    {0x43, 0x1A},
    {0x44, 0x1A},
    {0x45, 0x1A},
    {0x46, 0x1A},

    // Per-microphone low-power settings
    {0x47, 0x08},
    {0x48, 0x08},
    {0x49, 0x08},
    {0x4A, 0x08},

    // Power up MIC/ADC paths for MIC1-4
    {0x4B, 0x00},
    {0x4C, 0x00},

    {0x00, 0x71},
    {0x00, 0x41}
  };

  for (const auto &item : initTable) {
    if (!writeReg(item.reg, item.val)) {
      Serial.printf("I2C write failed: reg=0x%02X value=0x%02X\n",
                    item.reg, item.val);
      return false;
    }
    delay(1);
  }

  return true;
}

// ------------------------------------------------------------------
// ESP32 I2S TDM RX: 16-bit x 4 slots, 8 bytes per frame
// ------------------------------------------------------------------

static bool initI2STDM() {
  i2s_chan_config_t channelConfig =
      I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);

  channelConfig.dma_desc_num = 8;
  channelConfig.dma_frame_num = 256;

  esp_err_t err = i2s_new_channel(&channelConfig, nullptr, &rxHandle);
  if (err != ESP_OK) {
    Serial.printf("i2s_new_channel failed: %s\n", esp_err_to_name(err));
    return false;
  }

  i2s_tdm_config_t tdmConfig = {};

  tdmConfig.clk_cfg = I2S_TDM_CLK_DEFAULT_CONFIG(SAMPLE_RATE);
  tdmConfig.clk_cfg.mclk_multiple = I2S_MCLK_MULTIPLE_256;

  tdmConfig.slot_cfg = I2S_TDM_PHILIPS_SLOT_DEFAULT_CONFIG(
      I2S_DATA_BIT_WIDTH_16BIT,
      I2S_SLOT_MODE_STEREO,
      (i2s_tdm_slot_mask_t)(
          I2S_TDM_SLOT0 |
          I2S_TDM_SLOT1 |
          I2S_TDM_SLOT2 |
          I2S_TDM_SLOT3));

  tdmConfig.slot_cfg.slot_bit_width = I2S_SLOT_BIT_WIDTH_16BIT;

  tdmConfig.gpio_cfg.mclk = (gpio_num_t)PIN_I2S_MCLK;
  tdmConfig.gpio_cfg.bclk = (gpio_num_t)PIN_I2S_BCLK;
  tdmConfig.gpio_cfg.ws   = (gpio_num_t)PIN_I2S_LRCK;
  tdmConfig.gpio_cfg.dout = I2S_GPIO_UNUSED;
  tdmConfig.gpio_cfg.din  = (gpio_num_t)PIN_I2S_DIN;

  tdmConfig.gpio_cfg.invert_flags.mclk_inv = false;
  tdmConfig.gpio_cfg.invert_flags.bclk_inv = false;
  tdmConfig.gpio_cfg.invert_flags.ws_inv = false;

  err = i2s_channel_init_tdm_mode(rxHandle, &tdmConfig);
  if (err != ESP_OK) {
    Serial.printf("i2s_channel_init_tdm_mode failed: %s\n",
                  esp_err_to_name(err));
    i2s_del_channel(rxHandle);
    rxHandle = nullptr;
    return false;
  }

  err = i2s_channel_enable(rxHandle);
  if (err != ESP_OK) {
    Serial.printf("i2s_channel_enable failed: %s\n", esp_err_to_name(err));
    i2s_del_channel(rxHandle);
    rxHandle = nullptr;
    return false;
  }

  return true;
}

// ------------------------------------------------------------------
// Upload one WAV file
// ------------------------------------------------------------------

static bool uploadFile(const uint8_t *data,
                       size_t length,
                       const String &filename) {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("Wi-Fi disconnected; reconnecting...");
    WiFi.reconnect();

    const uint32_t start = millis();
    while (WiFi.status() != WL_CONNECTED &&
           millis() - start < 15000) {
      delay(250);
    }

    if (WiFi.status() != WL_CONNECTED) {
      return false;
    }
  }

  HTTPClient http;

  String url = String("http://") +
               SERVER_IP +
               ":" +
               String(SERVER_PORT) +
               "/upload?name=" +
               filename;

  if (!http.begin(url)) {
    Serial.println("HTTP begin failed");
    return false;
  }

  http.setTimeout(20000);
  http.setReuse(false);
  http.addHeader("Content-Type", "application/octet-stream");

  const int status = http.POST((uint8_t *)data, length);
  const String response = (status > 0) ? http.getString() : "";

  http.end();

  if (status != 200) {
    Serial.printf("HTTP upload failed: status=%d response=%s\n",
                  status, response.c_str());
    return false;
  }

  return true;
}

// ------------------------------------------------------------------
// WAV little-endian helpers
// ------------------------------------------------------------------

static void putLE16(uint8_t *p, uint16_t v) {
  p[0] = (uint8_t)(v & 0xFF);
  p[1] = (uint8_t)(v >> 8);
}

static void putLE32(uint8_t *p, uint32_t v) {
  p[0] = (uint8_t)(v & 0xFF);
  p[1] = (uint8_t)((v >> 8) & 0xFF);
  p[2] = (uint8_t)((v >> 16) & 0xFF);
  p[3] = (uint8_t)((v >> 24) & 0xFF);
}

// Extract one channel from the interleaved TDM buffer and build a mono WAV.
static void buildChannelWav(size_t ch) {
  const uint32_t dataLen = (uint32_t)CH_DATA_BYTES;

  // Standard 44-byte PCM WAV header
  memcpy(wavBuf, "RIFF", 4);
  putLE32(wavBuf + 4, 36 + dataLen);
  memcpy(wavBuf + 8, "WAVE", 4);
  memcpy(wavBuf + 12, "fmt ", 4);
  putLE32(wavBuf + 16, 16);                       // fmt chunk size
  putLE16(wavBuf + 20, 1);                        // PCM
  putLE16(wavBuf + 22, 1);                        // mono
  putLE32(wavBuf + 24, SAMPLE_RATE);
  putLE32(wavBuf + 28, SAMPLE_RATE * BYTES_PER_SAMPLE);
  putLE16(wavBuf + 32, BYTES_PER_SAMPLE);         // block align
  putLE16(wavBuf + 34, 16);                       // bits per sample
  memcpy(wavBuf + 36, "data", 4);
  putLE32(wavBuf + 40, dataLen);

  // TDM input is interleaved: slot0, slot1, slot2, slot3, repeat.
  const uint16_t *src = (const uint16_t *)recording;
  uint8_t *dst = wavBuf + WAV_HEADER_BYTES;

  for (size_t i = 0; i < SAMPLES_PER_CH; i++) {
    const uint16_t sample = src[i * CHANNELS + ch];
    dst[0] = (uint8_t)(sample & 0xFF);
    dst[1] = (uint8_t)(sample >> 8);
    dst += BYTES_PER_SAMPLE;
  }
}

// ------------------------------------------------------------------

static void stopWithError(const char *message) {
  Serial.println(message);
  while (true) {
    delay(1000);
  }
}

// ------------------------------------------------------------------
// Setup
// ------------------------------------------------------------------

void setup() {
  Serial.begin(115200);
  delay(800);

  Serial.println("\n=== ESP32-S3 / ES7210 4ch 16kHz/16bit capture ===");

  statusLed.begin();
  statusLed.setBrightness(255);
  statusLed.clear();
  statusLed.show();

  Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, 100000);
  Wire.setTimeOut(100);

  uint8_t value = 0;

  if (!readReg(0x00, value)) {
    stopWithError(
        "ES7210 I2C read failed. Check SDA/SCL, power, ground and address.");
  }

  Serial.printf("ES7210 register 0x00 before init: 0x%02X\n", value);

  if (!configureES7210()) {
    stopWithError("ES7210 configuration failed.");
  }

  Serial.println("ES7210 register sequence sent.");

  // Connect to Wi-Fi
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);

  Serial.printf("Connecting to Wi-Fi: %s\n", WIFI_SSID);

  const uint32_t wifiStart = millis();
  while (WiFi.status() != WL_CONNECTED &&
         millis() - wifiStart < 30000) {
    delay(500);
    Serial.print(".");
  }

  Serial.println();

  if (WiFi.status() != WL_CONNECTED) {
    stopWithError("Wi-Fi connection timed out. Check SSID/password.");
  }

  Serial.print("ESP32 IP: ");
  Serial.println(WiFi.localIP());

  // Initialize I2S
  if (!initI2STDM()) {
    stopWithError("I2S TDM initialization failed. Check Arduino-ESP32 version/API.");
  }

  Serial.println("I2S TDM RX enabled.");

  // Allocate recording buffer: try PSRAM first, then regular heap.
  recording = (uint8_t *)ps_malloc(RECORD_BYTES);
  if (recording == nullptr) {
    recording = (uint8_t *)malloc(RECORD_BYTES);
  }

  if (recording == nullptr) {
    stopWithError("Cannot allocate recording buffer. Enable PSRAM.");
  }

  // Allocate one-channel WAV buffer: try PSRAM first, then regular heap.
  wavBuf = (uint8_t *)ps_malloc(WAV_TOTAL_BYTES);
  if (wavBuf == nullptr) {
    wavBuf = (uint8_t *)malloc(WAV_TOTAL_BYTES);
  }

  if (wavBuf == nullptr) {
    free(recording);
    recording = nullptr;
    stopWithError("Cannot allocate WAV buffer.");
  }

  Serial.printf("Allocated rec=%u B, wav=%u B\n",
                (unsigned)RECORD_BYTES,
                (unsigned)WAV_TOTAL_BYTES);

  // Phase 1: capture 5 seconds of interleaved 4-channel raw audio.
  size_t recorded = 0;
  const uint32_t captureStart = millis();

  Serial.println("Recording 5 seconds...");

  while (recorded < RECORD_BYTES) {
    size_t bytesRead = 0;

    const size_t requestSize =
        min((size_t)(16 * 1024), RECORD_BYTES - recorded);

    const esp_err_t err = i2s_channel_read(
        rxHandle,
        recording + recorded,
        requestSize,
        &bytesRead,
        1000);

    if (err != ESP_OK || bytesRead == 0) {
      Serial.printf("I2S read failed/empty: %s\n", esp_err_to_name(err));
      stopWithError("Audio capture failed.");
    }

    // Each complete 4-slot, 16-bit TDM frame is 8 bytes.
    if ((bytesRead % TDM_FRAME_BYTES) != 0) {
      Serial.printf("I2S read is not frame-aligned: %u bytes\n",
                    (unsigned)bytesRead);
      stopWithError("Audio capture returned a partial TDM frame.");
    }

    recorded += bytesRead;
  }

  Serial.printf("Recording complete: %u bytes in %lu ms.\n",
                (unsigned)recorded,
                (unsigned long)(millis() - captureStart));

  // Diagnostic: channel peak and RMS before WAV splitting/upload.
  Serial.println("Channel energy (peak / RMS):");

  {
    const int16_t *samples = (const int16_t *)recording;

    for (size_t ch = 0; ch < CHANNELS; ch++) {
      int64_t sumSquares = 0;
      int32_t peak = 0;

      for (size_t i = 0; i < SAMPLES_PER_CH; i++) {
        const int32_t sample = samples[i * CHANNELS + ch];
        const int32_t absSample = (sample < 0) ? -sample : sample;

        if (absSample > peak) {
          peak = absSample;
        }

        sumSquares += (int64_t)sample * sample;
      }

      const float rms =
          sqrtf((float)sumSquares / (float)SAMPLES_PER_CH);

      Serial.printf("  slot%u: peak=%ld rms=%.1f %s\n",
                    (unsigned)(ch + 1),
                    (long)peak,
                    rms,
                    peak < 64 ? "(silent/empty)" : "(has signal)");
    }
  }

  // Diagnostic: read back important ES7210 registers.
  Serial.println("ES7210 register readback:");

  {
    const uint8_t regs[] = {
      0x40, 0x41, 0x42,
      0x43, 0x44, 0x45, 0x46,
      0x47, 0x48, 0x49, 0x4A,
      0x4B, 0x4C,
      0x11, 0x12,
      0x04, 0x05, 0x02, 0x07
    };

    uint8_t rd = 0;

    for (uint8_t reg : regs) {
      if (readReg(reg, rd)) {
        Serial.printf("  0x%02X = 0x%02X\n", reg, rd);
      } else {
        Serial.printf("  0x%02X = <read fail>\n", reg);
      }
    }
  }

  // Phase 2: split channels, build WAVs, and upload four separate files.
  const String base = "rec16k_" + String((uint32_t)millis());

  for (size_t ch = 0; ch < CHANNELS; ch++) {
    buildChannelWav(ch);

    const String filename =
        base + "_ch" + String((unsigned)ch) + ".wav";

    uint32_t retryDelay = 200;

    while (!uploadFile(wavBuf, WAV_TOTAL_BYTES, filename)) {
      Serial.printf("Upload retry ch%u (wait %u ms)\n",
                    (unsigned)ch,
                    (unsigned)retryDelay);

      delay(retryDelay);

      if (retryDelay < 5000) {
        retryDelay *= 2;
        if (retryDelay > 5000) {
          retryDelay = 5000;
        }
      }
    }

    Serial.printf("Uploaded ch%u: %s (%u B)\n",
                  (unsigned)ch,
                  filename.c_str(),
                  (unsigned)WAV_TOTAL_BYTES);
  }

  free(wavBuf);
  wavBuf = nullptr;

  free(recording);
  recording = nullptr;

  Serial.println("All 4 channel WAV files uploaded.");

  if (rxHandle != nullptr) {
    i2s_channel_disable(rxHandle);
    i2s_del_channel(rxHandle);
    rxHandle = nullptr;
  }

  captureUploadFinished = true;
}

// ------------------------------------------------------------------
// Loop: breathe the status LED after all uploads have completed.
// ------------------------------------------------------------------

void loop() {
  if (!captureUploadFinished) {
    delay(10);
    return;
  }

  const uint32_t now = millis();

  if (now - lastLedUpdate >= 15) {
    lastLedUpdate = now;

    int next = (int)ledBrightness + ledDirection * 3;

    if (next >= 255) {
      next = 255;
      ledDirection = -1;
    } else if (next <= 0) {
      next = 0;
      ledDirection = 1;
    }

    ledBrightness = (uint8_t)next;

    statusLed.setPixelColor(
        0,
        statusLed.Color(ledBrightness, ledBrightness, ledBrightness));

    statusLed.show();
  }
}

六、电脑端接收服务器(Python 标准库,零第三方依赖)

把下面的代码保存为 es7210_wifi_server.py(要求 Python 3,Windows/Linux/macOS 均可,无需 pip 安装任何包)。

python 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ESP32 音频数据接收服务器(纯标准库,零第三方依赖)

用法:
    python es7210_wifi_server.py [端口] [保存目录]
默认端口 8080,保存目录为脚本同级的 received/

功能:
    POST /upload?name=文件名&append=1&offset=偏移&total=总长
        接收二进制数据块,按偏移写入文件
        append=1 : 分块上传,offset 为写入偏移
        total    : 整个文件期望的总字节数,收满后做完整性校验
    GET  /     列出已收到的文件

与原版差异(修复 800KB 上传出问题的根因):
    1. 删除 append 分支"读不满就补 0x00"的逻辑。
       旧代码在网络中断/块过大时会把文件用 \x00 填满,
       导致 raw 文件被垃圾字节污染,后续解析四通道全部错位。
       现在任何一块未收满都会返回错误并丢弃该块,由 ESP32 重发。
    2. 追加时只允许 offset 顺序连续写入;offset 跳跃/回退直接报错。
    3. 收满 total 后校验最终 size == total,不满足则报错重传。

说明:
    1. 电脑和 ESP32 连同一个 WiFi。
    2. 首次运行 Windows 防火墙弹窗时选择"允许访问"。
    3. ESP32 里填电脑的局域网 IPv4(ipconfig 查看)。
"""
import http.server
import socketserver
import json
import os
import sys
import time
import urllib.parse

DEFAULT_SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "received")
MAX_BODY = 512 * 1024 * 1024   # 单次请求 body 上限(防御性)


class Handler(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    server_version = "ESP32Recv/2.0"

    def log_message(self, fmt, *args):
        sys.stderr.write("[%s] %s\n" % (time.strftime("%H:%M:%S"), fmt % args))

    # ---------- 工具 ----------
    def _reply(self, code, obj):
        data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Connection", "close")
        self.end_headers()
        self.wfile.write(data)

    def _safe_name(self, name):
        """去掉路径分隔符,防止写入目录外。"""
        if not name:
            return ""
        name = urllib.parse.unquote(name).replace("\\", "_").replace("/", "_").strip()
        name = os.path.basename(name)
        if not name or name in (".", ".."):
            return ""
        return name

    def _read_body(self, length, bufsize=65536):
        """严格读取 length 字节;收不满返回 None,由调用方丢弃。"""
        out = bytearray()
        left = length
        while left > 0:
            chunk = self.rfile.read(min(left, bufsize))
            if not chunk:
                return None
            out.extend(chunk)
            left -= len(chunk)
        return bytes(out)

    # ---------- GET: 文件列表 ----------
    def do_GET(self):
        files = []
        try:
            for f in sorted(os.listdir(self.server.save_dir)):
                p = os.path.join(self.server.save_dir, f)
                if os.path.isfile(p) and not f.endswith(".part"):
                    files.append({"name": f, "size": os.path.getsize(p)})
        except OSError:
            pass
        self._reply(200, {"ok": True,
                          "save_dir": self.server.save_dir,
                          "count": len(files),
                          "files": files})

    # ---------- POST: 接收数据 ----------
    def do_POST(self):
        parsed = urllib.parse.urlparse(self.path)
        if parsed.path.rstrip("/") != "/upload":
            self._reply(404, {"ok": False, "error": "未知路径,请使用 POST /upload"})
            return

        clen = self.headers.get("Content-Length")
        if clen is None:
            self._reply(411, {"ok": False, "error": "请求缺少 Content-Length"})
            return
        try:
            length = int(clen)
        except ValueError:
            self._reply(400, {"ok": False, "error": "Content-Length 非法"})
            return
        if length < 0 or length > MAX_BODY:
            self._reply(400, {"ok": False, "error": "Content-Length 超出限制"})
            return

        qs = urllib.parse.parse_qs(parsed.query)
        name = self._safe_name(qs.get("name", [""])[0])
        if not name:
            name = time.strftime("%Y%m%d_%H%M%S") + ".raw"

        append = qs.get("append", ["0"])[0] in ("1", "true", "yes")
        offset = 0
        try:
            offset = int(qs.get("offset", ["0"])[0] or 0)
        except ValueError:
            self._reply(400, {"ok": False, "error": "offset 参数无效"})
            return
        if offset < 0:
            self._reply(400, {"ok": False, "error": "offset 不能为负"})
            return

        total = None
        if "total" in qs:
            try:
                total = int(qs.get("total", ["0"])[0] or 0)
            except ValueError:
                self._reply(400, {"ok": False, "error": "total 参数无效"})
                return

        if not append:
            # 独立整块文件(同名自动加后缀,不覆盖)
            self._save_standalone(name, length)
            return

        # ---------- append=1: 按偏移拼接 ----------
        target = os.path.join(self.server.save_dir, name)

        # 读不满直接丢弃,绝不补 0
        body = self._read_body(length)
        if body is None:
            self._reply(400, {"ok": False, "error": "数据未收满,连接中断,请重发该块"})
            return

        try:
            os.makedirs(self.server.save_dir, exist_ok=True)
            cur = 0
            if os.path.exists(target):
                cur = os.path.getsize(target)

            # 只允许顺序连续写入:新块应从当前文件末尾开始
            if offset != cur:
                self._reply(409, {"ok": False,
                                  "error": "offset=%d 与文件当前长度 %d 不连续,文件可能已损坏,请删除后重传" % (offset, cur)})
                return

            with open(target, "ab") as f:
                f.write(body)
            written = os.path.getsize(target)

            # 总长校验(如果 ESP32 提供了 total)
            if total is not None and written >= total and total > 0:
                if written > total:
                    self._reply(400, {"ok": False,
                                      "error": "文件超过预期总长,数据异常,请重传"})
                    return
                print("完成: %s (共 %d 字节)" % (os.path.basename(target), written))
                self._reply(200, {"ok": True,
                                  "file": os.path.basename(target),
                                  "path": target,
                                  "done": True,
                                  "size": written,
                                  "total": total})
                return

            print("追加: %s (offset=%d, %d 字节, 当前 %d)" %
                  (os.path.basename(target), offset, len(body), written))
            self._reply(200, {"ok": True,
                              "file": os.path.basename(target),
                              "path": target,
                              "offset": offset,
                              "size": len(body),
                              "current": written,
                              "done": False})
        except Exception as e:  # noqa: BLE001
            self._reply(500, {"ok": False, "error": str(e)})

    # ---------- 独立整块保存 ----------
    def _save_standalone(self, name, length):
        base, ext = os.path.splitext(name)
        target = os.path.join(self.server.save_dir, name)
        i = 1
        while os.path.exists(target):
            target = os.path.join(self.server.save_dir, "%s_%d%s" % (base, i, ext))
            i += 1

        body = self._read_body(length)
        if body is None:
            self._reply(400, {"ok": False, "error": "数据未收满,连接中断"})
            return

        tmp = target + ".part"
        try:
            os.makedirs(self.server.save_dir, exist_ok=True)
            with open(tmp, "wb") as f:
                f.write(body)
            os.replace(tmp, target)
        except Exception as e:  # noqa: BLE001
            self._safe_cleanup(tmp)
            self._reply(500, {"ok": False, "error": str(e)})
            return

        print("已保存: %s (%d 字节)" % (os.path.basename(target), length))
        self._reply(200, {"ok": True,
                          "file": os.path.basename(target),
                          "path": target,
                          "size": length})

    def _safe_cleanup(self, p):
        try:
            os.remove(p)
        except OSError:
            pass


class Server(socketserver.ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True

    def __init__(self, addr, save_dir):
        self.save_dir = save_dir
        super().__init__(addr, Handler)


def main():
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
    save_dir = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_SAVE_DIR
    os.makedirs(save_dir, exist_ok=True)

    srv = Server(("0.0.0.0", port), save_dir)
    print("=" * 56)
    print("  ESP32 数据接收服务器 v2 已启动")
    print("  监听地址: 0.0.0.0:%d" % port)
    print("  保存目录: %s" % save_dir)
    print("  上传接口: POST /upload?name=<文件名>&append=1&offset=<偏移>&total=<总长>")
    print("  文件列表: GET /")
    print("  ESP32 中填电脑 IP: 在 cmd 里执行 ipconfig 查看 IPv4 地址")
    print("=" * 56)
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        print("\n已停止")
        srv.server_close()


if __name__ == "__main__":
    main()

说明:本方案中 ESP32 上传的是 4 个独立 WAV 文件 (单独 POST,不启用 append),服务器将以 rec16k_<时间戳>_ch0.wav ~ ch3.wav 的名字直接落盘;append 分块上传接口是为兼容大 raw 场景保留的。


七、部署与使用步骤

  1. 取得电脑局域网 IP

    Windows:命令行执行 ipconfig,找到当前 WiFi 网卡的 IPv4 地址 (本方案示例 192.168.31.68),填入 ESP32 固件顶部的 SERVER_IP。

  2. 启动服务器(电脑上)

    bash 复制代码
    cd 存放 es7210_wifi_server.py 的目录
    python es7210_wifi_server.py 8080

    首次运行 Windows 防火墙弹窗请选择"允许访问"(专用网络)。

    服务器会在脚本同目录自动创建 received/ 文件夹。

  3. 烧录 ESP32 固件

    用 Arduino IDE 打开 es7210_4ch_mic_record.ino,确认板型(ESP32S3 Dev Module)、PSRAM 已开启,修改顶部的 WiFi 账号密码与 SERVER_IP,编译烧录。

  4. 观察串口 (波特率 115200)

    启动后依次打印:WiFi 连接 → I2S 使能 → 内存分配 → 录音 5 秒 → 四通道能量诊断 → 寄存器回读 → 4 次上传进度。全部完成后板载 WS2812 进入呼吸灯状态。

  5. 验收

    • 服务器窗口出现 4 条「已保存」日志;
    • received/ 下出现 4 个 160044 字节的 WAV 文件;
    • 用播放器打开,或用 Audacity / Python 读取,确认四路声音正常。

八、调试与排障

8.1 串口诊断信息解读

  • Channel energy(peak / RMS) :slot1~slot4 对应 4 个通道。peak < 64 会标注 (silent/empty),表示该路几乎没有信号。正常环境应 4 路都有数值;对单颗麦克风吹气,应对应 slot 出现明显峰值。
  • ES7210 register readback :回读关键寄存器。正常应看到:
    0x11=0x60(16bit)、0x12=0x02(TDM I2S/LJ)、0x4B=0x00、0x4C=0x00(ADC 全部上电)、0x43~0x46=0x1A(四路 30dB 增益)、0x41/0x42=0x70(MICBIAS 2.87V)。

8.2 常见问题排查表

现象 可能原因 检查方向
串口报 ES7210 I2C read failed I2C 接线/供电/地址错 查 GPIO4/5 与 CDATA/CCLK 连接、ES7210 供电、AD0/AD1 电平(期望 0x40)
Wi-Fi connection timed out SSID/密码错、距离远 核对固件里 WiFi 参数,手机热点可临时替代
上传一直重试失败 电脑 IP 填错、服务器没启动、防火墙拦截 ipconfig 核对 IP;确认 python es7210_wifi_server.py 在跑;允许防火墙放行
录音时久久不完成 I2S 读到 0 / 帧未对齐 查 MCLK/BCLK/LRCK/SDOUT1 四根线;确认 ES7210 从模式与 0x12=0x02
上传到一半 offset 不连续 上次上传残留了半截文件 删除 received/ 里对应文件重录
某通道恒 (silent/empty) 且读回正常 该路硬件问题 对对应麦克风吹气看 slot 是否变化;查 MICnP/N 焊点、耦合电容、MICBIAS 分组供电
四路都是噪声/电流声 模拟地数字地共地不良、供电滤波不足 检查地线单点接地、去耦电容(数据手册参考设计)

8.3 本工程关键调试记录(供参考)

  1. 最初配置 0x12 = 0x01(TDM DSP/PCM)时,四通道能量显示 ch2、ch4 恒静音;
  2. 用寄存器回读确认 0x12 确实写入成功 → 排除 I2C 写入失败;
  3. 对照 ES7210 数据手册 Figure 2e/2g:DSP 模式帧序 CH1...CH4,I2S/LJ 模式帧序 CH1、CH3、CH2、CH4,且 ESP32 侧 Philips TDM 与 ES7210 的 I2S/LJ 布局才能正确匹配;
  4. 将 0x12 改为 0x02(TDM I2S/LJ)后,四通道全部正常出声 ------ 即为本 README 提供的最终配置。

结论 :ES7210 与 ESP32-S3(Arduino-ESP32 3.x)搭配时,四通道 TDM 采集必须用 0x12 = 0x02(TDM I2S/Left-Justified) ,同时保持 ESP32 侧 I2S_TDM_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, ...) + 四个 slot 掩码、slot_bit_width = 16BIT(无填充)。

相关推荐
weixin199701080162 小时前
《1688图片空间API踩坑:img.upload 与 album.* 的防盗链与CDN缓存问题》(附Python源码)
开发语言·python·缓存
2401_862880822 小时前
iMX6ULL 驱动工程搭建 + LED 灯驱动
c语言·驱动开发·嵌入式硬件
hahaha60162 小时前
FPGA+ARM实现AWB的全流程
人工智能·嵌入式硬件·算法·计算机视觉·fpga开发
ACP广源盛139246256732 小时前
Type‑C 扩展坞方案选型笔记|国产 DP1.4 转 HDMI2.0 桥接芯片 GSV2201S @ACP评估
c语言·开发语言·笔记·硬件架构·硬件工程·国产芯片
沐欣工作室_lvyiyi2 小时前
超市智能货架系统设计与实现(论文+源码)
c语言·单片机·课程设计·电子信息工程毕业设计·黑龙江工商学院·广州工商学院·成都工业学院
niucloud-admin2 小时前
JAVA V6 多商户商城 开发文档——JAVA服务端
java·开发语言
轻舟技术A3 小时前
Pi 的 Rust 实现来了:rpi,不止一个终端 Agent
开发语言·后端·rust
C++ 老炮儿的技术栈3 小时前
static的作用
c语言·c++·人工智能·单片机·c