ESP32-S3 IDF音频开发:I2S数字麦克风与MAX98357录放音
上一篇记录了 ESP32-S3 的 ESP-IDF 入门,包括环境搭建、hello world、CMake、烧录、串口监视以及 USB-JTAG 调试。这一次开始正式进入音频开发,目标是把板载 I2S 数字麦克风和 MAX98357 I2S 功放串起来,完成一个最小但完整的音频闭环:按音量-录音,按音量+播放录音。

这篇笔记重点记录今天对 I2S、数字麦克风、MAX98357、PCM 以及 ESP-IDF 新版 I2S 驱动的理解和实践过程。最终工程放在:projects/01_max98357_play、projects/02_i2s_mic。
一、今天完成了什么
今天主要完成了三个阶段:
- 用 MAX98357 播放声音,理解 ESP32-S3 如何通过 I2S 输出 PCM。
- 用板载 I2S 数字麦克风读取声音,观察原始采样数据和音量变化。
- 结合麦克风与 MAX98357,实现单次录音 + 播放录音。
最终交互方式如下:
| 操作 | GPIO | 功能 |
|---|---|---|
| 音量- | GPIO39 | 开始录音 |
| 音量+ | GPIO40 | 播放录音 |
录音参数:
| 参数 | 值 |
|---|---|
| 采样率 | 16 kHz |
| 声道 | mono |
| 采样格式 | signed 16-bit PCM |
| 录音时长 | 3 秒 |
| 存储位置 | RAM |
3 秒录音的数据量:
16000 samples/s * 3s * 2 bytes = 96000 bytes
也就是约 96KB,ESP32-S3-N16R8 放在静态 RAM 里可以接受。
二、I2S 和 MAX98357 的关系
一开始最容易混淆的是:I2S 和 MAX98357 到底是什么关系?
我的理解是:
PCM 是音频数据
I2S 是传输 PCM 的总线/协议
MAX98357 是接收 I2S 数据并驱动喇叭的 DAC + 功放芯片
更具体一点:
ESP32-S3 --I2S--> MAX98357 --> 喇叭
ESP32-S3 负责输出数字音频,MAX98357 负责把数字音频转换为模拟信号并放大。MAX98357 本身不会“理解 MP3”,也不会自己解码音乐,它只接收 I2S 上连续传来的 PCM 采样。
所以播放链路可以拆成:
PCM sample -> I2S BCLK/LRCLK/DOUT -> MAX98357 -> speaker
其中 MAX98357 使用的三个关键引脚是:
| MAX98357 侧 | ESP32-S3 侧 | 作用 |
|---|---|---|
| BCLK | GPIO15 | 位时钟 |
| LRCLK | GPIO16 | 左右声道时钟 |
| DIN | GPIO7 | 音频数据输入 |
代码中对应:
#define AMP_BCLK_GPIO GPIO_NUM_15
#define AMP_LRCLK_GPIO GPIO_NUM_16
#define AMP_DOUT_GPIO GPIO_NUM_7
注意这里名字容易反:MAX98357 引脚叫 DIN,因为从功放角度看它是 data in;但从 ESP32-S3 角度看,这是 data out,所以在 ESP-IDF I2S 配置中要填到 .dout。
三、I2S 数字麦克风的关系
麦克风链路和播放链路刚好反过来:
声音 -> 数字麦克风 -> I2S -> ESP32-S3
板载数字麦克风的三个关键引脚是:
| 麦克风侧 | ESP32-S3 侧 | 作用 |
|---|---|---|
| WS / LR | GPIO4 | 左右声道选择时钟 |
| SCK / BCLK | GPIO5 | 位时钟 |
| SD | GPIO6 | 麦克风数据输出 |
代码中对应:
#define MIC_WS_GPIO GPIO_NUM_4
#define MIC_BCLK_GPIO GPIO_NUM_5
#define MIC_DIN_GPIO GPIO_NUM_6
这里 ESP32-S3 仍然是 I2S master,负责产生 WS 和 BCLK。数字麦克风根据这些时钟,把采样数据从 SD 线上送给 ESP32-S3。
四、什么是 I2S 通道
ESP-IDF 新版 I2S 驱动里会经常看到“channel”这个词。这里的 I2S 通道不是音频里的“左声道/右声道”,而是 ESP-IDF 驱动层管理 I2S 外设的一种对象。
例如:
static i2s_chan_handle_t s_mic_rx_chan;
static i2s_chan_handle_t s_amp_tx_chan;
这里有两个 I2S channel:
| 变量 | 方向 | 作用 |
|---|---|---|
s_mic_rx_chan | RX | 从数字麦克风读音频 |
s_amp_tx_chan | TX | 向 MAX98357 写音频 |
也就是说:
I2S RX channel:ESP32-S3 收音频
I2S TX channel:ESP32-S3 发音频
它们和左右声道不是一个概念。左右声道属于 I2S 音频帧里的 slot,而 RX/TX channel 是驱动层的收发对象。
五、为什么麦克风要按 stereo 读取
板子上只有一颗数字麦克风,看起来应该是 mono,但代码里一开始仍然用了 stereo 读取:
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(
MIC_SLOT_BIT_WIDTH,
I2S_SLOT_MODE_STEREO
)
原因是很多 I2S 数字麦克风虽然只有一颗,但仍然会根据 LR 引脚选择把数据放在 left slot 或 right slot。
实际诊断时发现:
L slot 有原始采样数据
R slot 一直是 0x00000000
所以最终确认这块板载数字麦克风的数据在 left slot:
#define MIC_ACTIVE_SLOT 0
读取时就从交错 buffer 中取 left slot:
int32_t raw_sample = s_mic_i2s_samples[i * SLOTS_PER_FRAME + MIC_ACTIVE_SLOT];
stereo buffer 的排列方式是:
samples[0] = 第 0 帧 left
samples[1] = 第 0 帧 right
samples[2] = 第 1 帧 left
samples[3] = 第 1 帧 right
六、从麦克风 raw 数据转成 PCM16
I2S 数字麦克风常见输出是 24-bit 有效数据,放在 32-bit slot 里传输。ESP32-S3 读到的是 int32_t,为了保存和播放方便,需要转成 int16_t PCM。
代码里这样处理:
static int16_t mic_raw_to_pcm16(int32_t raw_sample)
{
int32_t sample = (raw_sample >> 16) * MIC_RECORD_GAIN;
return clamp_i16(sample);
}
这里有三个点:
raw_sample >> 16:把 32-bit 容器里的高位有效数据缩到近似 16-bit。MIC_RECORD_GAIN:录音增益,让回放更容易听见。clamp_i16:防止放大后超过int16_t范围导致溢出。
int16_t 的范围是:
-32768 ~ 32767
所以必须做限幅:
static int16_t clamp_i16(int32_t value)
{
if (value > INT16_MAX) {
return INT16_MAX;
}
if (value < INT16_MIN) {
return INT16_MIN;
}
return (int16_t)value;
}
七、录音流程
录音时,程序不断从 I2S RX channel 读取麦克风数据:
i2s_channel_read(
s_mic_rx_chan,
s_mic_i2s_samples,
sizeof(s_mic_i2s_samples),
&bytes_read,
portMAX_DELAY
);
然后把每一帧的有效 slot 转成 16-bit mono PCM,保存到 RAM:
s_recorded_pcm[s_recorded_sample_count++] = mic_raw_to_pcm16(raw_sample);
录音缓存:
static int16_t s_recorded_pcm[RECORD_SAMPLE_COUNT];
这就是一段裸 PCM 数据。它没有 WAV 头,也没有 MP3 压缩,只是连续的 signed 16-bit little-endian mono sample。
八、播放流程
MAX98357 播放侧配置为 16-bit stereo I2S:
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(
AMP_SLOT_BIT_WIDTH,
I2S_SLOT_MODE_STEREO
)
但录音数据是 mono,所以播放时把同一个 sample 写到左右声道:
s_play_stereo_samples[i * SLOTS_PER_FRAME] = sample;
s_play_stereo_samples[i * SLOTS_PER_FRAME + 1] = sample;
然后写给 I2S TX channel:
i2s_channel_write(
s_amp_tx_chan,
s_play_stereo_samples,
frames_now * SLOTS_PER_FRAME * sizeof(s_play_stereo_samples[0]),
&bytes_written,
portMAX_DELAY
);
播放结束后还写了一小段静音:
write_silence(PLAY_WRITE_FRAMES * 2);
这样可以避免 MAX98357 保持最后一个采样值,造成轻微杂音。
九、按键控制
按键使用内部上拉:
.pull_up_en = GPIO_PULLUP_ENABLE
所以状态是:
未按下:GPIO = 1
按下:GPIO = 0
代码里定义:
#define BUTTON_PRESSED_LEVEL 0
主循环逻辑很直接:
if (is_button_pressed(RECORD_BUTTON_GPIO)) {
wait_button_release(RECORD_BUTTON_GPIO);
record_once();
}
if (is_button_pressed(PLAY_BUTTON_GPIO)) {
wait_button_release(PLAY_BUTTON_GPIO);
play_recording();
}
wait_button_release 做了一个简单防抖,避免一次按下触发多次。
十、今天踩到的坑
10.1 音量+ 按键不是 GPIO38
一开始根据原理图推测音量+ 是 GPIO38,但实测 GPIO38 一直没有变化。后来通过打印 GPIO 电平发现:
音量+ = GPIO40
音量- = GPIO39
所以实际代码使用:
#define PLAY_BUTTON_GPIO GPIO_NUM_40
#define RECORD_BUTTON_GPIO GPIO_NUM_39
10.2 I2S 麦克风数据不一定在 mono 默认槽位
一开始直接按 mono 读取,串口上几乎都是 0。后来改成 stereo 诊断,同时观察 left/right,才确认:
left slot 有数据
right slot 为 0
所以调 I2S 麦克风时,先做左右槽位诊断很有必要。
10.3 I2S 缓冲区不要放在 app_main 栈上
调试 stereo 诊断版时,曾经遇到:
stack overflow in task main
原因是 I2S 缓冲区太大,放在 app_main 局部变量里压爆了 main 任务栈。
修复方式是把大缓冲区放到静态全局区:
static int32_t s_mic_i2s_samples[MIC_READ_FRAMES * SLOTS_PER_FRAME];
static int16_t s_play_stereo_samples[PLAY_WRITE_FRAMES * SLOTS_PER_FRAME];
static int16_t s_recorded_pcm[RECORD_SAMPLE_COUNT];
这是嵌入式开发里非常重要的习惯:大块内存要明确放在哪里,不要随手塞进栈里。
10.4 两个 I2S 通道会占用不同控制器
启动时看到过类似日志:
i2s controller 0 has been occupied by i2s_driver
这不是错误,而是提示第一个 I2S 控制器已经被麦克风 RX 使用,驱动会继续给 MAX98357 TX 分配其他可用控制器。
十一、调试命令
进入工程目录:
cd projects/02_i2s_mic
编译:
idf.py build
烧录并监视:
idf.py -p /dev/cu.usbserial-10 flash monitor
退出串口监视:
Ctrl + ]
启动成功后会看到:
single record/play demo ready: MIC WS=4 BCLK=5 DIN=6 | AMP BCLK=15 LRCLK=16 DIN=7 | VOL-=record GPIO39 VOL+=play GPIO40
然后:
- 按音量-,开始录音。
- 对着板载数字麦克风说话,等待 3 秒。
- 按音量+,通过 MAX98357 和喇叭播放刚才录到的声音。
十二、今天的阶段性理解
今天最大的收获是把抽象的音频链路跑通了:
声音
↓
I2S 数字麦克风
↓
ESP32-S3 I2S RX
↓
mono s16le PCM in RAM
↓
ESP32-S3 I2S TX
↓
MAX98357
↓
喇叭
这条链路虽然还很简单,但它已经是后续接入实时 AI 对话模型的基础。因为实时语音对话本质上也离不开两条流:
上行:麦克风 PCM -> 网络 -> AI 模型
下行:AI 返回音频 PCM/Opus -> ESP32-S3 -> MAX98357
后面可以继续做几个 demo:
- 把录音保存成 WAV,方便在电脑上听和分析。
- 增加 VAD,检测什么时候有人说话。
- 把 PCM 分片通过 WebSocket 发到服务端。
- 接收服务端返回的音频流并实时播放。
- 最终做成全双工实时语音对话。
今天这个“单次录音 + 播放录音”就是向实时对话迈出的第一步。