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。

一、今天完成了什么

今天主要完成了三个阶段:

  1. 用 MAX98357 播放声音,理解 ESP32-S3 如何通过 I2S 输出 PCM。
  2. 用板载 I2S 数字麦克风读取声音,观察原始采样数据和音量变化。
  3. 结合麦克风与 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 侧作用
BCLKGPIO15位时钟
LRCLKGPIO16左右声道时钟
DINGPIO7音频数据输入

代码中对应:

#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 / LRGPIO4左右声道选择时钟
SCK / BCLKGPIO5位时钟
SDGPIO6麦克风数据输出

代码中对应:

#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,负责产生 WSBCLK。数字麦克风根据这些时钟,把采样数据从 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_chanRX从数字麦克风读音频
s_amp_tx_chanTX向 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);
}

这里有三个点:

  1. raw_sample >> 16:把 32-bit 容器里的高位有效数据缩到近似 16-bit。
  2. MIC_RECORD_GAIN:录音增益,让回放更容易听见。
  3. 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

然后:

  1. 按音量-,开始录音。
  2. 对着板载数字麦克风说话,等待 3 秒。
  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:

  1. 把录音保存成 WAV,方便在电脑上听和分析。
  2. 增加 VAD,检测什么时候有人说话。
  3. 把 PCM 分片通过 WebSocket 发到服务端。
  4. 接收服务端返回的音频流并实时播放。
  5. 最终做成全双工实时语音对话。

今天这个“单次录音 + 播放录音”就是向实时对话迈出的第一步。