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


一、项目概述
本方案实现 ESP32-S3 + ES7210(4 通道音频 ADC) 的完整录音链路:
- ESP32-S3 通过 I2C 初始化 ES7210,配置为 16kHz / 16bit / 4-slot TDM 采集;
- 连续采 5 秒 四通道交织音频数据,存入 PSRAM(640000 字节);
- 在设备内存中把交织数据切分成 4 个单声道通道,各自加上 44 字节 WAV 头,生成 4 个标准的单声道 WAV 文件(每个 160044 字节);
- 分 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 场景保留的。
七、部署与使用步骤
-
取得电脑局域网 IP
Windows:命令行执行
ipconfig,找到当前 WiFi 网卡的 IPv4 地址 (本方案示例192.168.31.68),填入 ESP32 固件顶部的SERVER_IP。 -
启动服务器(电脑上)
bashcd 存放 es7210_wifi_server.py 的目录 python es7210_wifi_server.py 8080首次运行 Windows 防火墙弹窗请选择"允许访问"(专用网络)。
服务器会在脚本同目录自动创建
received/文件夹。 -
烧录 ESP32 固件
用 Arduino IDE 打开
es7210_4ch_mic_record.ino,确认板型(ESP32S3 Dev Module)、PSRAM 已开启,修改顶部的 WiFi 账号密码与SERVER_IP,编译烧录。 -
观察串口 (波特率 115200)
启动后依次打印:WiFi 连接 → I2S 使能 → 内存分配 → 录音 5 秒 → 四通道能量诊断 → 寄存器回读 → 4 次上传进度。全部完成后板载 WS2812 进入呼吸灯状态。
-
验收
- 服务器窗口出现 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 本工程关键调试记录(供参考)
- 最初配置
0x12 = 0x01(TDM DSP/PCM)时,四通道能量显示 ch2、ch4 恒静音; - 用寄存器回读确认
0x12确实写入成功 → 排除 I2C 写入失败; - 对照 ES7210 数据手册 Figure 2e/2g:DSP 模式帧序 CH1...CH4,I2S/LJ 模式帧序 CH1、CH3、CH2、CH4,且 ESP32 侧 Philips TDM 与 ES7210 的 I2S/LJ 布局才能正确匹配;
- 将
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(无填充)。