嵌入式 ANC:ESP32 实战

ESP32-S3 的双核 Xtensa LX7 处理器带有向量指令集扩展,适合执行嵌入式 ANC 所需的实时 DSP 运算。配合 ESP-DSP 库可以高效实现自适应滤波器。

I2S 麦克风采集

ANC 需要至少两路同步输入:参考麦克风(采集环境噪声)和误差麦克风(采集残余误差)。ESP32-S3 的 I2S 外设支持同时接收多路 ADC 数据,配置为 16 位、16 kHz 采样即可满足消费级 ANC 需求。

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include "driver/i2s.h"

#define I2S_SAMPLE_RATE     16000
#define I2S_BUFFER_SIZE     1024

void i2s_mic_init(void) {
    i2s_config_t i2s_config = {
        .mode = I2S_MODE_MASTER | I2S_MODE_RX,
        .sample_rate = I2S_SAMPLE_RATE,
        .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
        .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
        .communication_format = I2S_COMM_FORMAT_STAND_I2S,
        .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
        .dma_buf_count = 4,
        .dma_buf_len = I2S_BUFFER_SIZE,
        .use_apll = true,
        .tx_desc_auto_clear = false,
        .fixed_mclk = 0
    };

    i2s_pin_config_t pin_config = {
        .bck_io_num = GPIO_NUM_4,
        .ws_io_num = GPIO_NUM_5,
        .data_out_num = I2S_PIN_NO_CHANGE,
        .data_in_num = GPIO_NUM_6
    };

    i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
    i2s_set_pin(I2S_NUM_0, &pin_config);
}

void read_mic_samples(int16_t *buffer, size_t len) {
    size_t bytes_read;
    i2s_read(I2S_NUM_0, buffer, len * sizeof(int16_t), &bytes_read, portMAX_DELAY);
}

use_apll = true 启用 Audio PLL(音频锁相环)以获得更精确的音频时钟。APLL 由专用的 PLL 电路生成,比默认的 I2S 时钟抖动更小——时钟抖动会直接转化为采样时序误差,在 ANC 这种对相位精度敏感的系统中会严重降低降噪深度。dma_buf_count = 4dma_buf_len = 1024 的含义是:4 个缓冲区轮转,每个缓冲区 1024 样本,总缓冲区大小为 4096 采样 @ 16 kHz ≈ 256 ms。较大的缓冲区可以容忍 CPU 调度的偶发延迟,但会增加 I2S 采集的延迟,需要在实时性和鲁棒性之间取舍。

下图为整个 ANC 数据流的路径,从参考麦克风和误差麦克风经 DMA 进入 NLMS 处理器,最终输出到扬声器:

mermaid
flowchart TD
    MIC1["参考麦克风<br/>I2S CH0"] --> DMA["DMA 双缓冲<br/>128 samples/frame"]
    MIC2["误差麦克风<br/>I2S CH1"] --> DMA
    DMA --> DSP["NLMS 处理<br/>归一化步长"]
    DSP --> DAC["I2S 输出<br/>16bit PCM"]
    DAC --> SPK["扬声器"]

    classDef input fill:#2196F3,color:#fff
    classDef proc fill:#9C27B0,color:#fff
    classDef output fill:#4CAF50,color:#fff
    class MIC1,MIC2,DMA input
    class DSP proc
    class DAC,SPK output

自适应滤波器核心

NLMS 滤波器是整个 ANC 系统的核心。在 ESP32-S3 上,滤波器阶数取 64 已能覆盖 1 kHz 以下的主要低频噪声。环形缓冲区避免了每次采样后整体移位数据,将每个样本的复杂度从 O(N) 降为 O(1)。

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include "esp_dsp.h"
#include "driver/i2s.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

#define SAMPLE_RATE     16000
#define FRAME_SIZE      128
#define FILTER_ORDER    64

typedef struct {
    float w[FILTER_ORDER];          // 滤波器系数
    float x_ring[FILTER_ORDER];     // 参考信号环形缓冲区
    float s_hat[32];                // 次级路径估计
    float s_ring[32];               // 滤波后信号环形缓冲区
    int w_ptr;                      // 环形缓冲区写指针
    int s_ptr;
    float mu;                       // 自适应步长
} ESP32_ANC;

static ESP32_ANC g_anc;

void esp_anc_init(void) {
    memset(&g_anc, 0, sizeof(g_anc));
    g_anc.mu = 0.0005f;
    dsps_fft2r_init_fc32(NULL, CONFIG_DSP_MAX_FFT_SIZE);
}

次级路径 s_hat 长度为 32 个抽头,通常通过离线辨识获得——在部署前播放一段白噪声并用麦克风录制,通过 LMS 估计出扬声器到误差麦克风的传递函数。

NLMS 帧处理

每个音频帧(128 样本)的实时处理流程:

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static inline float fast_convolve(const float *w, const float *x, int ptr, int len) {
    float output = 0.0f;
    for (int i = 0; i < len; i++) {
        int idx = (ptr + len - i) % len;
        output += w[i] * x[idx];
    }
    return output;
}

void esp_anc_process_frame(float *ref, float *err, float *out, int len) {
    for (int n = 0; n < len; n++) {
        g_anc.x_ring[g_anc.w_ptr] = ref[n];
        g_anc.s_ring[g_anc.s_ptr] = ref[n];

        float y = fast_convolve(g_anc.w, g_anc.x_ring, g_anc.w_ptr, FILTER_ORDER);
        float x_filt = fast_convolve(g_anc.s_hat, g_anc.s_ring, g_anc.s_ptr, 32);

        float power = 0.0f;
        for (int i = 0; i < FILTER_ORDER; i++)
            power += g_anc.x_ring[i] * g_anc.x_ring[i];
        float norm_step = g_anc.mu / (power + 1e-6f);

        for (int i = 0; i < FILTER_ORDER; i++) {
            int idx = (g_anc.w_ptr + FILTER_ORDER - i) % FILTER_ORDER;
            g_anc.w[i] += norm_step * err[n] * g_anc.x_ring[idx];
        }

        out[n] = y;
        g_anc.w_ptr = (g_anc.w_ptr + 1) % FILTER_ORDER;
        g_anc.s_ptr = (g_anc.s_ptr + 1) % 32;
    }
}

环形缓冲区的卷积访问模式用动画看最直观——注意读取指针如何按反方向回绕:

环形缓冲区 · 卷积
5 阶 FIR · 卷积计算
w = [0.40, 0.30, 0.20, 0.07, 0.03]

fast_convolve 用环形索引计算卷积,避免每次重新排序数据。NLMS 系数更新时,正则项 1e-6f 防止参考信号功率过小导致除零。步长 mu = 0.0005 在收敛速度和稳态失调之间取折中——对于稳态噪声(如风扇、发动机)足够快,又不会在收敛后过度波动。

双核任务分配

将 ANC 循环锁定到 CPU1(即 PRO_CPU),CPU0 处理 Wi-Fi/蓝牙协议栈和其他系统任务。I2S 采样率为 16 kHz、帧长 128 样本时,每个帧的处理时间预算约为 8 ms。

下图展示了两个核心的具体分工——CPU0 负责系统管理,CPU1 专门处理 ANC 实时流水线:

mermaid
flowchart TD
    subgraph CPU0["CPU0 - 系统管理"]
        A1["WiFi/BT 协议栈"]
        A2["配置管理"]
        A3["OTA 升级"]
    end
    subgraph CPU1["CPU1 - 实时 ANC"]
        B1["I2S 读取<br/>双通道音频"]
        B2["NLMS/FXLMS<br/>滤波运算"]
        B3["I2S 写入<br/>抗噪声输出"]
        B1 --> B2 --> B3 --> B1
    end

    classDef sys fill:#2196F3,color:#fff
    classDef anc fill:#f44336,color:#fff
    class A1,A2,A3 sys
    class B1,B2,B3 anc
c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static void anc_task(void *arg) {
    int16_t *raw_buffer = heap_caps_malloc(FRAME_SIZE * 2 * sizeof(int16_t),
                                           MALLOC_CAP_INTERNAL);
    float *f_ref = heap_caps_malloc(FRAME_SIZE * sizeof(float), MALLOC_CAP_INTERNAL);
    float *f_err = heap_caps_malloc(FRAME_SIZE * sizeof(float), MALLOC_CAP_INTERNAL);
    float *f_out = heap_caps_malloc(FRAME_SIZE * sizeof(float), MALLOC_CAP_INTERNAL);
    int16_t *dac_buffer = heap_caps_malloc(FRAME_SIZE * sizeof(int16_t), MALLOC_CAP_INTERNAL);

    size_t bytes_read;
    for (;;) {
        i2s_read(I2S_NUM_0, raw_buffer, FRAME_SIZE * 2 * sizeof(int16_t),
                 &bytes_read, portMAX_DELAY);

        for (int i = 0; i < FRAME_SIZE; i++) {
            f_ref[i] = raw_buffer[i * 2] / 32768.0f;
            f_err[i] = raw_buffer[i * 2 + 1] / 32768.0f;
        }

        esp_anc_process_frame(f_ref, f_err, f_out, FRAME_SIZE);

        for (int i = 0; i < FRAME_SIZE; i++) {
            float clamped = fmaxf(-1.0f, fminf(1.0f, f_out[i]));
            dac_buffer[i] = (int16_t)(clamped * 32767.0f);
        }

        i2s_write(I2S_NUM_1, dac_buffer, FRAME_SIZE * sizeof(int16_t),
                  &bytes_read, portMAX_DELAY);
    }
}

void app_main(void) {
    esp_anc_init();
    i2s_mic_init();
    xTaskCreatePinnedToCore(anc_task, "anc_task", 8192, NULL,
                            configMAX_PRIORITIES - 1, NULL, 1);
}

heap_caps_malloc(..., MALLOC_CAP_INTERNAL) 确保所有缓冲区都在内部 SRAM 中,避免因 PSRAM 访问延迟(通常比 SRAM 慢 3–5 倍)导致处理超时。任务栈大小 8192 字节足够容纳 DSP 调用链的局部变量。

I2S 输出(I2S_NUM_1)驱动外部 DAC 或 Class-D 功放,扬声器发出反相声波与原始噪声在声学上抵消。输出前通过 fmaxf/fminf 限幅,防止 DAC 削波失真。

绑到 CPU1 而非 CPU0 的原因:CPU0 在 ESP-IDF 中负责 Wi-Fi 和蓝牙协议栈,Wi-Fi 硬件中断优先级高且密集——每次信标帧接收或 TCP ACK 都会触发中断。这些中断会抢占 CPU0 上的用户任务,导致 ANC 帧处理出现不可预测的延迟抖动。CPU1(APP_CPU)不受 Wi-Fi 中断影响,因此是绑定实时 DSP 任务的更优选择。

ESP32-S3 vs ESP32:S3 的 Xtensa LX7 处理器增加了向量指令扩展(AE_* 系列),可在单周期内完成多个乘加(MAC)操作,NLMS 的卷积更新速度比 ESP32(LX6)快约 3–5 倍。ESP32 的 CPU0 和 CPU1 均为 LX6 架构,不带向量扩展,DSP 效率明显偏低。如果使用 ESP32(非 S3),建议将滤波器阶数降至 32 或将帧长缩短至 64 样本以保持实时性。

构建配置

sdkconfig 中需要启用以下选项:

如果使用 ESP-IDF v5.0 以上版本,建议迁移到新的 i2s_chan_handle_t 驱动模式,但处理逻辑一致。

延迟预算

整个流水线的延迟由以下环节累积:

环节延迟
I2S DMA 采集1 帧(128 样本)≈ 8 ms
ADC 转换 + 传输~0.5 ms
帧处理(NLMS)~2–3 ms
DAC 转换 + 传输~0.5 ms
声学路径~0.1–0.3 ms
总计~11–12 ms

下图为延迟预算的详细分解:

mermaid
flowchart TD
    A["I2S 采集<br/>~2ms"] --> B["DMA 传输<br/>~0.1ms"]
    B --> C["NLMS 计算<br/>~3-5ms"]
    C --> D["I2S 输出<br/>~1ms"]
    D --> TOTAL["总延迟<br/>~6-8ms<br/>有效频率: ~60-80Hz"]

    classDef stage fill:#2196F3,color:#fff
    classDef result fill:#f44336,color:#fff
    class A,B,C,D stage
    class TOTAL result

消费级 ANC 的有效频率上限约为 1 / (2 × 总延迟) ≈ 40–45 Hz,因此这套方案主要针对 50 Hz 以下的低频轰鸣声——空调压缩机、风扇、路面胎噪等。要提高有效频率,需要缩短帧长、降低滤波器阶数,或在更强的 MCU 上运行。