ESP32 I2S音频总线学习笔记(四): INMP441采集音频并实时播放

发布于:2025-06-12 ⋅ 阅读:(18) ⋅ 点赞:(0)

简介

前面两期文章我们介绍了I2S的读取和写入,一个是通过INMP441麦克风模块采集音频,一个是通过PCM5102A模块播放音频,那如果我们将两者结合起来,将麦克风采集到的音频通过PCM5102A播放,是不是就可以做一个扩音器了呢,本篇将介绍一个INMP441采集音频并实时播放的应用。

往期相关文章:

ESP32 I2S音频总线学习笔记(一):初识I2S通信与配置基础

ESP32 I2S音频总线学习笔记(二):I2S读取INMP441音频数据

ESP32 I2S音频总线学习笔记(三):I2S音频输出

主要硬件

INMP441全向麦克风模块:
在这里插入图片描述
PCM5102A 立体声DAC模块 :在这里插入图片描述

硬件接线

ESP32和麦克风INMP441:

ESP32 INMP441
D13 SCK
D12 WS
D14 SD
3.3V VDD
GND GND

ESP32和PCM5102A:

ESP32 PCM5102A
- VCC
3.3V 3.3V
GND GND
GND FLT、DMP、SCL (这里SCL悬空可能会有干扰,所以接地)
D27 BCK
D25 DIN
D26 LCK
GND FMT
3.3V XMT

软件实现

前面两篇文章我们详细介绍了I2S读取和I2S输出的初始化步骤,所以本篇我们就不过多介绍了。我们的目的是实现INMP441采集音频并通过PCM5102A实时播放(可替换为其他DAC模块如MAX98357),使用的协议是I2S,所以首先要分别配置麦克风I2S初始化音频播放模块I2S初始化,前者我们起名为setupI2SMic( ) ,后者起名为setupI2SDac( );因为我们是循环采集音频,所以音频处理逻辑部分放在loop( )函数,首先搭建起整体框架,注意必不可少的是包含I2S驱动头文件:

#include <driver/i2s.h>

void setup() {
  Serial.begin(115200);
  setupI2SMic();
  setupI2SDac();
}

void loop()
{
   /*音频处理逻辑部分...待补充*/
}

搭建完整体框架后,我们再来完善setupI2SMic( )和setupI2SDac( )里面的内容。

INMP441读取 I2S初始化

setupI2SMic( )里面的I2S初始化步骤如何配置,可参考往期第二篇文章。这里因为使用到了两个I2S,一个用于麦克风采集,一个用于播放音频,所以我们将I2S0用于麦克风,I2S1用于音频的实时播放。

// 配置 I2S0 用于麦克风采集
#define I2S_MIC_NUM    I2S_NUM_0
#define I2S_MIC_BCK 13
#define I2S_MIC_WS  12
#define I2S_MIC_SD  14

#define SAMPLE_RATE    44100

void setupI2SMic() {
  i2s_config_t mic_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, // 单声道
    .communication_format = I2S_COMM_FORMAT_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,  // 增加 DMA 缓冲区数量
    .dma_buf_len = BUFFER_SIZE
  };

  i2s_pin_config_t mic_pin_config = {
    .bck_io_num = I2S_MIC_BCK,
    .ws_io_num = I2S_MIC_WS,
    .data_out_num = -1,
    .data_in_num = I2S_MIC_SD
  };

  i2s_driver_install(I2S_MIC_NUM, &mic_config, 0, NULL);
  i2s_set_pin(I2S_MIC_NUM, &mic_pin_config);
}

PCM5102A输出 I2S初始化

// 配置 I2S1 用于 DAC 输出
#define I2S_DAC_NUM    I2S_NUM_1
#define I2S_DAC_BCK 27
#define I2S_DAC_WS  26
#define I2S_DAC_DIN  25

void setupI2SDac() {
  i2s_config_t dac_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT, // 立体声
    .communication_format = I2S_COMM_FORMAT_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = BUFFER_SIZE
  };

  i2s_pin_config_t dac_pin_config = {
    .bck_io_num = I2S_DAC_BCK,
    .ws_io_num = I2S_DAC_WS,
    .data_out_num = I2S_DAC_DIN,
    .data_in_num = -1
  };

  i2s_driver_install(I2S_DAC_NUM, &dac_config, 0, NULL);
  i2s_set_pin(I2S_DAC_NUM, &dac_pin_config);
}

音频处理逻辑部分

音频处理逻辑部分主要步骤是从麦克风采集数据,然后播放音频数据,需要用到前面讲过的两个函数:esp_err_t i2s_read(i2s_port_t i2s_num, void *dest, size_t size, size_t *bytes_read, TickType_t ticks_to_wait);esp_err_t i2s_write(i2s_port_t i2s_num, const void *src, size_t size, size_t *bytes_written, TickType_t ticks_to_wait);
除此之外,因为麦克风是单声道的,音频播放模块是双声道的,需要将单声道转化为双声道音频;并且这里测试发现如果不对采集到的声音进行放大,声音是比较小的,所以还需进行音频增益处理。其处理逻辑如下:

void loop(){

       /*音频处理逻辑部分 */

//      从麦克风采集数据
 //     增益处理,放大音量并限制范围
 //     单声道转立体声
 //     播放音频数据
}



首先定义两个音频缓冲区,buffer 用于单声道输入,stereo_buffer 用于立体声输出。BUFFER_SIZE定义为单声道缓冲区大小1024,每次读取1024个样本,双声道缓存区的样本数是单声道缓存区样本数的两倍,所以为BUFFER_SIZE * 2。

#define BUFFER_SIZE    1024

int16_t buffer[BUFFER_SIZE];          // 单声道缓冲区
int16_t stereo_buffer[BUFFER_SIZE * 2]; // 立体声缓冲区

然后从麦克风采集数据:

i2s_read(I2S_MIC_NUM, buffer, BUFFER_SIZE * sizeof(int16_t), &bytesRead, portMAX_DELAY);

将采集到的音频数据进行增益处理,这里实测增益因子20~50比较合适,太大了采集到的音频声音会大,但是近距离说话的时候会容易出现破音,对远处的采集声音较好。溢出保护是因为16 位音频范围为 -32768 到 32767,放大后若超出此范围就会导致失真。

 for (int i = 0; i < BUFFER_SIZE; i++) {
     buffer[i] = buffer[i] * 20;  // 增益因子为20,可以根据需要调整
     // 增加溢出保护
      if (buffer[i] > 32767) buffer[i] = 32767;
      if (buffer[i] < -32768) buffer[i] = -32768;
  }

放大音频数据后将单声道音频数据转化为立体声 音频数据,单声道只有一个声道的数据,立体声需要左右两个声道,这里将单声道样本复制到左右声道。

 for (int i = 0; i < BUFFER_SIZE; i++) {
    stereo_buffer[2 * i] = buffer[i];      // 左声道
    stereo_buffer[2 * i + 1] = buffer[i]; // 右声道
  }

通过 I2S1 接口将立体声数据发送到PCM5102A播放。

i2s_write(I2S_DAC_NUM, stereo_buffer, sizeof(stereo_buffer), &bytesWritten, portMAX_DELAY);

音频处理逻辑部分的代码如下,这个步骤可以总结为:1. 采集单声道音频 → 2. 放大音量并限制范围 → 3. 转换为立体声 → 4. 播放

void loop() {
  int16_t buffer[BUFFER_SIZE];          // 单声道缓冲区
  int16_t stereo_buffer[BUFFER_SIZE * 2]; // 立体声缓冲区
  size_t bytesRead, bytesWritten;

  // 从麦克风采集数据
  i2s_read(I2S_MIC_NUM, buffer, BUFFER_SIZE * sizeof(int16_t), &bytesRead, portMAX_DELAY);

  // 增益处理,放大音量并限制范围
  for (int i = 0; i < BUFFER_SIZE; i++) {
     buffer[i] = buffer[i] * 20;  // 增益因子为20,可以根据需要调整
     // 增加溢出保护
      if (buffer[i] > 32767) buffer[i] = 32767;
      if (buffer[i] < -32768) buffer[i] = -32768;
  }

  // 单声道转立体声
  for (int i = 0; i < BUFFER_SIZE; i++) {
    stereo_buffer[2 * i] = buffer[i];      // 左声道
    stereo_buffer[2 * i + 1] = buffer[i]; // 右声道
  }

  // 播放音频数据
  i2s_write(I2S_DAC_NUM, stereo_buffer, sizeof(stereo_buffer), &bytesWritten, portMAX_DELAY);
}

全部整合后的代码如下:

#include <driver/i2s.h>

// 配置 I2S0 用于麦克风采集
#define I2S_MIC_NUM    I2S_NUM_0
#define I2S_MIC_BCK 13
#define I2S_MIC_WS  12
#define I2S_MIC_SD  14

// 配置 I2S1 用于 DAC 输出
#define I2S_DAC_NUM    I2S_NUM_1
#define I2S_DAC_BCK 27
#define I2S_DAC_WS  26
#define I2S_DAC_DIN  25

#define SAMPLE_RATE    44100
#define BUFFER_SIZE    1024

void setupI2SMic() {
  i2s_config_t mic_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, // 单声道
    .communication_format = I2S_COMM_FORMAT_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,  // 增加 DMA 缓冲区数量
    .dma_buf_len = BUFFER_SIZE
  };

  i2s_pin_config_t mic_pin_config = {
    .bck_io_num = I2S_MIC_BCK,
    .ws_io_num = I2S_MIC_WS,
    .data_out_num = -1,
    .data_in_num = I2S_MIC_SD
  };

  i2s_driver_install(I2S_MIC_NUM, &mic_config, 0, NULL);
  i2s_set_pin(I2S_MIC_NUM, &mic_pin_config);
}

void setupI2SDac() {
  i2s_config_t dac_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT, // 立体声
    .communication_format = I2S_COMM_FORMAT_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = BUFFER_SIZE
  };

  i2s_pin_config_t dac_pin_config = {
    .bck_io_num = I2S_DAC_BCK,
    .ws_io_num = I2S_DAC_WS,
    .data_out_num = I2S_DAC_DIN,
    .data_in_num = -1
  };

  i2s_driver_install(I2S_DAC_NUM, &dac_config, 0, NULL);
  i2s_set_pin(I2S_DAC_NUM, &dac_pin_config);
}

void setup() {
  Serial.begin(115200);
  setupI2SMic();
  setupI2SDac();
}

void loop() {
  int16_t buffer[BUFFER_SIZE];          // 单声道缓冲区
  int16_t stereo_buffer[BUFFER_SIZE * 2]; // 立体声缓冲区
  size_t bytesRead, bytesWritten;

  // 从麦克风采集数据
  i2s_read(I2S_MIC_NUM, buffer, BUFFER_SIZE * sizeof(int16_t), &bytesRead, portMAX_DELAY);

  // 增益处理,放大音量并限制范围
  for (int i = 0; i < BUFFER_SIZE; i++) {
     buffer[i] = buffer[i] * 20;  // 增益因子为20,可以根据需要调整
     // 增加溢出保护
      if (buffer[i] > 32767) buffer[i] = 32767;
      if (buffer[i] < -32768) buffer[i] = -32768;// 
  }

  // 单声道转立体声
  for (int i = 0; i < BUFFER_SIZE; i++) {
    stereo_buffer[2 * i] = buffer[i];      // 左声道
    stereo_buffer[2 * i + 1] = buffer[i]; // 右声道
  }

  // 播放音频数据
  i2s_write(I2S_DAC_NUM, stereo_buffer, sizeof(stereo_buffer), &bytesWritten, portMAX_DELAY);
}

功能扩展

添加两个按钮,用于控制实时播放的音量。主要实现思路是通过按钮去控制增益因子的增加或减少。

首先创建一个数组用来存储增益因子,这里我们选择放大的倍数为20, 30, 40, 50, 60, 70, 80可选,然后numGains用于计算元素个数,方便我们后续的判断。并设置默认增益为20。

// 增益因子数组
const int gainFactors[] = {20, 30, 40, 50, 60, 70, 80};
const int numGains = sizeof(gainFactors) / sizeof(gainFactors[0]);
int currentGainIndex = 0;  // 默认增益为 20

整体思路是:如果按键1按下,currentGainIndex++, 如果按键2按下,currentGainIndex- -

考虑到按键抖动的情况,代码如下:

// 按键引脚
#define BUTTON_UP_PIN 33   // 增益增加按键(GPIO 33)
#define BUTTON_DOWN_PIN 32 // 增益减少按键(GPIO 32)

// 按键去抖变量
unsigned long lastUpDebounceTime = 0;
unsigned long lastDownDebounceTime = 0;
const unsigned long debounceDelay = 50;

void KeyUp() {
  // 检查增益增加按键
  bool upReading= digitalRead(BUTTON_UP_PIN);
  if (upReading!= lastUpButtonState) {
    lastUpDebounceTime = millis();
  }
  if ((millis() - lastUpDebounceTime) > debounceDelay) {
    if (upReading== LOW && currentGainIndex < numGains - 1) {  // 按下且未达最大增益
      currentGainIndex++;
      Serial.println("增益切换至: " + String(gainFactors[currentGainIndex]));
      delay(200);  // 防止快速切换
    }
  }
  lastUpButtonState = upReading;
}

void KeyDn() {
    // 检查增益减少按键
  bool downReading = digitalRead(BUTTON_DOWN_PIN);
  if (downReading != lastDownButtonState) {
    lastDownDebounceTime = millis();
  }
  if ((millis() - lastDownDebounceTime) > debounceDelay) {
    if (downReading == LOW && currentGainIndex > 0) {  // 按下且未达最小增益
      currentGainIndex--;
      Serial.println("增益切换至: " + String(gainFactors[currentGainIndex]));
      delay(200);  // 防止快速切换
    }
  }
  lastDownButtonState = downReading;

  }


增加增益因子和减少增益因子的去抖机制相同,以增加为例,机械按键在按下或释放的时候会有产生短暂的电平抖动,通过去抖机制,等待一段时间(这里的debounceDelay)可以确保状态稳定,避免误判为多次按键事件。millis() 这个函数会返回程序启动后经过的毫秒数。lastUpDebounceTime = millis(); 标记按键状态变化的时间点,millis() - lastUpDebounceTime是当前时间与按键状态变化时间的差值,表示从上次状态变化以来经过的毫秒数, 用于判断按键状态是否稳定足够长时间,如果时间差值大于 debounceDelay,说明按键状态已经稳定,其效果类似于delay(50) (但是一个是阻塞等待,一个是非阻塞等待)。

主要代码是这两个, 其中numGains 是增益因子数组元素个数,因为数组是从零开始的,所以numGains个数减1就是表示数组最大索引数, 如果没达到最大索引,即还未达最大增益,currentGainIndex++。

 if (upReading== LOW && currentGainIndex < numGains - 1) {  // 按下且未达最大增益
      currentGainIndex++;
      Serial.println("增益切换至: " + String(gainFactors[currentGainIndex]));
      delay(200);  // 防止快速切换
    }

currentGainIndex > 0表示还未到最小索引,所以currentGainIndex- -

if (downReading == LOW && currentGainIndex > 0) {  // 按下且未达最小增益
      currentGainIndex--;
      Serial.println("增益切换至: " + String(gainFactors[currentGainIndex]));
      delay(200);  // 防止快速切换
    }

按键是否按下是通过判断upReading和downReading是否变为LOW实现。

为了方便控制音量,这里我还尝试了使用旋转编码器来控制音量,主要代码如下:

#include <ESP32Encoder.h>
// 编码器引脚
#define MODE_DT_PIN  32  // A 相
#define MODE_CLK_PIN 35  // B 相

#define MODE_STEP    2   // 每 2 个计数触发

ESP32Encoder modeEncoder;

void KeyUp() {
  if (currentGainIndex < numGains - 1) {
    currentGainIndex++;
    Serial.println("增益切换至: " + String(gainFactors[currentGainIndex]));
  }
}

void KeyDn() {
  if (currentGainIndex > 0) {
    currentGainIndex--;
    Serial.println("增益切换至: " + String(gainFactors[currentGainIndex]));
  }
}

/*------EC11 控制函数------*/
void EC11_Control() {
  static int lastModeCount = 0;
  static unsigned long lastRotateTime = 0;
  int currentModeCount = modeEncoder.getCount();

  if (abs(currentModeCount - lastModeCount) >= MODE_STEP) {
    lastRotateTime = millis();
    if (currentModeCount > lastModeCount) {
      KeyUp(); // 顺时针增加增益
      Serial.println("向下");
    } else {
      KeyDn(); // 逆时针减少增益
      Serial.println("向上");
    }
    modeEncoder.setCount(0); // 重置计数
    lastModeCount = 0;
  }

  // 长时间无操作时输出停止
  if (millis() - lastRotateTime > 1000) {
    //Serial.println("停止旋转");
    lastRotateTime = millis(); 
  }
}

理解了按键控制增益因子的原理,这里也是一样的。我们在EC11_Control()调用 KeyUp()和KeyDn()这两个函数。采用编码器的时候还需要注意在setup()函数里面添加初始化编码器的相关代码。

void setup() {
// 初始化编码器
  pinMode(MODE_CLK_PIN, INPUT_PULLUP);
  pinMode(MODE_DT_PIN, INPUT_PULLUP);
  modeEncoder.attachHalfQuad(MODE_CLK_PIN, MODE_DT_PIN);//配置编码器为半四分之一模式,只计数部分状态变化(每步约 1-2 个计数)
  modeEncoder.setFilter(50); // 滤波值
  modeEncoder.setCount(0);
  }

现象

ESP32驱动inmp441采集音频并实时播放

注意事项

  1. 音频输出可以用耳机去接收听到声音,也可以使用AUX线外接功放板。PCM5102A板子上也有L/R左右声道接口,也可以杜邦线接到其他功放上(比如上一期的TDA2030A功放模块)。
  2. INM441是全向麦克风模块,把扬声器和麦克风放在一起使用很容易引起啸叫,有耳机的话基本不会有这个问题。
  3. 根据前面啸叫问题,增益不宜调太大,一是容易引起啸叫,二是靠近说话容易引起破音,增益大了对远距离采集声音较好。