ESP-ADF架构探索(I. 初探IDF)

本文最后更新于 2025年9月1日 下午

ESP-ADF架构探索(I. 初探ADF)

乐鑫音频开发框架 (ESP-ADF) 是乐鑫官方为其 ESP32、ESP32-S3 SoC 设计的音频开发框架,支持多种音频格式和输入输出方式,以音频元素(Element)和音频管道(Pipeline)来组织和管理音频流,以audio_hal、periph等管理音频硬件。

典型音频项目示例

前言

最近用Rust想写一个嵌入式音频框架embedded-audio,但架构设计上很是困扰,因此撰文,研究ESP-ADF (ESP32嵌入式音频框架)。

本文阅读基础:

  • ESP-IDF使用经验(不需ADF),了解Kconfig和IDF项目架构
  • C语言能力
  • 一定英文能力

本文 (I. 初探ADF) 提供:

  • 架构概览
  • 五个经典例程讲解
  • ADF概念详细讲解

如果你已经比较清楚ADF架构,可以直接看第II篇。如果你是ADF初学者,建议只看第I篇。后面会深入探讨和研究架构,对于应用层开发是不必要的。

(I. 初探ADF)

(II. 深入Element)

(III. 深入Pipeline)

前排提醒:

  1. 本文出示的代码均为经删改的伪代码,仅用于逻辑演示。
  2. 本文中一些机械性的叙述由LLM辅助编写,但是经过N次人工校对修改。
  3. 本篇(第 I 篇)后面的篇目,主要研究ESP-ADF的内部实现和架构,不重点研究应用层。
  4. 文章发布时间为2025年6-7月,请注意是否过时。

你知道吗?

ESP-ADF现在不是叫 Espressif Audio Development Framework,而是Espressif Advanced Development Framework。不知道什么时候改的名。

Espressif Advanced Development Framework

ESP-ADF不仅是他们在用,Beken也买了ADF放在他们SDK里面,叫bk-audio(非官方repo引用的官方SDK)。

整体概览

ADF Block Diagram

乐鑫音频开发框架 (ESP-ADF) 是乐鑫官方为其 ESP32、ESP32-S3 SoC 设计的高级开发框架 。

ESP-ADF 支持广泛的音频格式,包括 MP3、AAC、FLAC、WAV、OGG、OPUS、AMR 等,并且能够从多种音源获取数据,例如 HTTP、HLS (HTTP Live Streaming)、SPIFFS、SD 卡、A2DP 音频源/接收器等 。其目标应用领域包括智能音箱、互联网收音机、语音控制设备、VoIP 解决方案以及视频通话、录制和直播等 。该框架提供了一系列 API 组件,涵盖音频流 (Audio Streams)、编解码器 (Codecs) 和服务 (Services),这些组件被组织在音频管道 (Audio Pipeline) 结构中 。

Elements

音频数据通常使用输入流获取,通过编解码器处理,在某些情况下还通过音频处理函数处理,最后通过另一个流输出。提供了一个事件接口来促进应用程序事件的通信。与特定硬件的接口通过外设完成。

示例管道组织结构

ADF仓库结构

espressif/esp-adf: Espressif Advanced Development Framework

我让AI整理了下,详见:结构、组件和例程表

1
2
3
4
5
6
7
8
9
esp-adf/
├── components # 核心组件和第三方库
├── docs # 文档
├── examples # 示例代码
├── tools # 实用工具
├── CMakeLists.txt # 顶层构建脚本
├── esp-idf # idf 作为submodoule
├── README.md # 项目根 README
└── ... # 其他配置文件

Examples

详细表格请见:结构、组件和例程表

目录 主要功能
get-started 提供最基础的音频播放、控制和连接示例,适合初学者快速上手。
player 专注于各种音源(HTTP, HLS, SD卡, Flash, 蓝牙等)的播放功能。
recorder 展示如何从麦克风录制音频,并编码存储为不同格式。
speech_recognition 包含语音唤醒 (Wake Word Engine) 和语音活动检测 (VAD) 的示例。
protocols 演示了基于特定网络协议的音视频应用,如 RTMP, RTSP, VoIP。
system 提供系统级功能的示例,如低功耗管理、CoreDump 上传、电池管理等。
display 展示如何结合显示屏或 LED 灯带,为音频应用提供可视化交互。
cloud_services 演示如何对接到云服务平台,实现语音合成 (TTS) 和翻译等功能。
audio_processing 包含了各种音频后处理算法的示例,如均衡器、变声、重采样等。
advanced_examples 提供更复杂的综合性应用示例,如多房间同步、DLNA、灵活管道构建等。
ai_agent 展示如何与 AI 代理平台(如 Coze)进行集成,实现智能对话机器人。
cli 提供一个基于命令行的交互式示例,用于测试和控制 ADF 功能。
checks 用于快速验证开发板上外设(如按键、LED)功能是否正常。

play_mp3_control开始,看一些例程

我们先简单看几个例子,看看ADF是怎么用的。

这一步简单了解大概运行逻辑,了解下概念即可。应用层的开发并非本文重点。

play_mp3_control

源码地址

这个示例项目展示了创建一个可以控制的 MP3 播放器。该播放器能够播放嵌入在固件中的 MP3 文件,并展示了播放、暂停、恢复、停止、音量调节以及切换歌曲等功能。

数据流如下: 读取函数 mp3_music_read_cb -> MP3解码器 -> I2S流输出 -> Codec芯片

音频文件

首先,代码通过 asm 语句来引用被链接脚本嵌入到二进制固件中的 MP3 文件。

1
2
3
// low rate mp3 audio
extern const uint8_t lr_mp3_start[] asm("_binary_music_16b_2c_8000hz_mp3_start");
extern const uint8_t lr_mp3_end[] asm("_binary_music_16b_2c_8000hz_mp3_end");

audio_element 需要一个回调函数来获取音频数据流。这里的 mp3_music_read_cb 函数负责从 flash 中读取由 file_marker 指定的 MP3 数据块。它会从当前位置 (file_marker.pos )开始,复制element所请求的长度的数据到缓冲区,并更新位置。当所有数据都读取完毕后,它返回 AEL_IO_DONE 来通知 pipeline 数据流结束。

1
2
3
4
5
6
7
8
9
10
11
12
int mp3_music_read_cb(audio_element_handle_t el, char *buf, int len, TickType_t wait_time, void *ctx)
{
int read_size = file_marker.end - file_marker.start - file_marker.pos;
if (read_size == 0) {
return AEL_IO_DONE;
} else if (len < read_size) {
read_size = len;
}
memcpy(buf, file_marker.start + file_marker.pos, read_size);
file_marker.pos += read_size;
return read_size;
}

(这个在rust里就很好办了,直接include_bytes!就行。)

主程序 app_main

1. 初始化Codec

首先,它会初始化音频编解码芯片 (Codec),如DA7217,ES8388、ES8311(乐鑫的音频板子常带ES的Codec),这些CodeC主要是通过I2S与主机传递数据,以I2C作为配置接口。

1
2
3
4
5
6
// app_main
audio_element_handle_t i2s_stream_writer, mp3_decoder;
// ...
ESP_LOGI(TAG, "[ 1 ] Start audio codec chip");
audio_board_handle_t board_handle = audio_board_init();
audio_hal_ctrl_codec(board_handle->audio_hal, AUDIO_HAL_CODEC_MODE_DECODE, AUDIO_HAL_CTRL_START);

2. 创建和配置音频 Pipeline

音频管线 (audio_pipeline) 负责管理和连接各个Element。

首先创建了一个 MP3 decoder element,并使用 audio_element_set_read_cb 将上面定义的数据读取函数 mp3_music_read_cb 注册给它。这样,解码器就能获取原始的 MP3 数据。

接着,创建一个 I2S element 作为数据流的终点,它负责将解码后的数据写入到 Codec 芯片。

1
2
3
4
5
6
7
8
9
audio_pipeline_handle_t pipeline;
audio_pipeline_cfg_t pipeline_cfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
pipeline = audio_pipeline_init(&pipeline_cfg);

// [2.1] Create mp3 decoder to decode mp3 file and set custom read callback
audio_element_handle_t i2s_stream_writer, mp3_decoder;
mp3_decoder_cfg_t mp3_cfg = DEFAULT_MP3_DECODER_CONFIG();
mp3_decoder = mp3_decoder_init(&mp3_cfg);
audio_element_set_read_cb(mp3_decoder, mp3_music_read_cb, NULL);

创建完 elements 后,需要将它们注册到 pipeline 中,并按照 mp3_decoder -> i2s_stream_writer 的顺序连接起来,形成一个完整的数据处理链路。

1
2
3
// [2.4] Link it together [mp3_music_read_cb]-->mp3_decoder-->i2s_stream-->[codec_chip]
const char *link_tag[2] = {"mp3", "i2s"};
audio_pipeline_link(pipeline, &link_tag[0], 2);

3. 事件监听与主循环

为了响应按键输入和 pipeline 的状态变化,代码设置了一个事件监听器 audio_event_iface。它会同时监听来自外设(如触摸按键)和 pipeline 中所有 element 的事件。

1
2
3
4
5
6
7
8
9
ESP_LOGI(TAG, "[ 4 ] Set up  event listener");
audio_event_iface_cfg_t evt_cfg = AUDIO_EVENT_IFACE_DEFAULT_CFG();
audio_event_iface_handle_t evt = audio_event_iface_init(&evt_cfg);

ESP_LOGI(TAG, "[4.1] Listening event from all elements of pipeline");
audio_pipeline_set_listener(pipeline, evt);

ESP_LOGI(TAG, "[4.2] Listening event from peripherals");
audio_event_iface_set_listener(esp_periph_set_get_event_iface(set), evt);

主循环 while(1) 通过 audio_event_iface_listen 阻塞等待事件消息。

  • 音乐信息事件:当 MP3 解码器开始处理数据时,它会发出 AEL_MSG_CMD_REPORT_MUSIC_INFO 事件,其中包含了音频的采样率、位宽和声道数。代码在收到此事件后,调用 i2s_stream_set_clk 来配置 I2S 时钟,以确保后续的音频数据能被正确地硬件播放。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    if (msg.source_type == AUDIO_ELEMENT_TYPE_ELEMENT && msg.source == (void *) mp3_decoder
    && msg.cmd == AEL_MSG_CMD_REPORT_MUSIC_INFO) {
    audio_element_info_t music_info = {0};
    audio_element_getinfo(mp3_decoder, &music_info);
    ESP_LOGI(TAG, "[ * ] Receive music info from mp3 decoder, sample_rates=%d, bits=%d, ch=%d",
    music_info.sample_rates, music_info.bits, music_info.channels);
    i2s_stream_set_clk(i2s_stream_writer, music_info.sample_rates, music_info.bits, music_info.channels);
    continue;
    }
  • 按键事件:当接收到按键事件时,代码根据按键ID执行不同操作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    if ((msg.source_type == PERIPH_ID_TOUCH || msg.source_type == PERIPH_ID_BUTTON || msg.source_type == PERIPH_ID_ADC_BTN)
    && (msg.cmd == PERIPH_TOUCH_TAP || msg.cmd == PERIPH_BUTTON_PRESSED || msg.cmd == PERIPH_ADC_BUTTON_PRESSED)) {
    if ((int) msg.data == get_input_play_id()) {
    // ...
    } else if ((int) msg.data == get_input_set_id()) {
    // ...
    break;
    } else if ((int) msg.data == get_input_mode_id()) {
    ESP_LOGI(TAG, "[ * ] [mode] tap event");
    audio_pipeline_stop(pipeline);
    audio_pipeline_wait_for_stop(pipeline);
    audio_pipeline_terminate(pipeline);
    audio_pipeline_reset_ringbuffer(pipeline);
    audio_pipeline_reset_elements(pipeline);
    set_next_file_marker();
    audio_pipeline_run(pipeline);
    } // ...
    }

4. 资源释放

跳出主循环后,程序会停止、销毁 pipeline,注销 elements,释放所有alloc的资源。

总结

先注册一些element,然后把它们加入到pipeline里,再用audio_pipeline_link连接起来(这样pipeline会在他们之间建一个ringbuffer)。

然后注册和绑定监听事件。随后pipeline.run(),将管线开起来,等decoder跑起来了,监听来自 mp3_decoderAEL_MSG_CMD_REPORT_MUSIC_INFO 事件,以便获取音频元信息(采样率、位深、声道)并正确配置 i2s_stream 的时钟。

pipeline_http_mp3

源码地址

这个例子的source,使用了一个专门用于处理网络数据流的 audio_elementhttp_stream

[http_server] ---> http_stream ---> mp3_decoder ---> i2s_stream ---> [codec_chip]

1. 网络初始化

播放网络音频流的首要前提是连接到互联网。因此,在 app_main 中,程序首先初始化 Wi-Fi 外设 (periph_wifi) 并等待连接成功。SSID 和密码通过 menuconfig 进行配置。

2. 构建Pipeline

audio_pipeline 的构建过程在概念上与前例相似,但其构成元素有所不同。

  • 创建 Elements: 除了共有的 mp3_decoderi2s_stream_writer,此示例创建了一个 http_stream_readeraudio_element,它负责从 HTTP 服务器获取数据流。

  • 链接 Pipeline: 数据流的链接顺序相应地变为三级:http -> mp3 -> i2s。数据首先由 http_stream_reader 从网络获取,然后传递给 mp3_decoder 解码,最后由 i2s_stream_writer 输出到 codec_chip

1
2
const char *link_tag[3] = {"http", "mp3", "i2s"};
audio_pipeline_link(pipeline, &link_tag[0], 3);
  • 配置http_stream_reader: 程序通过 audio_element_set_uri 函数,将要播放的 MP3 文件的 URL 地址设置给 http_stream_readerhttp_stream_reader 内部会自动处理 HTTP 请求、接收数据等网络操作。
1
2
ESP_LOGI(TAG, "[2.6] Set up  uri (http as http_stream, mp3 as mp3 decoder, and default output is i2s)");
audio_element_set_uri(http_stream_reader, "https://dl.espressif.com/dl/audio/ff-16b-2c-44100hz.mp3");

3. 事件处理

这个例子的事件循环逻辑比前一个简单。它没按键,仅监听两类事件:

  1. 和前例一样,监听来自 mp3_decoderAEL_MSG_CMD_REPORT_MUSIC_INFO 事件,以便获取音频元信息(采样率、位深、声道)并正确配置 i2s_stream 的时钟。
  2. 监听来自 pipeline 最后一个 element (i2s_stream_writer) 的状态报告。当接收到 AEL_STATUS_STATE_STOPPEDAEL_STATUS_STATE_FINISHED 状态时,意味着音频流播放完毕或已停止,此时程序会跳出循环,结束播放。
1
2
3
4
5
6
7
/* Stop when the last pipeline element (i2s_stream_writer in this case) receives stop event */
if (msg.source_type == AUDIO_ELEMENT_TYPE_ELEMENT && msg.source == (void *) i2s_stream_writer
&& msg.cmd == AEL_MSG_CMD_REPORT_STATUS
&& (((int)msg.data == AEL_STATUS_STATE_STOPPED) || ((int)msg.data == AEL_STATUS_STATE_FINISHED))) {
ESP_LOGW(TAG, "[ * ] Stop event received");
break;
}

总结

这个例子在ESP-IDF高度封装的情况下,显得极为简单。值得注意的是,这个例子使用的是 http_stream_reader ,而网络是不确定的,这才能体现出RingBuffer的必要性。未来我们将更加深入的研究http_stream 这个element的具体实现。

element_wav_amr_sdcard

源码地址

从麦克风(通过 codec_chipi2s_stream)采集音频数据,然后将这份数据 同时分发给两个独立的编码和写入流程,一个用于生成 WAV 文件,另一个用于生成 AMR 文件,最终都保存在 SD 卡中。

1
2
3
                                  |---> rb01 ---> wav_encoder ---> rb02 ---> fatfs ---> [wav_file]
[mic] ---> codec_chip ---> i2s -- |
|---> rb10 ---> amr_encoder ---> rb12 ---> fatfs ---> [amr_file]

i2s : i2s_stream_reader rb : ringbuf fatfs : fatfs_stream_writer

与之前使用 pipeline 来统管理所有 element 的方式不同,这个示例手动管理 audio_element来控制数据流的连接和生命周期。

1. 初始化硬件

录音首先需要配置 codec chip 工作在编码模式 (AUDIO_HAL_CODEC_MODE_ENCODE),并初始化 SD 卡。

2. 创建并手动连接 Elements

程序会创建几个的 audio_element:1个 i2s_stream_reader、2个编码器 (wav_encoder, amr_encoder) 和2个文件写入器 fatfs_stream_writer (fatfs可以设置同时最多打开文件的个数,在ESP-IDF中是通过menuconfig设置。我记得默认是5)。

  • 构建WAV处理链:

    首先,代码创建了两个 ringbuf。然后使用 audio_element_set_output_ringbuf 和 audio_element_set_input_ringbuf 函数,将 i2s_stream_reader -> ringbuf01 -> wav_encoder -> ringbuf02 -> wav_fatfs_stream_writer 连接起来。

    1
    2
    3
    4
    5
    ESP_LOGI(TAG, "[3.3] Create a ringbuffer and insert it between i2s_stream_reader and wav_encoder");
    ringbuf01 = rb_create(RING_BUFFER_SIZE, 1);
    audio_element_set_output_ringbuf(i2s_stream_reader, ringbuf01);
    audio_element_set_input_ringbuf(wav_encoder, ringbuf01);
    // ...
  • 数据分流:

    为了实现从 i2s_stream_reader 到 AMR 编码器的第二路数据流,代码使用了 audio_element_set_multi_output_ringbuf 函数。这个函数允许一个 element 拥有多个输出 ringbuf,从而实现了数据的一对多分发。这是实现数据流分叉的关键。(C语言直接用指针真爽,Rust生命周期所有权引用真的头大)

    1
    2
    3
    4
    ESP_LOGI(TAG, "[4.2] Create a ringbuffer and insert it between i2s_stream_reader and wav_encoder");
    ringbuf11 = rb_create(RING_BUFFER_SIZE, 1);
    audio_element_set_multi_output_ringbuf(i2s_stream_reader,ringbuf11,0);
    audio_element_set_input_ringbuf(amr_encoder,ringbuf11);

    后续 AMR 链的连接方式与 WAV 链相同。

  • 设置输出文件路径:

    通过 audio_element_set_uri 为两个 fatfs_stream_writer 分别指定在 SD 卡上保存的文件名。

    1
    2
    3
    audio_element_set_uri(wav_fatfs_stream_writer, "/sdcard/rec_out.wav");
    // ...
    audio_element_set_uri(amr_fatfs_stream_writer, "/sdcard/rec_out.amr");

3. 启动与控制

由于没有统一的 pipeline 管理器,程序需要调用 audio_element_runaudio_element_resume 来独立启动每一个 element

1
2
3
4
5
ESP_LOGI(TAG, "[8.0] Start audio elements");
audio_element_run(i2s_stream_reader);
audio_element_run(wav_encoder);
audio_element_run(wav_fatfs_stream_writer);
// ...

主循环通过一个简单的延时来控制录制时长,达到预设的 RECORD_TIME_SECONDS 后就跳出循环并停止所有 element

总结

这个例子构建了一个从单一数据源 (i2s_stream) 到两个不同处理链 (wav_encoderamr_encoder) 的 分叉数据流。

而且它不依赖 audio_pipeline ,而是通过 audio_element_set_input/output_ringbufaudio_element_run 等函数手动管理各个 element 的连接与运行。

http_play_and_save_to_file

源码地址

这个示例展示了如何使用 audio_pipeline 管理器来构建和控制一个分叉数据流。从单一的数据源 http_stream 获取数据后,pipeline 将其分发到两个不同的处理路径:一个用于解码和播放,另一个用于直接写入文件。

1
2
3
4
 http_stream_reader ---> mp3_decoder ---> i2s_stream ---> codec chip
  |
  v
  raw_stream ---> fatfs_stream ---> SD card

与上一个录音示例中手动管理所有 element 不同,此示例使用了两个audio_pipeline,不过仍然是手动设置某个元素的多路输出来实现数据流的分叉。

1. 创建两个Pipeline

程序创建了两个pipeline句柄:pipeline用于播放,pipeline_save用于保存。

  • 播放Pipeline (pipeline): 负责从HTTP获取数据、解码并播放。其包含http_stream_readermp3_decoderi2s_stream_writer三个元素。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ESP_LOGI(TAG, "[2.0] Create audio pipeline for playback");
    audio_pipeline_cfg_t pipeline_cfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
    pipeline = audio_pipeline_init(&pipeline_cfg); // Playback pipeline

    // ... register http_stream_reader, mp3_decoder, i2s_stream_writer ...

    ESP_LOGI(TAG, "[2.5] Link elements together http_stream-->mp3_decoder-->i2s_stream-->[codec_chip]");
    const char *link_tag[3] = {"http", "mp3", "i2s"};
    audio_pipeline_link(pipeline, &link_tag[0], 3);
  • 保存Pipeline (pipeline_save): 负责将原始数据流写入文件。它包含一个raw_stream(作为数据入口的占位元素)和一个fatfs_stream_writer(写入SD卡)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ESP_LOGI(TAG, "[3.2] Create pipeline to save audio file");
    audio_pipeline_cfg_t pipeline_save_cfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
    audio_pipeline_handle_t pipeline_save = audio_pipeline_init(&pipeline_save_cfg); // Save pipeline

    // ... register el_raw_read, el_fatfs_wr_stream ...

    ESP_LOGI(TAG, "[3.5] Link elements together raw_stream-->fatfs_stream");
    const char *link_save[2] = {"raw", "file"};
    audio_pipeline_link(pipeline_save, &link_save[0], 2);

2. 数据分叉

两个Pipeline创建并各自链接后,程序通过设置Element的multi ringbuf,将播放Pipeline中的 http_stream 与保存Pipeline连接起来。

raw_stream是一个占位元素,并不实际发挥作用。其作用只是当pipeline_save连接时,使pipeline在 raw_streamfatfs_stream 中间创建一个RingBuffer,然后我们再将这个RingBuffer设置为 http_stream 的第二个输出RingBuffer。

1
2
3
4
5
ESP_LOGI(TAG, "[3.6] Connect input ringbuffer of pipeline_save to http stream multi output");
// Get the output ringbuffer of the save pipeline's raw element.
ringbuf_handle_t rb = audio_element_get_output_ringbuf(el_raw_read);
// Set this ringbuffer as an additional output for the http_stream_reader.
audio_element_set_multi_output_ringbuf(http_stream_reader, rb, 0);

4. 存储

这个例子可以存储在SD卡上,也可以存储在Flash上。这需要提前配置partitions.csv,挂载FatFs(Flash文件系统强推littlefsLittleFS For ESP-IDF 。不推荐spiffs。)

总结

这个例子中,我们通过设置某个特定元素的多个输出RingBuffer,在Pipeline不知情的情况下,完成了数据的分流操作。事实上,Pipeline也没有为分流进行特殊的设计,这在我们下一篇文章中将会讲到。

这个例子使用了一个用于在Pipeline中占位的 raw_stream 可能是出于演示需要,事实上,第二条Pipeline可以完全不设置,手动创建一个RingBuffer,将其设置为 http_streammulti output ringbuffer,再将fatfs连上即可。

pipeline_a2dp_sink_and_hfp

源码地址 (不是,你管这玩意叫get-started??)

此示例是实现了一个典型的蓝牙音箱或耳机的功能。它遵从以下几个核心的蓝牙Profile:

  • A2DP Sink (Advanced Audio Distribution Profile - Sink Role): 高质量立体声音乐播放。手机或电脑作为 A2DP Source (音源),将音频流发送给作为 A2DP Sink 的 ESP32 设备进行播放。
  • HFP HF (Hands-Free Profile - Hands-Free Role): 通话功能。它支持双向的、较低延迟的单声道语音传输,并定义了丰富的通话控制指令,如接听、挂断、拒接、语音拨号等。

为了在不同功能(听音乐 vs. 打电话)间切换,它预先定义了两个独立的 pipeline 句柄,分别用于 A2DP 和 HFP 场景,并结合HFP回调函数来直接操作数据流,从而实现音乐播放和免提通话的切换。

1. 初始化蓝牙服务与HFP回调

app_main 中,程序首先启动蓝牙服务,并立即为 HFP Client 注册一个回调函数 bt_hf_client_cb。这个回调函数是后续所有通话功能处理的入口。随后初始化codec。

1
2
3
4
5
6
7
8
9
10
esp_log_level_set(TAG, ESP_LOG_DEBUG);

ESP_LOGI(TAG, "[ 1 ] Create Bluetooth service");
bluetooth_service_cfg_t bt_cfg = {
.device_name = "ESP-ADF-AUDIO",
.mode = BLUETOOTH_A2DP_SINK,
};
bluetooth_service_start(&bt_cfg);
esp_hf_client_register_callback(bt_hf_client_cb);
esp_hf_client_init();

2. 创建两个并行的Pipeline

程序创建了两个独立的Pipeline句柄,pipeline_d用于播放,pipeline_e用于录音。这两个Pipeline在程序启动后便会一直运行。

  • 播放Pipeline (pipeline_d): 其数据流为 bt_stream_reader -> i2s_stream_writer (filter可选)。它负责将从蓝牙服务收到的任何音频数据(无论是A2DP音乐还是HFP通话语音)送往编解码芯片进行播放。

    1
    2
    3
    4
    5
    audio_pipeline_register(pipeline_d, bt_stream_reader, "bt");
    audio_pipeline_register(pipeline_d, i2s_stream_writer, "i2s_w");
    // ...
    const char *link_d[2] = {"bt", "i2s_w"};
    audio_pipeline_link(pipeline_d, &link_d[0], 2);
  • 录音Pipeline (pipeline_e): 其数据流为 i2s_stream_reader -> raw_read。它负责从编解码芯片的ADC采集麦克风的声音,并暂时存入管道的RingBuffer中,等待下游 raw_stream 元素 (raw_read) 读取。

    1
    2
    3
    4
    5
    audio_pipeline_register(pipeline_e, i2s_stream_reader, "i2s_r");
    audio_pipeline_register(pipeline_e, raw_read, "raw");
    // ...
    const char *link_e[2] = {"i2s_r", "raw"};
    audio_pipeline_link(pipeline_e, &link_e[0], 2);

    RingBuffer满后,将Block住i2s_stream_readeraudio_element_init默认将元素超时时间设为最大值,所以在读取前会一直卡住。raw_stream 没有自己主动的任务,在下游元素的回调中才会被动读取。

3. HF回调函数

当HFP事件发生时(如电话接通),由bt_hf_client_cb函数进行分发和响应。通话期间的音频数据流完全由特定的回调函数接管,它们直接与运行中的Pipeline元素交互。

  • 当HFP音频连接建立时,会触发ESP_HF_CLIENT_AUDIO_STATE_EVT事件。此时,bt_app_hf_client_audio_open函数被调用,它会重新配置bt_stream_reader的音频参数(如采样率、声道数)以匹配HFP的语音规格(如16kHz,单声道)。通话结束后,bt_app_hf_client_audio_close会将其恢复为A2DP的规格(如44.1kHz,双声道)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    static void bt_app_hf_client_audio_open(void)
    {
    // ...
    audio_element_info_t bt_info = {0};
    audio_element_getinfo(bt_stream_reader, &bt_info);
    bt_info.sample_rates = HFP_RESAMPLE_RATE; // e.g., 16000
    bt_info.channels = 1;
    audio_element_setinfo(bt_stream_reader, &bt_info);
    }
  • 一旦HFP音频连接建立,蓝牙协议栈会注册两个数据处理回调。

    :当从手机端收到通话语音时,bt_app_hf_client_incoming_cb被触发。它直接调用audio_element_output将语音数据注入正在运行的bt_stream_reader元素的输出中,数据随后被i2s_stream_writer播放出来。

    1
    2
    3
    4
    5
    6
    static void bt_app_hf_client_incoming_cb(const uint8_t *buf, uint32_t sz)
    {
    // ...
    audio_element_output(bt_stream_reader, (char *)buf, sz); // Inject incoming voice to player
    esp_hf_client_outgoing_data_ready();
    }
  • 当需要向手机端发送麦克风语音时,bt_app_hf_client_outgoing_cb被触发。它直接调用raw_stream_read ,从 pipeline_eraw_read元素中主动取出音频数据,并拷贝到蓝牙协议栈的缓冲区中进行发送。

    1
    2
    3
    4
    5
    6
    7
    static uint32_t bt_app_hf_client_outgoing_cb(uint8_t *p_buf, uint32_t sz)
    {
    // ...
    out_len_bytes = raw_stream_read(raw_read, enc_buffer, sz); // Read mic data from recorder
    memcpy(p_buf, enc_buffer, out_len_bytes);
    // ...
    }

总结

这个例子使用两个独立的、持续运行的Pipeline实例来分别适配音频的输出和输入。当需要在不同模式(音乐/通话)间切换时,通过注册到底层协议栈的回调函数,直接与Pipeline中的Element进行直接的数据交互和参数配置。

但是这个例子在电话接通时没有清空缓冲区的动作(至少我没找到),播放时可能会先有杂音,这好像也是很多HF设备的通病。

ESP-ADF 的核心架构概念

音频管道 (Audio Pipeline)

音频管道 (Audio Pipeline) 是 ESP-ADF 中管理音频数据流的核心概念。它代表了一组动态链接的音频元素 (Audio Elements) 的组合,其中每个元素执行特定的任务(例如,从源读取、解码、滤波、写入目标)。

管道的生命周期包括初始化 (audio_pipeline_init)、注册元素 (audio_pipeline_register,元素需有唯一名称)、链接元素 (audio_pipeline_link,按顺序连接已注册的元素)、运行 (audio_pipeline_run)、暂停 (audio_pipeline_pause)、恢复 (audio_pipeline_resume)、停止 (audio_pipeline_stop)、终止/反初始化 (audio_pipeline_terminate / audio_pipeline_deinit) 15。这种结构化的生命周期为控制音频处理任务提供了清晰且易于管理的方式。

比如:

  • 录音并重采样:mic ---> codec_chip ---> i2s_stream ---> resample_filter ---> wav_encoder ---> fatfs_stream ---> sdcard
  • 从 SD 卡播放 MP3 并控制:[sdcard] ---> fatfs_stream ---> mp3_decoder ---> resample ---> i2s_stream ---> [codec_chip]
  • 从 SPIFFS 播放 MP3:[flash] ---> spiffs_stream ---> mp3_decoder ---> i2s_stream ---> [codec_chip]
  • HTTP MP3 播放:HTTP reader stream -> MP3 decoder -> I2S writer stream

音频管道不仅仅是元素的列表,它还主动管理这些元素的执行上下文(任务)以及它们之间的数据流(通过环形缓冲区)。

audio_pipeline_handle_t 负责控制音频数据流并将音频元素与环形缓冲区连接起来,并按顺序启动音频元素,音频元素作为 FreeRTOS 任务运行。

管道在链接元素时 (audio_pipeline_link) ,会建立连接这些任务的环形缓冲区。

此外,管道还将元素任务的消息转发给应用程序 ,充当中央通信枢纽。然而,管道的性能取决于底层 FreeRTOS 任务调度和任务间通信机制(环形缓冲区和事件队列)的效率。

音频元素 (Element)

音频元素是 ESP-ADF 中的基本构建模块 。每个解码器、编码器、滤波器、输入流或输出流实际上都是一个音频元素。它们代表了音频数据生命周期中的各个阶段:数据采集、处理和数据输出。应用程序是通过连接这些元素来构建的。

每个音频元素都可以为其生命周期的不同阶段设置回调函数:openseekprocessclosedestroyreadwrite(在 audio_element_cfg_t 中定义)19。这种回调机制允许在每个元素内部实现自定义行为,并定义其核心处理逻辑。例如,MP3 解码器使用 openprocessclosedestroy 回调函数 19。readwrite 回调对于充当数据源或数据汇的元素,或用于直接数据操作至关重要 19。音频元素作为 FreeRTOS 任务运行 19,可以启动、停止、暂停和恢复。这种基于任务的执行方式允许元素并发和独立地操作,在输入环形缓冲区数据可用时进行处理。

分类(非正式)

  • Source (输入流): 产生音频数据。示例包括:i2s_stream (用于麦克风输入 16)、fatfs_stream (用于 SD 卡 17)、spiffs_stream (用于 SPIFFS 18)、http_stream (用于网络 14)、raw_stream (用于自定义数据输入 21)。
  • Processor (滤波器、解码器、编码器): 转换音频数据。示例包括:mp3_decoderwav_encoderresample_filteraac_decoderopus_decoderequalizer (均衡器)、sonic (变速变调)、alc (自动电平控制) 、algorithm_stream (用于 AEC、AGC、NS)。
  • Sink (输出流): 消费音频数据。示例包括:i2s_stream (用于扬声器/耳机输出 )、fatfs_stream (比如用于 SD 卡录音或播放)、raw_stream (用于自定义数据输出 21)。

音频元素的设计促进了可重用性和可组合性。标准化的接口(audio_element_handle_t、通用的配置模式、环形缓冲区连接)允许将不同的元素混合搭配,包括分支,混音,以创建复杂的音频应用。

环形缓冲区 (RingBuffer)

环形缓冲区 (ringbuf_handle_t) 用于连接音频元素并在它们之间传输音频数据。每个从环形缓冲区读取数据的元素任务将在数据可用之前阻塞。类似地,向已满的环形缓冲区写入数据也将阻塞写入任务,除非指定了超时 25。

关键的环形缓冲区操作包括 rb_createrb_destroyrb_readrb_writerb_bytes_availablerb_abort 25。这些函数提供了管理环形缓冲区生命周期和数据流所必需的机制。rb_abort 对于在管道关闭或错误条件下解除任务阻塞至关重要。

在大多数 ESP-ADF 示例中,都使用了audio_pipeline_link(),这会自动创建RingBuffer。它们也可以手动创建并插入元素之间,如 element_wav_amr_sdcard 示例。

ESP-ADF 中的环形缓冲区不仅用作数据容器,还充当并发音频元素任务之间的基本同步原语。其阻塞行为是用于进程间/任务间通信和同步的生产者-消费者队列的特征。这些环形缓冲区的大小对于管道性能和对瞬时延迟(例如网络延迟)非常重要。过小的缓冲区可能导致欠载(播放器卡顿)或过载(写入器卡顿),而过大的缓冲区则会消耗宝贵的 RAM。audio_pipeline_link 的默认行为可能使用默认缓冲区大小,这可能需要针对特定应用进行调整。

事件处理系统 (Event)

ESP-ADF 提供了一个事件接口 API (audio_event_iface_t),用于音频元素与应用程序之间或元素之间的通信。它基于 FreeRTOS 队列。该系统允许元素异步报告状态更改(例如,播放完成、错误、数据发现)或其他重要事件。应用程序可以监听这些事件并做出相应反应。

事件系统将音频处理任务(元素)与主应用程序逻辑解耦。元素专注于数据处理,而应用程序通过事件处理更高级别的控制和响应状态更新。

层级结构

层级 关键组件/模块 主要功能
应用层 用户特定代码、UI 逻辑、自定义功能 实现最终产品功能
服务层 ESP_Dispatcher, periph_service, wifi_service, audio_service, esp_audio 提供高级、可重用的应用构建模块 (例如模块化、外设管理、网络、音频控制)
框架层 audio_pipeline, audio_element, ringbuf, audio_event_iface 核心音频处理引擎、数据流管理、元素编排、事件系统
抽象层 audio_hal, 流实现 (HTTP, FATFS 等) 连接框架与硬件 (编解码器) 并抽象复杂 I/O (网络、文件系统)
驱动层 / ESP-IDF FreeRTOS, 外设驱动 (I2S, SPI, I2C), 网络协议栈 (Wi-Fi, TCP/IP), HAL (IDF) 底层硬件控制、操作系统、基本连接、系统工具

最后

我们未来重点关注框架层,按需研究一些抽象层和服务层的内容。下一篇文章中,将对整体架构和概念进行进一步深入研究。

传送门:(II. 深入Element)


ESP-ADF架构探索(I. 初探IDF)
https://decaday.github.io/blog/exploring-esp-adf-i/
作者
星期十
发布于
2025年6月9日
更新于
2025年9月1日
许可协议