ESP32-S3与INMP441迷你录音笔实战:I2S音频采集与SPIFFS存储

发布时间:2026/10/3 3:17:45
ESP32-S3与INMP441迷你录音笔实战:I2S音频采集与SPIFFS存储 到底能录多久我第一次算这笔账的时候愣了一下16kHz采样率、16bit单声道每秒就是32KB数据而一个2MB的SPIFFS分区满打满算只能录65秒。也就是说用ESP32-S3加INMP441做录音笔大多数人第一版跑通后都会发现不是不能录是录不久。但录不久不等于没有价值。这个组合做短时语音备忘、会议重点记录、声控触发录音模块完全够用。而且整个链路麻雀虽小五脏俱全I2S数字麦克风采集、DMA缓冲管理、WAV文件封装、SPIFFS文件系统写入每个环节都能拆出不少值得讲的东西。这篇文章我就把自己从零搭这套迷你录音笔的完整过程、踩过的坑、以及每个关键参数为什么这么设一次说清楚。1. 硬件连接INMP441的每个引脚都有脾气1.1 引脚接线与L/R声道选择INMP441是一颗I2S数字输出的MEMS麦克风它不像模拟麦克风那样需要运放和ADC而是直接把声波转成24bit数字信号通过I2S总线送给主控。这对ESP32-S3来说非常合适因为芯片内部自带I2S外设硬件上只需要接四根信号线。我先给出一份实测可用的接线表INMP441引脚接到哪里说明VDD3.3V供电注意电源质量GNDGND共地SCKGPIO4I2S位时钟BCLKWSGPIO5I2S字选择LRCLKSDGPIO6I2S串行数据输出L/RGND接地表示左声道输出L/R这个脚是最容易被忽略的。INMP441的SD引脚是时分复用的通过WS信号区分左右声道L/R接地时WS为低的半周期输出左声道数据L/R接3.3V时WS为高的半周期输出右声道数据。如果你在初始化I2S时设置了ONLY_LEFT但L/R却接了VDD那么你读到的数据实际上是右声道的结果就是录音全是噪声或者静音。我第一次调试时就犯了这个错焊完板子发现采集到的数据全是0x000000排查了半天最后用示波器看WS和SD的时序才反应过来。所以接线时务必确认L/R接地I2S配置为左声道。1.2 电源与布局面包板上的噪声教训INMP441对电源纹波比较敏感。ESP32-S3开发板上的3.3V是板载LDO输出的本身够用但如果用面包板加长杜邦线供电录制时会出现明显的周期性底噪听起来像是沙沙沙里夹杂着嗡嗡。这是因为I2S的BCLK频率很高数据线在长导线上像天线一样接收各种干扰同时如果电源线过长麦克风内部的偏置电路会受到数字电路开关噪声的耦合。我的处理方案供电线和数据线尽量短最好控制在10cm以内。在INMP441的VDD和GND之间靠近引脚位置加一个10uF钽电容并联一个0.1uF陶瓷电容。不要让INMP441贴着ESP32-S3的射频天线区域。如果发现爆音先怀疑DMA缓冲区配置而不是麦克风本身。这块板子我有一次把麦克风放在ESP32-S3的USB口旁边录音开头总有几个爆音后来移开2cm就正常了。这种玄学问题往往不是芯片坏而是布局干扰。2. VSCode环境搭建和那条最常见的编译报错2.1 PlatformIO项目的初始化配置开发环境我建议直接用VSCode加PlatformIO插件不要用Arduino IDE。不是说Arduino IDE不能用而是录音笔这种项目要改分区表、上传文件系统镜像、看串口日志PlatformIO的工程化管理会省很多事。新建项目时选好板子esp32-s3-devkitc-1框架选Arduino。PlatformIO会自动下载对应的平台和工具链。项目根目录的platformio.ini我最初是这样配置的[env:esp32-s3-devkitc-1] platform espressif32 board esp32-s3-devkitc-1 framework arduino monitor_speed 115200 board_build.filesystem spiffsboard_build.filesystem spiffs这一行很关键它告诉PlatformIO在构建文件系统镜像时使用SPIFFS格式。如果你用的是LittleFS就要写成littlefs。后面要上传文件系统镜像到开发板时PlatformIO会根据这个配置生成对应格式的镜像。2.2 spiffs was not declared 的三种可能原因与排查先说你可能会遇到的报错原文compilation error: spiffs was not declared in this scope这条报错我见过很多次也帮别人排查过。真正的原因一般不是库没装而是下面几种情况第一种大小写写错了。Arduino-ESP32核心里导出的全局对象叫SPIFFS全大写。如果你写的是spiffs.begin()或者spiffs.open()编译器找不到任何叫spiffs的标识符直接报这个错。网上很多教程代码是缩写的复制下来手一抖就改成了小写。解决办法很简单统一改成大写SPIFFS。第二种头文件没包含或者顺序不对。使用SPIFFS之前需要在代码文件顶部加上#include FS.h #include SPIFFS.h注意SPIFFS.h必须在FS.h之后引入。虽然大多数情况下编译器不强制这个顺序但如果你的代码里同时又用了SD、FFat之类的文件系统不按顺序引入可能造成重定义或类型冲突。养成习惯先FS.h再SPIFFS.h能省去很多莫名其妙的编译问题。第三种代码文件后缀问题。Arduino框架会把.ino文件自动处理成C来编译但如果你把主逻辑写在了.c文件里Arduino核心库中的C对象可能不参与编译或者因为混合编译规则不同导致符号找不到。录音笔这种项目建议整个工程都用.cpp文件不要混用.c和.cpp。PlatformIO对文件后缀很敏感我之前把一段录音逻辑写在.c里SPIFFS就是死活不能识别。排查时也可以先做一个最小复现新建一个空的.cpp文件只写两行#include SPIFFS.h void setup() { SPIFFS.begin(); }如果这个能编译过说明环境没问题问题在你的工程结构里。如果连这个都报错那就是板级支持包安装不完整重新部署PlatformIO的espressif32平台就好。2.3 分区表设置给SPIFFS划定真正的空间SPIFFS不是凭空出现的它需要Flash里有一个分区。ESP32-S3的Flash空间要同时放引导程序、固件、NVS配置、OTA备份和文件系统分区的划分由分区表决定。PlatformIO默认的分区表对录音笔这种场景往往不够用。我建议自定义分区表在项目目录下建一个partitions.csv# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app0, app, ota_0, 0x10000, 0x300000, app1, app, ota_1, 0x310000,0x300000, spiffs, data, spiffs, 0x610000,0x400000,这个分区表按8MB Flash设计的两个OTA应用区各3MBSPIFFS分了4MB。按16kHz采样率32KB/s算能录约128秒即两分多钟。如果Flash是16MB可以把SPIFFS继续加大。然后在platformio.ini里指定这个分区表board_build.flash_size 8MB board_build.partitions partitions.csv改完分区表之后一定要执行一次Upload Filesystem Image否则SPIFFS可能没有被格式化或者容量还是旧分区的大小。这一步很多人会忘结果代码里SPIFFS.begin(true)虽然返回true但实际可写入空间远小于预期。3. 音频采集链路I2S到底在采集什么3.1 I2S配置参数解读ESP32-S3的I2S外设工作在主机模式由它产生BCLK和LRCLK时钟INMP441作为从机输出数据。初始化I2S的代码可以这样写#include driver/i2s.h #include SPIFFS.h #include FS.h #define I2S_SCK 4 #define I2S_WS 5 #define I2S_SD 6 #define SAMPLE_RATE 16000 void setupI2S() { i2s_config_t i2s_config { .mode (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate SAMPLE_RATE, .bits_per_sample I2S_BITS_PER_SAMPLE_32BIT, .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 64, .use_apll false, .tx_desc_auto_clear false, .fixed_mclk 0 }; i2s_pin_config_t pin_config { .bck_io_num I2S_SCK, .ws_io_num I2S_WS, .data_out_num -1, .data_in_num I2S_SD }; i2s_driver_install(I2S_NUM_0, i2s_config, 0, NULL); i2s_set_pin(I2S_NUM_0, pin_config); }这里的参数每一个都有讲究。bits_per_sample我设置成32bit而不是24bit。INMP441虽然输出24bit数据但I2S外设读取时用32bit容器最方便。如果用16bit驱动底层会做截断反而损失精度。实测下来32bit容器读取后再做转换音质最好。dma_buf_count和dma_buf_len决定了DMA缓冲区的总大小。这两个值直接影响录音的稳定性和延迟。缓冲区太小CPU来不及搬数据就会溢出出现爆音缓冲区太大延迟增加而且占内存。经过反复测试dma_buf_count8、dma_buf_len64这个组合对16kHz采样率非常稳定一次DMA中断搬运的数据量足够CPU从容处理。I2S_CHANNEL_FMT_ONLY_LEFT配合前面说的L/R接地只采集左声道数据。如果配置成I2S_CHANNEL_FMT_RIGHT_LEFT你会拿到交错的双声道数据对单麦克风来说浪费一半带宽。I2S_COMM_FORMAT_STAND_I2S是标准的I2S Philips格式。INMP441支持这个格式不需要用I2S_COMM_FORMAT_STAND_MSB之类的变体。3.2 从24位原始数据到16位PCM样本每次从I2S读取我们会得到一个32位的整数但有效的音频数据只有高24位。INMP441采用左对齐输出也就是说在32位容器中最高位是符号位数据位占据bit31到bit8低8位补零。所以把24位有符号数据转成16位PCM无脑右移16位就对了int32_t raw 0; size_t bytes_read 0; esp_err_t err i2s_read(I2S_NUM_0, raw, sizeof(raw), bytes_read, portMAX_DELAY); if (err ESP_OK bytes_read sizeof(raw)) { int16_t pcm (int16_t)(raw 16); }右移16位后低16位正好是最高的16位有效数据。这里有一个常见误区有人会把数据右移8位得到24位结果然后强制塞进16位变量这样绝对值超过32767时会发生溢出音频听起来就是刺耳的爆音加削波。如果你觉得录音整体声音偏小可以右移14位来放大4倍但要注意过载时削波很严重。建议先按16位右移录一段用音频软件看波形再决定要不要调增益。i2s_read是阻塞调用portMAX_DELAY表示一直等到有数据才返回。这个阻塞特性正好符合录音逻辑录音不结束主循环就一直在从麦克风往SPIFFS写数据。如果中间还需要处理按钮事件可以考虑把i2s_read改成固定超时比如pdMS_TO_TICKS(100)避免长时间卡住。3.3 WAV文件头如何处理PCM裸数据不能直接播放得封装成WAV格式。WAV文件头是固定的44字节里面包含了采样率、位深、声道数以及数据区长度。录音开始时文件头里的数据长度未知先写一个占位版本void writeWavHeader(File f, int sampleRate, int bitsPerSample, int channels) { uint32_t dataSize 0; uint32_t byteRate sampleRate * bitsPerSample / 8 * channels; uint8_t header[44]; memcpy(header, RIFF, 4); memcpy(header 4, dataSize, 4); // 整个文件大小先填0 memcpy(header 8, WAVE, 4); memcpy(header 12, fmt , 4); uint32_t fmtSize 16; memcpy(header 16, fmtSize, 4); uint16_t audioFormat 1; // PCM格式 memcpy(header 20, audioFormat, 2); memcpy(header 22, channels, 2); memcpy(header 24, sampleRate, 4); memcpy(header 28, byteRate, 4); uint16_t blockAlign bitsPerSample / 8 * channels; memcpy(header 32, blockAlign, 2); uint16_t bitsPerSampleCopy bitsPerSample; memcpy(header 34, bitsPerSampleCopy, 2); memcpy(header 36, data, 4); memcpy(header 40, dataSize, 4); // 数据区大小先填0 f.write(header, 44); }录音结束后需要回到文件开头把真实的文件大小和数据区大小填回去void finalizeWavHeader(File f) { uint32_t fileSize f.size(); uint32_t dataSize fileSize - 44; f.seek(4); uint32_t riffSize fileSize - 8; f.write((uint8_t*)riffSize, 4); f.seek(40); f.write((uint8_t*)dataSize, 4); }这里有个非常重要的经验SPIFFS支持seek和覆盖写入但性能并不好。我实测在录音过程中频繁seek会拖慢写入速度甚至导致录音卡顿。正确做法是录音开始时写一次44字节占位头然后一直顺序追加音频数据等按下停止键那一刻才seek回文件头填长度。整个过程只seek两次数据不会乱速度也快。4. SPIFFS写入与容量规划4.1 能录多久的账要算清楚我先给出一张不同采样率/位深组合下的容量对照表假设SPIFFS分区为4MB采样率位深每秒数据量4MB分区录音时长8000Hz16bit单声道16KB/s约256秒16000Hz16bit单声道32KB/s约128秒22050Hz16bit单声道44.1KB/s约93秒44100Hz16bit单声道88.2KB/s约47秒语音识别和语音备忘推荐16kHz因为人的语音主要能量集中在4kHz以下16kHz采样率绰绰有余。如果你只是记录会议重点不需要高保真音乐那么16kHz、16bit、单声道是性价比最高的组合。如果你觉得128秒还是太短两条路一是加大SPIFFS分区比如在16MB Flash上分出12MB给SPIFFS能录约6分40秒二是降低采样率到8kHz但音质会有明显损失听感像电话音不适合作为通用录音笔参数。4.2 写入策略与掉电恢复SPIFFS是一个为嵌入式Flash设计的文件系统它有几个特性会直接影响录音实现。首先是写入块大小。每次调用file.write()都有固定开销。如果每次只写2字节PCM样本写满4MB分区需要调用约200万次函数这会让录音循环慢得离谱。实测单次写2字节和单次写1024字节相比同样数据量耗时相差几十倍。所以录音循环里不要一次一采样应该先把PCM数据攒到一个缓冲区每积累1024字节再写一次。其次是写入失败检测。SPIFFS写满之后不会自动报错write函数可能返回短写或者直接失败。录音代码里必须检查返回值size_t written recordFile.write(buffer, bytesToWrite); if (written ! bytesToWrite) { Serial.println(SPIFFS write failed); break; }如果忽略返回值文件写坏了也不知道最后导出来的WAV可能只有半截声音。然后是掉电保护。这个坑对录音笔来说最致命录音过程中直接断电SPIFFS经常会出现文件系统损坏之前录的所有文件全部丢失。SPIFFS本身没有日志结构写操作中途断电很容易破坏元数据。我的做法是折中的每次录音开始前把要写的新文件名固定为/rec.wav先把旧文件删掉再创建新文件。这样即使录音损坏也只损失当前这一次的录音不会影响之前已经导出的文件。如果你需要保存多条录音可以给文件加时间戳命名但要注意SPIFFS不支持目录文件名要扁平化。异常掉电后重新上电先做一次完整性检查如果SPIFFS.begin(true)的第二个参数format_if_failed设为true它会在挂载失败时自动格式化。这个参数极其有用但同时意味着异常掉电后SPIFFS可能被格式化旧文件全没了。对于单文件录音笔方案来说这是可以接受的反正真正的录音应该及时导出设备本身不是冷备份仓库。4.3 完整录音主循环示例把前面所有片段拼起来一个能跑的录音循环大概长这样#include Arduino.h #include FS.h #include SPIFFS.h #include driver/i2s.h #define I2S_SCK 4 #define I2S_WS 5 #define I2S_SD 6 #define SAMPLE_RATE 16000 File recordFile; const char* RECORD_PATH /rec.wav; void setup() { Serial.begin(115200); Serial.println(ESP32-S3 recorder starting...); if (!SPIFFS.begin(true)) { Serial.println(SPIFFS mount failed); return; } SPIFFS.remove(RECORD_PATH); setupI2S(); recordFile SPIFFS.open(RECORD_PATH, FILE_WRITE); if (!recordFile) { Serial.println(Cannot create record file); return; } writeWavHeader(recordFile, SAMPLE_RATE, 16, 1); Serial.println(Recording...); } void loop() { int32_t raw 0; size_t bytes_read 0; static uint8_t pcmBuffer[1024]; static size_t bufferLen 0; esp_err_t err i2s_read(I2S_NUM_0, raw, sizeof(raw), bytes_read, portMAX_DELAY); if (err ! ESP_OK || bytes_read ! sizeof(raw)) { return; } int16_t pcm (int16_t)(raw 16); memcpy(pcmBuffer bufferLen, pcm, sizeof(pcm)); bufferLen sizeof(pcm); if (bufferLen sizeof(pcmBuffer)) { size_t written recordFile.write(pcmBuffer, bufferLen); if (written ! bufferLen) { Serial.println(write failed); delay(100); ESP.restart(); } bufferLen 0; } }停止录音的逻辑我用一个简单的方式串口收到s就停止方便测试时用串口监视器控制。if (Serial.available() Serial.read() s) { if (bufferLen 0) { recordFile.write(pcmBuffer, bufferLen); bufferLen 0; } finalizeWavHeader(recordFile); recordFile.close(); Serial.println(Recording stopped, file saved.); while (1) { delay(1000); } }5. 实测表现与更实用的进阶玩法5.1 音质测试结果我录制了一段距离麦克风约30cm的人声回放时感觉清晰度接近手机录音的通话质量但有两个明显差异一是环境底噪偏高在安静房间里也能听到轻微的白噪声二是声音偏干没有手机录音那种经过降噪和AGC处理的柔和感。底噪来自INMP441自身特性和电源质量想要改善可以从三方面入手给麦克风单独用低噪声LDO供电在SPIFFS写入时减少DMA中断对采样的干扰以及录完后做一次软件高通滤波滤掉100Hz以下的低频噪声。ESP32-S3的算力跑一个简单的IIR高通滤波完全没压力但会增加代码复杂度我建议第一版先不做确认链路通了再优化。5.2 功耗和续航录音场景下ESP32-S3的电流实测大约在35mA到45mA之间比我预想的低不少。因为录音时不开WiFi、不开蓝牙CPU虽然频繁被DMA中断唤醒但大部分时间在等待数据写入主频可以降到80MHz运行。用一块500mAh的锂电池供电理论上能连续录音三个多小时足够覆盖大多数会议场景。如果你打算做便携设备有一个省电技巧录音过程中调用WiFi.mode(WIFI_OFF)关闭射频避免无线模块的周期性唤醒电流。SPIFFS写入时CPU频率不要降太低否则写文件的速度跟不上音频数据产生速度会导致缓冲区溢出。5.3 向更长录音扩展如果试完这套方案觉得两分钟不够用下一步建议不要盲目加大Flash容量而是先做ADPCM压缩。IMA-ADPCM能把16bit PCM压缩到4bit压缩比4:1音质对语音来说损失很小4MB分区能录约8分半钟。ESP32-S3的算力跑ADPCM编码绰绰有余编码逻辑也不复杂网上有很多开源实现可以直接移植。另一种更实用的方案是外接MicroSD卡模块通过SDMMC接口读写的速度远超SPIFFS容量可以做到GB级别。SD卡方案适合做真正的长时间录音笔但硬件连接多几根线代码里文件系统操作也要换成SD库。说实话SPIFFS方案定位就是短时语音记录等硬件跑通了你自然会知道该不该往SD卡方向走。如果你跟我一样需要的是一个按下就录、再按就存的独立小模块ESP32-S3加INMP441加SPIFFS这套组合是目前成本和开发速度上最顺的一条路。整个过程最值得记住的一句话是先把数据链路的每一环参数搞明白再谈优化。I2S配置、SPIFFS写入粒度、WAV文件头这三个点做到扎实这个项目就成功了大半。