ESP32 实战教程:用 I2S 麦克风采集并保存 WAV 音频

这篇不做“音量条跳一跳”就结束,而是认真录一段能下载、能播放、能分析的声音。项目会把 I2S 的三根线、DMA 缓冲、PCM 数据和 WAV 文件头串起来。录音过程中,ESP32 终于有机会把“我听到了”变成一个电脑真正认识的文件。

I2S 麦克风录音与 WAV 文件结构

一、为什么选择数字 I2S 麦克风

INMP441、SPH0645 这类麦克风内部已经完成了模拟放大和模数转换,输出的是 I2S 数字音频。相比模拟麦克风加 ADC,I2S 方案少受模拟电源噪声、ADC 参考电压和地线布局影响,数据也更容易直接送入 DMA。

当然,数字并不等于“插上就有声音”。I2S 至少包含 BCLK、WS/LRCLK 和 DIN 三根线,ESP32 作为主机时负责产生时钟。INMP441 的 L/R 选择脚决定它输出左声道还是右声道,如果软件读取了另一个槽,就会得到一串接近安静的数字。这个坑特别有教育意义,因为它告诉我们:协议正确,不代表数据一定在你以为的位置。

二、接线和供电注意事项

以 INMP441 为例,常见接线如下(GPIO 可以根据板子调整):

  • SCK/BCLK -> GPIO 26;
  • WS/LRCLK -> GPIO 25;
  • SD -> GPIO 33;
  • VDD -> 3.3 V;
  • GND -> 共地;
  • L/R -> GND 或 3.3 V,决定左/右槽。

麦克风供电一定以模块规格为准。长杜邦线、USB 线材和 SD 卡同时工作时,电源噪声会让录音出现“电流声”和周期性咔哒声。布线时把数字麦克风和 SD 卡的地线回路做短,必要时在电源旁加去耦电容。

三、Arduino Core 3.x 的 I2S 初始化

当前 Arduino-ESP32 的推荐接口是 ESP_I2S.h 中的 I2SClass,可以先设置引脚,再调用 begin()。下面示例采用 16 kHz、16 bit、单声道接收,适合语音记录和简单声音检测。

#include <Arduino.h>
#include <ESP_I2S.h>

I2SClass I2S;
constexpr int PIN_BCLK = 26;
constexpr int PIN_WS   = 25;
constexpr int PIN_DIN  = 33;

void setupI2S() {
  I2S.setPins(PIN_BCLK, PIN_WS, -1, PIN_DIN);
  bool ok = I2S.begin(I2S_MODE_STD, 16000,
                      I2S_DATA_BIT_WIDTH_16BIT,
                      I2S_SLOT_MODE_MONO);
  if (!ok) {
    Serial.println("I2S begin failed");
    while (true) delay(1000);
  }
}

void setup() {
  Serial.begin(115200);
  setupI2S();
}

void loop() {
  int available = I2S.available();
  if (available > 0) {
    uint8_t buffer[512];
    size_t n = I2S.readBytes((char*)buffer, min(available, 512));
    Serial.printf("read %u bytes\\n", (unsigned)n);
  }
}

旧教程中常见 driver/i2s.h、i2s_driver_install() 等写法,在旧版 Arduino Core 和 ESP-IDF 项目里仍然有价值,但不能和新的 I2SClass 接口混用。升级开发环境时,先确认头文件和示例对应同一套 API。原版 ESP32 的 16 位单声道还存在硬件槽位限制,Arduino 库会在底层做兼容转换,但这也是为什么文档强调要确认声道模式。

四、WAV 文件到底是什么

WAV 不是一种神秘的“音频格式”,最常见的是 RIFF 容器加 PCM 裸数据。文件开头 44 字节描述采样率、位宽、通道数和数据长度,后面紧跟一串 little-endian 的采样值。

RIFF + fileSize + WAVE
fmt  + 16       + PCM 参数
data + dataSize + PCM 样本

录音开始时我们还不知道最终文件有多大,所以先写一个占位头;录音结束后回到文件开头,把 fileSize 和 dataSize 回填。这个过程很像先写发票抬头,最后数清楚购物车里有几件东西再填总价。

#include <FS.h>

void writeLE16(File& f, uint16_t v) {
  uint8_t b[2] = {(uint8_t)(v & 0xff), (uint8_t)(v >> 8)};
  f.write(b, 2);
}

void writeLE32(File& f, uint32_t v) {
  uint8_t b[4] = {(uint8_t)(v & 0xff), (uint8_t)(v >> 8),
                  (uint8_t)(v >> 16), (uint8_t)(v >> 24)};
  f.write(b, 4);
}

void writeWavHeader(File& f, uint32_t sampleRate,
                    uint16_t bits, uint16_t channels,
                    uint32_t dataSize) {
  f.seek(0);
  f.write((const uint8_t*)"RIFF", 4);
  writeLE32(f, 36 + dataSize);
  f.write((const uint8_t*)"WAVEfmt ", 8);
  writeLE32(f, 16);              // PCM fmt chunk size
  writeLE16(f, 1);               // PCM
  writeLE16(f, channels);
  writeLE32(f, sampleRate);
  writeLE32(f, sampleRate * channels * bits / 8);
  writeLE16(f, channels * bits / 8);
  writeLE16(f, bits);
  f.write((const uint8_t*)"data", 4);
  writeLE32(f, dataSize);
}

五、录音循环:按块读、按块写

录音时不要每读两个字节就 File.write() 一次,那会放大文件系统开销。让 I2S DMA 一次准备 512 或 1024 字节,程序批量写入,既减少调用次数,也能让任务调度更平稳。

WAV 录音的关键步骤

File wav;
uint32_t dataBytes = 0;
const uint32_t MAX_BYTES = 16000UL * 2 * 10; // 10 秒单声道 16 bit

void recordWav(fs::FS& fs, const char* path) {
  wav = fs.open(path, FILE_WRITE);
  if (!wav) return;

  writeWavHeader(wav, 16000, 16, 1, 0);
  uint8_t buffer[1024];
  uint32_t start = millis();
  dataBytes = 0;
  while (millis() - start < 10000 && dataBytes < MAX_BYTES) {
    int available = I2S.available();
    if (available <= 0) { delay(1); continue; }
    size_t want = min((int)sizeof(buffer), available);
    size_t got = I2S.readBytes((char*)buffer, want);
    if (got == 0) continue;
    wav.write(buffer, got);
    dataBytes += got;
  }
  writeWavHeader(wav, 16000, 16, 1, dataBytes);
  wav.close();
}

示例中使用 fs::FS,可以接 LittleFS,也可以接 SD。LittleFS 适合保存短录音和配置,SD 卡适合多段长录音。无论选择哪种介质,都要检查剩余空间,并给文件名加时间戳或递增编号,避免每次开机都覆盖同一段“最新录音”。

六、如何判断录到的声音是否正常

第一步看文件能否被播放器打开;第二步看文件时长是否接近预期;第三步用 Audacity 或 ffplay 查看波形。完全平直通常是声道槽位或 DIN 接线错误,持续满幅则可能是位宽解析错误或数据没有右移。底噪很大时,先短接/遮住麦克风不是好方法,应检查电源、地线和 I2S 线长。

INMP441 常见的 24 bit 数据会放在 32 bit 槽内,软件最后取哪 16 位取决于驱动配置。如果把 32 位样本直接当成 16 位写入 WAV,播放器虽然能打开,听起来会像机器人在水里说话。建议先用串口打印几帧原始值,再确认有效位所在位置。

七、加一个网页下载录音

当录音已经保存到 LittleFS 或 SD,ESP32 可以提供一个简单的下载接口:设置正确的 audio/wav Content-Type,使用文件流式发送,不要把整段文件读进 String。录音按钮则负责开始任务,任务完成后在网页显示文件大小和下载链接。

如果还要同时采集摄像头视频,建议把 I2S 采集、JPEG 编码和 HTTP 流分成独立任务,并严格控制 DMA 缓冲和堆内存。摄像头项目里“声音能录,视频一开就重启”,往往不是麦克风坏了,而是多个高吞吐任务同时抢内存。

八、延伸实验和避坑总结

可以继续加入 VAD(语音活动检测)、按音量自动分段、录音前后各保留一秒缓冲、上传到 NAS、或把 WAV 转成 Opus 后再传输。学习时建议先固定 16 kHz/16 bit/mono 这一组参数,等文件链路稳定后再提高采样率;一次改变太多变量,排错会变成“每个变量都嫌疑很大”。

本项目的核心不是做出录音笔,而是理解一条完整数据链:I2S 时钟 -> DMA 缓冲 -> PCM 样本 -> WAV 头 -> 文件系统。链路上任何一环的单位不一致,最终就会表现为噪声、速度不对或文件无法播放。把每一环单独验证,声音自然会从“数字噪音”变成一段像样的录音。

参考资料

打赏

如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏码

打赏支持:王庆岩

posted on 2026-09-29 09:31  潇洒石头  阅读(36)  评论(0)    收藏  举报

导航