Skip to content

2.7 麦克风与喇叭 ​

这节要搞懂:声音怎么录进来、怎么放出去。 需要基础:2.6

本节两样都要用到

麦克风:语音命令(工程里的 voice_command_task) 喇叭:闹铃播放(3.7)

一、要买什么 ​

要干嘛用什么说明
语音输入LMD4030T261-OA1 MEMS 麦克风PDM 数字输出,只要两根线
放音NS4168 功放 + 小喇叭I2S 输入,自带 DAC + D 类功放

二、麦克风(PDM 数字麦克风) ​

项目里麦克风用来干嘛

天气时钟用它做语音命令 —— 工程里的 voice_command_task 就是处理这一路音频的。

底层的 PDM 读取封装在 nocodec_audio.c 里。

我们用的 LMD4030T261-OA1 是一颗 PDM 数字 MEMS 麦克风。

PDM 麦克风内部集成了 MEMS 振膜 + 模数转换电路,直接输出数字码流。它只需要两根信号线:一根时钟进去,一根数据出来。

用到的引脚(信号线只有 2 根,加上电源地一共 4 根):

麦克风ESP32-S3方向说明
VDD3.3V—供电
GNDGND—地
CLKGPIO3主机 → 麦克风时钟,由 ESP32 提供
DATA / DOUTGPIO42麦克风 → 主机1 位码流
SEL / L/RGND 或 3.3V—声道选择,见下

丝印不统一,认功能别认名字

你可能看到其实就是
CLK / PDM_CLK / SCK时钟线(ESP32 输出)
DATA / DOUT / SD / PDM_DAT数据线(麦克风输出)
SEL / L/R / LRSEL声道选择

只有 4 个焊盘的模块通常是 VDD / GND / CLK / DATA,声道已经在模块内部定死了。

声道选择脚怎么接

PDM 麦克风靠在时钟的哪个边沿输出数据来区分左右声道:

  • SEL 接 GND → 左声道(在时钟下降沿输出)
  • SEL 接 3.3V → 右声道(在时钟上升沿输出)

单麦克风场景接 GND 用左声道就行,代码里也要选左声道,两边必须一致。

代码:接收 PDM 麦克风 ​

c
#include "driver/i2s_pdm.h"

#define PDM_CLK     3
#define PDM_DIN    42

static i2s_chan_handle_t s_rx_chan;

void mic_init(void)
{
    // 1. 创建接收通道(PDM 只能用 I2S0)
    i2s_chan_config_t chan_config =
        I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
    i2s_new_channel(&chan_config, NULL, &s_rx_chan);

    // 2. 配置成 PDM 接收模式,16kHz / 16 位 / 单声道
    i2s_pdm_rx_config_t pdm_config = {
        .clk_cfg  = I2S_PDM_RX_CLK_DEFAULT_CONFIG(16000),
        .slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(
                        I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
        .gpio_cfg = {
            .clk = PDM_CLK,
            .din = PDM_DIN,
        },
    };
    i2s_channel_init_pdm_rx_mode(s_rx_chan, &pdm_config);
    i2s_channel_enable(s_rx_chan);
}

// 3. 读数据 —— 拿到的已经是 PCM,可以直接用
int16_t pcm_buffer[512];
size_t bytes_read;
i2s_channel_read(s_rx_chan, pcm_buffer, sizeof(pcm_buffer),
                 &bytes_read, portMAX_DELAY);

PDM 不用做位移

这是 PDM 相对 I2S 麦克风省事的地方 —— 驱动内部已经把 1 位码流滤波转换成 PCM 了,读出来直接就是 int16_t 的音频采样值。

I2S 麦克风(INMP441)那种"按 32 位读、再右移取高位"的操作,PDM 这边不需要。

录不到声音,查这三条

  1. CLK 和 DATA 接反了 —— CLK 是 ESP32 输出,DATA 是麦克风输出,方向不同不能对调
  2. SEL 脚悬空 —— 必须明确接 GND 或 3.3V,且和代码里的声道选择一致
  3. 通道用错 —— PDM 接收在 ESP32-S3 上只能用 I2S0,写成 I2S_NUM_1 初始化会失败

三、喇叭和功放(NS4168) ​

NS4168 是一颗 I2S 数字输入的 D 类音频功放。它内部集成了 DAC(数模转换)+ 功率放大两级,所以可以直接吃 I2S 数字信号,输出端直接接喇叭,中间不需要任何别的芯片。

打个比方:它是"数字信号进、声音出"的一体机 —— 你把音频数据推给它,喇叭就响了。

NS4168ESP32-S3 引脚说明
VDD3V3供电,电压越高音量越大
GNDGND地
CTRLGPIO38使能(关断控制)
BCLKGPIO46位时钟
LRC / LRCLK / WSGPIO9声道选择
SDATA / DINGPIO8音频数据
VON / VOP小喇叭两端10Ω

CTRL 是使能脚,不是数据线

NS4168 的 CTRL(有的丝印写 SD / EN)管的是开关,和 I2S 的三根线没关系:

  • 拉高 → 正常工作
  • 拉低 → 静音待机

本课程把它接到了 GPIO38,所以代码里要先把它拉高,喇叭才会响。想静音时不用停 I2S,把这个脚拉低就行,比改音量干净。

音量大就重启?

功放放大声时电流很大(3W 输出时瞬间可达数百 mA),USB 供电不够会把整块板子拉重启。

解决办法:

  1. 用能供 1A 以上的电源
  2. 功放的 VCC 直接从电源取,别从开发板的 3.3V 引脚取
  3. 电源和地之间并一个 470μF 以上的电解电容,吸收瞬间电流

NS4168 不需要 MCLK

和 MAX98357A 一样,NS4168 内部自己产生所需的时钟,只要 BCLK / WS / DIN 三根线就够。

所以代码里 .mclk = I2S_GPIO_UNUSED(2.6 第五节讲过)。

四、当前工程用它们做什么 ​

器件用途
麦克风voice_command_task 做声音检测 / 语音命令
喇叭播放闹铃提示音(3.7)、播放音频文件

麦克风采到的音频不落盘

voice_command_task 拿到音频是实时分析的 —— 判断有没有声音、是不是设定的那个命令,分析完就丢,不会存起来。

本节操作过程里那个"录 10 秒再放出来"的实验,是为了把两条链路一次跑通,不是工程里已有的功能。

本节要点 ​

  • 麦克风 LMD4030T261-OA1(PDM,2 根线)、功放 NS4168(I2S,3 根线),都不需要 MCLK
  • PDM 读出来直接是 PCM,不用像 I2S 麦克风那样做位移
  • PDM 的 SEL 脚必须明确接 GND 或 3.3V,不能悬空
  • 当前工程:麦克风做语音命令、喇叭放闹铃和音频
  • ⭐ 功放的 CTRL(GPIO38)要拉高才出声,拉低就是静音,比改音量干净
  • ⭐ 录音这种大块数据放 PSRAM:10 秒 16kHz 就要 313KB,片内 RAM 装不下
  • ⭐ PDM 收只能用 I2S0,功放发用 I2S1,两边错开互不干扰

操作过程 ​

目标:录 10 秒自己说的话,再原样放出来。

这个实验把这一节的两样东西串起来跑通了 —— 麦克风负责收,喇叭负责放,中间还要用到一块 300 多 KB 的内存来存录音。

建工程的流程每节都一样,见 2.2 第 2 步。工程名填 I2S,Target 选 esp32s3。

第 1 步:先算一笔内存账 ⭐ ​

录 10 秒要多大地方?

16000 采样/秒 × 10 秒 × 每个采样 2 字节 = 320,000 字节 ≈ 313 KB

ESP32-S3 片内 RAM 一共才 512KB,还要分给 WiFi、每个任务的栈、各种驱动缓冲。一口气申请 313KB 连续内存,基本不可能成功。

所以这个实验必须用 PSRAM。

什么是 PSRAM

PSRAM(Pseudo Static RAM,伪静态随机存储器)是芯片外部的一块扩展内存,通过 SPI 总线连到主控上。

打个比方:片内 RAM 是你的书桌 —— 小,但伸手就够得着;PSRAM 是旁边的书柜 —— 大得多,但每次取东西要多走两步。

片内 RAMPSRAM
容量512 KB2 ~ 8 MB
速度快慢一些(要走 SPI)
适合放任务栈、DMA 缓冲、频繁访问的变量大块数据:音频、图片、网页缓存

录音数据只是"存着等会儿再用",不怕慢,就怕放不下 —— 正是 PSRAM 的用武之地。

先确认你的模组带 PSRAM

PSRAM 不是所有 ESP32-S3 模组都有。型号后缀里带 R2 / R8(比如 N16R8)的才带,N16 这种不带 PSRAM 的开不出来。

拿不准就先按下面的步骤打开,烧录后看串口 —— 芯片在开机时会打印一行 PSRAM 的大小。

第 2 步:打开 PSRAM 支持 ​

PSRAM 默认是关闭的,要在工程配置里手动打开。

点 VS Code 底部状态栏的齿轮图标,打开 SDK 配置编辑器:

底部状态栏红框里的齿轮,就是 SDK 配置编辑器(menuconfig 的图形版)
底部状态栏红框里的齿轮,就是 SDK 配置编辑器(menuconfig 的图形版)

在搜索框里输入 PSRAM,找到 Component config → ESP PSRAM,把 Support for external, SPI-connected RAM 勾上:

搜 PSRAM,勾上「Support for external, SPI-connected RAM」—— 这是总开关
搜 PSRAM,勾上「Support for external, SPI-connected RAM」—— 这是总开关

勾上之后,下面会展开一大堆原本藏着的选项。只要改一个:Mode (QUAD/OCT) of SPI RAM chip in use,按你模组的实际型号选:

模组是 8 线 PSRAM(型号带 R8)就选 Octal Mode PSRAM,4 线(R2)选 Quad Mode
模组是 8 线 PSRAM(型号带 R8)就选 Octal Mode PSRAM,4 线(R2)选 Quad Mode
模组型号选哪个
N8R2 / N16R2Quad Mode PSRAM(4 线,默认)
N8R8 / N16R8Octal Mode PSRAM(8 线,更快)

选错了会开不起来

Quad 和 Octal 是物理接线根数不同,选错等于用错的方式去跟内存芯片说话 —— 开机时 PSRAM 初始化会直接失败。

不确定就看模组上的丝印,或者两个都试一下,能正常打印出 PSRAM 大小的那个就对。

其余选项保持默认即可。特别是这两个默认值,正好是我们要的:

选项默认值意思
Initialize SPI RAM during startup✅ 勾上开机自动初始化,不用自己调
SPI RAM access methodMake RAM allocatable using malloc() as well⭐ 让 PSRAM 能被 malloc 分配出来

改完点右上角的保存。

第 3 步:写代码 ​

c
#include <stdio.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>

#include "driver/gpio.h"
#include "driver/i2s_pdm.h"
#include "driver/i2s_std.h"
#include "esp_err.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

static const char *TAG = "record_play";

#define SAMPLE_RATE_HZ      16000
#define RECORD_SECONDS      10
#define DMA_FRAME_NUM       256
#define BEEP_HZ             1000
#define BEEP_MS             200
#define BEEP_AMPLITUDE      12000

#define MIC_CLK_GPIO        GPIO_NUM_3
#define MIC_DATA_GPIO       GPIO_NUM_42

#define SPK_BCLK_GPIO       GPIO_NUM_46
#define SPK_LRCLK_GPIO      GPIO_NUM_9
#define SPK_DATA_GPIO       GPIO_NUM_8
#define SPK_EN_GPIO         GPIO_NUM_38

#define RECORD_SAMPLE_COUNT (SAMPLE_RATE_HZ * RECORD_SECONDS)
#define RECORD_BYTE_COUNT   (RECORD_SAMPLE_COUNT * sizeof(int16_t))

static i2s_chan_handle_t s_mic_chan;
static i2s_chan_handle_t s_spk_chan;

// 功放开关:拉高出声,拉低静音
static void speaker_enable(int enable)
{
    gpio_config_t cfg = {
        .pin_bit_mask = 1ULL << SPK_EN_GPIO,
        .mode = GPIO_MODE_OUTPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE,
    };
    ESP_ERROR_CHECK(gpio_config(&cfg));
    gpio_set_level(SPK_EN_GPIO, enable ? 1 : 0);
}

static void mic_init(void)
{
    // PDM 接收只能用 I2S0
    i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
    chan_cfg.dma_desc_num = 4;
    chan_cfg.dma_frame_num = DMA_FRAME_NUM;
    ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, NULL, &s_mic_chan));

    i2s_pdm_rx_config_t pdm_cfg = {
        .clk_cfg = I2S_PDM_RX_CLK_DEFAULT_CONFIG(SAMPLE_RATE_HZ),
        .slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
        .gpio_cfg = {
            .clk = MIC_CLK_GPIO,
            .din = MIC_DATA_GPIO,
            .invert_flags = {
                .clk_inv = false,
            },
        },
    };

    /* 麦克风的 LR 脚接了 GND,输出的是左声道 */
    pdm_cfg.slot_cfg.slot_mask = I2S_PDM_SLOT_LEFT;

    ESP_ERROR_CHECK(i2s_channel_init_pdm_rx_mode(s_mic_chan, &pdm_cfg));
    ESP_ERROR_CHECK(i2s_channel_enable(s_mic_chan));
}

static void speaker_init(void)
{
    speaker_enable(0);                      // 先关着,别开机就响

    // 功放走 I2S1,和麦克风错开
    i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
    chan_cfg.dma_desc_num = 4;
    chan_cfg.dma_frame_num = DMA_FRAME_NUM;
    ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &s_spk_chan, NULL));

    i2s_std_config_t std_cfg = {
        .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE_HZ),
        .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
        .gpio_cfg = {
            .mclk = I2S_GPIO_UNUSED,        // NS4168 不需要 MCLK
            .bclk = SPK_BCLK_GPIO,
            .ws = SPK_LRCLK_GPIO,
            .dout = SPK_DATA_GPIO,
            .din = I2S_GPIO_UNUSED,         // 只发不收
            .invert_flags = {
                .mclk_inv = false,
                .bclk_inv = false,
                .ws_inv = false,
            },
        },
    };
    std_cfg.slot_cfg.slot_mask = I2S_STD_SLOT_BOTH;

    ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_spk_chan, &std_cfg));
    ESP_ERROR_CHECK(i2s_channel_enable(s_spk_chan));
}

// 发一声"滴":代码现算一段方波推给喇叭
static void speaker_beep(void)
{
    int16_t stereo_buf[DMA_FRAME_NUM * 2];
    int phase = 0;
    int half_period_samples = SAMPLE_RATE_HZ / (BEEP_HZ * 2);   // 半个周期占几个采样
    int total_samples = SAMPLE_RATE_HZ * BEEP_MS / 1000;
    int played = 0;

    ESP_LOGI(TAG, "beep");
    speaker_enable(1);

    while (played < total_samples) {
        int mono_count = total_samples - played;
        if (mono_count > DMA_FRAME_NUM) {
            mono_count = DMA_FRAME_NUM;
        }

        for (int i = 0; i < mono_count; i++) {
            // 前半周期最大、后半周期最小 —— 这就是方波
            int16_t sample = phase < half_period_samples ? BEEP_AMPLITUDE : -BEEP_AMPLITUDE;
            stereo_buf[i * 2] = sample;         // 左声道
            stereo_buf[i * 2 + 1] = sample;     // 右声道,填一样的

            phase++;
            if (phase >= half_period_samples * 2) {
                phase = 0;
            }
        }

        size_t bytes_written = 0;
        ESP_ERROR_CHECK(i2s_channel_write(s_spk_chan,
                                          stereo_buf,
                                          mono_count * 2 * sizeof(int16_t),
                                          &bytes_written,
                                          portMAX_DELAY));
        played += mono_count;
    }

    speaker_enable(0);
}

// 录 10 秒:一直读到填满整个缓冲区为止
static void record_10s(int16_t *record_buf)
{
    size_t total = 0;

    ESP_LOGI(TAG, "recording %d seconds...", RECORD_SECONDS);
    while (total < RECORD_BYTE_COUNT) {
        size_t bytes_read = 0;
        ESP_ERROR_CHECK(i2s_channel_read(s_mic_chan,
                                         (uint8_t *)record_buf + total,
                                         RECORD_BYTE_COUNT - total,
                                         &bytes_read,
                                         portMAX_DELAY));
        total += bytes_read;                    // ⭐ 一次不一定读满,要累加
    }
    ESP_LOGI(TAG, "record done");
}

// 回放:把单声道数据复制成左右两份再发出去
static void play_recording(const int16_t *record_buf)
{
    int16_t stereo_buf[DMA_FRAME_NUM * 2];
    size_t played = 0;

    ESP_LOGI(TAG, "playing...");
    speaker_enable(1);

    while (played < RECORD_SAMPLE_COUNT) {
        size_t mono_count = RECORD_SAMPLE_COUNT - played;
        if (mono_count > DMA_FRAME_NUM) {
            mono_count = DMA_FRAME_NUM;
        }

        for (size_t i = 0; i < mono_count; i++) {
            int16_t sample = record_buf[played + i];
            stereo_buf[i * 2] = sample;
            stereo_buf[i * 2 + 1] = sample;
        }

        size_t bytes_written = 0;
        ESP_ERROR_CHECK(i2s_channel_write(s_spk_chan,
                                          stereo_buf,
                                          mono_count * 2 * sizeof(int16_t),
                                          &bytes_written,
                                          portMAX_DELAY));
        played += mono_count;
    }

    speaker_enable(0);
    ESP_LOGI(TAG, "play done");
}

static void record_play_task(void *arg)
{
    (void)arg;

    // ⭐ 优先从 PSRAM 要这 313KB
    int16_t *record_buf = heap_caps_malloc(RECORD_BYTE_COUNT, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
    if (record_buf == NULL) {
        record_buf = malloc(RECORD_BYTE_COUNT);     // 退回片内 RAM 再试一次
    }
    if (record_buf == NULL) {
        ESP_LOGE(TAG, "failed to allocate %u bytes", (unsigned int)RECORD_BYTE_COUNT);
        vTaskDelete(NULL);
        return;
    }

    while (1) {
        speaker_beep();                 // 滴 —— 准备录了
        record_10s(record_buf);
        speaker_beep();                 // 滴 —— 录完了,要放了
        play_recording(record_buf);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void app_main(void)
{
    mic_init();
    speaker_init();
    xTaskCreate(record_play_task, "record_play_task", 4096, NULL, 5, NULL);
}

代码里有几个地方值得单独说:

为什么申请内存要写成"两步"

c
record_buf = heap_caps_malloc(RECORD_BYTE_COUNT, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (record_buf == NULL) {
    record_buf = malloc(RECORD_BYTE_COUNT);
}

第一句是指定要 PSRAM(MALLOC_CAP_SPIRAM)。如果板子没有 PSRAM、或者你忘了在配置里打开,这句会返回 NULL。

第二句是退而求其次,用普通 malloc 从片内 RAM 碰碰运气。313KB 多半也要不到,但代码不会因为第一句失败就直接崩掉 —— 它会走到第三句,打印一行明确的错误再退出:

c
ESP_LOGE(TAG, "failed to allocate %u bytes", ...);

比直接崩溃好排查得多。

麦克风为什么要指定 slot_mask

c
pdm_cfg.slot_cfg.slot_mask = I2S_PDM_SLOT_LEFT;

PDM 麦克风有个 LR(声道选择)脚:接 GND 是左声道,接 3.3V 是右声道。板子上接的是 GND,所以要告诉驱动"去左声道那一半取数据"。

搞反了的现象是录出来全是 0 —— 麦克风在左边说话,你却一直在右边听。

单声道的数据为什么要填两遍

c
stereo_buf[i * 2] = sample;         // 左
stereo_buf[i * 2 + 1] = sample;     // 右

喇叭那一路配的是 STEREO(双声道),I2S 每个周期要送左右各一个采样。而麦克风录的是单声道,只有一份数据。

所以把同一个采样左右各填一份,两个喇叭(或者同一个喇叭的左右通道)听起来就一样了。

漏了这一步,现象是声音变调、速度快一倍 —— 因为一半的数据被当成了另一个声道。

第 4 步:编译烧录 ​

操作流程见 2.2 第 4 步

在 VS Code 底部状态栏依次点:编译 → 烧录(或者直接点"编译、烧录并监视")。

烧录方式、串口、芯片型号第一次设好就不用再动了。

第 5 步:应该看到什么 ​

耳朵听到:滴一声 → 安静 10 秒(这时候对着麦克风说话)→ 又滴一声 → 把你刚才说的话放出来。

串口:

I (xxx) record_play: beep
I (xxx) record_play: recording 10 seconds...
I (xxx) record_play: record done
I (xxx) record_play: beep
I (xxx) record_play: playing...
I (xxx) record_play: play done

开机那几行日志里还能看到 PSRAM 被认出来了:

I (xxx) esp_psram: Found 8MB PSRAM device
I (xxx) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator

看到这两行,说明第 2 步的配置生效了。

动手试试 ​

  1. 把 RECORD_SECONDS 改成 30,看看还能不能申请到内存,顺便算一下要多少 KB。
  2. 把 BEEP_HZ 从 1000 改成 2000,听听提示音有什么不一样。
  3. 故意把配置里的 PSRAM 支持关掉,重新烧录,看串口打印的是哪一行错误。
  4. 把 SPK_EN_GPIO 那句 gpio_set_level(..., 1) 改成 0,验证"没声音"到底是什么现象。