到底能录多久?我第一次算这笔账的时候愣了一下: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引脚 | 接到哪里 | 说明 |
|---|---|---|
| VDD | 3.3V | 供电,注意电源质量 |
| GND | GND | 共地 |
| SCK | GPIO4 | I2S位时钟BCLK |
| WS | GPIO5 | I2S字选择LRCLK |
| SD | GPIO6 | I2S串行数据输出 |
| L/R | GND | 接地表示左声道输出 |
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应用区各3MB,SPIFFS分了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_count=8、dma_buf_len=64这个组合对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分区录音时长 |
|---|---|---|---|
| 8000Hz | 16bit单声道 | 16KB/s | 约256秒 |
| 16000Hz | 16bit单声道 | 32KB/s | 约128秒 |
| 22050Hz | 16bit单声道 | 44.1KB/s | 约93秒 |
| 44100Hz | 16bit单声道 | 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文件头这三个点做到扎实,这个项目就成功了大半。