ESP32-S3 IDF实时语音开发:Function Calling与SG90舵机联动
上一篇《ESP32-S3 IDF实时语音开发:Realtime直连与回声消除》 完成了 ESP32-S3 直连 Qwen Omni Realtime 和豆包 Realtime,并逐步解决了流式音频播放、AEC 回声消除和插话打断问题。到这里,开发板已经能够持续听、持续说,也能在模型讲话时接受新的语音输入。
这一次继续向前一步:让 Realtime 模型不只生成声音,还能通过 Function Calling 控制真实硬件。当用户说“给我打个招呼”时,豆包会调用 ESP32-S3 注册的工具;开发板立即返回“已执行”,同时启动一个独立任务,让 SG90 舵机以最大速度左右摆动 5 秒。最后再接入一块 0.96 寸 OLED,把用户转写和 AI 回复以双行流式字幕显示出来,并用核心板上的 RGB 灯区分用户讲话、AI 回复和空闲状态。

对应工程:
projects/05_doubao_realtime
最终交互效果如下:
用户:你给我打个招呼吧?
↓
豆包:调用 perform_greeting_action
↓
ESP32-S3:立即返回 {"status":"已执行"}
↓
SG90:高速左右摆动 5 秒,结束后回到中位
↓
豆包:继续通过语音和用户交流
↓
OLED:第一行显示用户,第二行显示 AI
↓
RGB:用户红色、AI 绿色、其他时间白色
一、Function Calling 到底是什么
Realtime 模型通常有两种输出方式:
- 直接生成文字或语音。
- 请求客户端执行一个提前声明好的工具。
例如用户说“给我打个招呼”,模型本身无法直接控制 ESP32-S3 的 GPIO。我们需要先告诉模型,设备具有一个名为 perform_greeting_action 的工具:
{
"type": "function",
"name": "perform_greeting_action",
"description": "让SG90高速左右摆动5秒,完成打招呼动作",
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}
模型判断用户意图与工具描述匹配后,不会真的执行 C 函数,而是通过 WebSocket 发来一条“请调用这个工具”的事件。ESP32-S3 收到事件后,需要完成三件事:
识别工具名
↓
按 call_id 返回执行结果
↓
执行对应的本地动作
所以 Function Calling 更准确的理解是:
模型负责理解意图和选择工具,设备负责执行真实动作并返回结果。
它把自然语言和传统嵌入式函数连接在了一起。
二、本次功能的整体结构
原来的豆包工程已经包含多个长期运行的任务:
麦克风采集任务 -> ESP-SR AFE AEC -> 音频上行队列
音频上行任务 -> Base64/JSON -> WebSocket
WebSocket 接收 -> JSON 解析/音频解码
播放任务 -> 抖动缓冲 -> I2S -> MAX98357
这次增加一条独立的控制链路:
用户自然语言
↓
豆包 Realtime
↓
response.function_call_arguments.done
↓
WebSocket 接收任务解析 Function Call
├── 立即发送 conversation.item.create
│ └── {"status":"已执行"}
│
└── 通知 servo_greeting_task
└── SG90 左右摆动 5 秒
这里最重要的设计是:舵机动作不能直接运行在 WebSocket 事件处理函数里。
如果解析到工具调用后直接写一个持续 5 秒的循环,WebSocket 接收任务会被阻塞。模型返回的音频、转写、会话状态和打断事件都无法及时处理,最终会导致卡顿、丢包甚至连接异常。
因此代码采用“立即回执 + 异步执行”的结构:
网络任务:解析、回执、发送任务通知,立即返回
舵机任务:负责 PWM 切换和 5 秒计时
显示与状态提示也采用相同的异步思路:
Realtime 文本事件 -> 更新双行文本缓冲 -> oled_display_task -> I2C OLED
Realtime 状态事件 -> 计算对话状态 -> status_led_task -> RMT RGB
WebSocket 任务只解析事件和更新状态,I2C 刷屏、RMT 发送和舵机动作都交给各自的低优先级任务,避免这些外设拖慢实时音频链路。
三、SG90 接线
SG90 通常有棕、红、黄三根线:
| SG90 线色 | 接口 | 作用 |
|---|---|---|
| 棕色 | GND | 电源地 |
| 红色 | 5V | 舵机电源 |
| 黄色 | GPIO21 | PWM 控制信号 |
本次选择 GPIO21,它没有被当前工程中的麦克风、MAX98357、音量按键和 ESP32-S3-N16R8 的 Octal PSRAM 占用。
需要特别注意:
- SG90 的红线接 5V,不要接 ESP32-S3 的 3.3V。
- 舵机电源地和 ESP32-S3 的 GND 必须共地,否则 PWM 没有共同参考电平。
- 舵机启动和换向时电流会突然增大,供电不足可能导致 ESP32-S3 重启、音频杂音或 Wi-Fi 断线。
- 如果舵机动作干扰音频,可使用独立 5V 电源,并在舵机电源附近增加
470uF~1000uF电解电容。
推荐接线结构:
5V 电源 + ---------------- SG90 红线
5V 电源 GND --------------- SG90 棕线
└------------------- ESP32-S3 GND
ESP32-S3 GPIO21 ----------- SG90 黄线
四、SG90 为什么使用 50Hz PWM
普通 180° SG90 使用周期约为 20ms 的控制脉冲:
频率 = 1 / 20ms = 50Hz
一个周期内,高电平持续时间决定目标角度。常见范围大致如下:
| 高电平脉宽 | 目标位置 |
|---|---|
| 500~700us | 靠近左端 |
| 1500us | 中位 |
| 2300~2500us | 靠近右端 |
不同 SG90 的机械范围存在差异。如果脉宽过于接近机械极限,舵机会发出持续的堵转声并快速发热。因此本次没有直接使用 500us~2500us,而是选择相对保守的:
#define SERVO_LEFT_PULSE_US 700
#define SERVO_CENTER_PULSE_US 1500
#define SERVO_RIGHT_PULSE_US 2300
五、“最大速度摆动”是什么意思
普通 SG90 的转速由舵机内部电机、减速齿轮和控制器决定,ESP32-S3 不能通过提高 PWM 频率让它无限加速。
代码能做的是不给目标角度添加软件缓动:
错误理解:不断增大 PWM 频率来提高速度
本次做法:
700us 目标位置 -> 直接切换到 2300us
2300us 目标位置 -> 直接切换到 700us
目标位置一步跳到另一端后,SG90 内部控制器会用它能达到的最大速度追向新位置。每 350ms 切换一次目标端点,整个动作持续 5 秒:
#define SERVO_GREETING_DURATION_MS 5000
#define SERVO_SWING_HALF_PERIOD_MS 350
350ms 不是控制运动速度,而是给舵机留出接近另一端的时间。间隔太短时,舵机还没到达目标就再次折返,实际摆幅会变小。
六、使用 LEDC 产生舵机 PWM
ESP-IDF 中可以使用 LEDC 外设产生稳定 PWM。虽然 LEDC 名字来自 LED Controller,但它同样适合控制舵机。
首先定义 PWM 参数:
#define SERVO_PWM_GPIO GPIO_NUM_21
#define SERVO_PWM_FREQUENCY_HZ 50
#define SERVO_PWM_PERIOD_US 20000
#define SERVO_LEDC_SPEED_MODE LEDC_LOW_SPEED_MODE
#define SERVO_LEDC_TIMER LEDC_TIMER_0
#define SERVO_LEDC_CHANNEL LEDC_CHANNEL_0
#define SERVO_LEDC_DUTY_RESOLUTION LEDC_TIMER_14_BIT
#define SERVO_LEDC_MAX_DUTY ((1U << 14) - 1U)
LEDC 设置的是占空比计数值,而我们习惯用微秒表示舵机脉宽,因此需要做一次换算:
duty = pulse_us / 20000us * (2^14 - 1)
对应代码:
static uint32_t servo_pulse_us_to_duty(uint32_t pulse_us)
{
return (uint32_t)(((uint64_t)pulse_us * SERVO_LEDC_MAX_DUTY) /
SERVO_PWM_PERIOD_US);
}
更新目标位置时,只需要修改 LEDC 占空比:
static esp_err_t servo_set_pulse_us(uint32_t pulse_us)
{
const uint32_t duty = servo_pulse_us_to_duty(pulse_us);
ESP_RETURN_ON_ERROR(
ledc_set_duty(SERVO_LEDC_SPEED_MODE, SERVO_LEDC_CHANNEL, duty),
TAG,
"set servo duty failed");
ESP_RETURN_ON_ERROR(
ledc_update_duty(SERVO_LEDC_SPEED_MODE, SERVO_LEDC_CHANNEL),
TAG,
"update servo duty failed");
return ESP_OK;
}
这个函数只更新 PWM 目标位置,不会等待舵机完成机械运动,所以调用本身很快。
初始化 LEDC
完整初始化代码如下:
static esp_err_t init_servo(void)
{
const ledc_timer_config_t timer_cfg = {
.speed_mode = SERVO_LEDC_SPEED_MODE,
.duty_resolution = SERVO_LEDC_DUTY_RESOLUTION,
.timer_num = SERVO_LEDC_TIMER,
.freq_hz = SERVO_PWM_FREQUENCY_HZ,
.clk_cfg = LEDC_AUTO_CLK,
.deconfigure = false,
};
ESP_RETURN_ON_ERROR(
ledc_timer_config(&timer_cfg),
TAG,
"init servo LEDC timer failed");
const ledc_channel_config_t channel_cfg = {
.gpio_num = SERVO_PWM_GPIO,
.speed_mode = SERVO_LEDC_SPEED_MODE,
.channel = SERVO_LEDC_CHANNEL,
.intr_type = LEDC_INTR_DISABLE,
.timer_sel = SERVO_LEDC_TIMER,
.duty = servo_pulse_us_to_duty(SERVO_CENTER_PULSE_US),
.hpoint = 0,
.sleep_mode = LEDC_SLEEP_MODE_NO_ALIVE_NO_PD,
.flags = {
.output_invert = 0,
},
};
ESP_RETURN_ON_ERROR(
ledc_channel_config(&channel_cfg),
TAG,
"init servo LEDC channel failed");
return ESP_OK;
}
初始化完成后,舵机会先回到 1500us 对应的中位。
此外还要在组件依赖中加入 LEDC 驱动:
idf_component_register(
SRCS "05_doubao_realtime.c"
INCLUDE_DIRS "."
REQUIRES
esp_driver_gpio
esp_driver_i2s
esp_driver_ledc
# 其他依赖省略
)
七、向豆包注册 Function Tool
豆包 Realtime 会话创建时,可以在 session.tools 中声明设备提供的函数。这个打招呼动作不需要模型生成任何参数,所以 parameters 是一个不允许额外字段的空对象。
#define GREETING_TOOL_NAME "perform_greeting_action"
static esp_err_t add_greeting_tool(cJSON *tools)
{
cJSON *tool = cJSON_CreateObject();
if (tool == NULL) {
return ESP_ERR_NO_MEM;
}
cJSON *parameters = cJSON_AddObjectToObject(tool, "parameters");
cJSON *properties = parameters != NULL
? cJSON_AddObjectToObject(parameters, "properties")
: NULL;
bool ok =
cJSON_AddStringToObject(tool, "type", "function") != NULL &&
cJSON_AddStringToObject(tool, "name", GREETING_TOOL_NAME) != NULL &&
cJSON_AddStringToObject(
tool,
"description",
"当用户要求打招呼、挥手、摆动舵机或执行打招呼动作时,"
"必须调用本工具。每次请求都要调用,即使刚执行过也不能只做口头回复。"
"设备会让SG90高速左右摆动5秒。") != NULL &&
parameters != NULL &&
cJSON_AddStringToObject(parameters, "type", "object") != NULL &&
properties != NULL &&
cJSON_AddFalseToObject(parameters, "additionalProperties") != NULL;
if (!ok || !cJSON_AddItemToArray(tools, tool)) {
cJSON_Delete(tool);
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
然后在 send_session_create() 中加入会话配置:
cJSON_AddItemToObject(session, "tools", tools);
ESP_ERROR_CHECK(add_greeting_tool(tools));
为什么还要补充系统提示词
只注册工具后,模型知道“可以调用”,但不一定每次都调用。例如用户说“给我打个招呼”,模型有时可能认为只说一句“你好”也满足要求。
因此还要在系统提示词中明确工具规则:
#define GREETING_TOOL_INSTRUCTION \
"\n工具调用规则:只要用户要求打招呼、挥手、摆动舵机或执行打招呼动作," \
"每次都必须调用 perform_greeting_action;即使刚执行过也必须再次调用," \
"不能只用语言回复。"
创建会话时,将它追加到原有角色提示词后:
cJSON_AddStringToObject(
session,
"instructions",
CONFIG_DOUBAO_SYSTEM_PROMPT GREETING_TOOL_INSTRUCTION);
工具描述和系统提示词的职责有所不同:
工具 description:说明工具能做什么、何时适合调用
系统 instructions:规定对话策略,要求模型在特定意图下必须调用
八、接收模型的 Function Call
模型决定调用工具后,豆包会下发:
response.function_call_arguments.done
事件中可能同时包含多个调用,因此代码读取的是 items 数组,而不是只处理一个固定对象。每个 item 至少要关注三个字段:
| 字段 | 作用 |
|---|---|
name | 模型选择的工具名 |
call_id | 本次调用的唯一标识 |
arguments | 模型生成的参数 JSON 字符串 |
本次工具不需要参数,所以 arguments 通常是:
{}
在 WebSocket JSON 分发函数中增加事件分支:
} else if (strcmp(type->valuestring,
"response.function_call_arguments.done") == 0) {
handle_function_calls(
cJSON_GetObjectItemCaseSensitive(root, "items"));
}
九、为什么必须原样返回 call_id
call_id 用来对应“哪一次函数调用”和“哪一个执行结果”。即使工具名相同,每次调用的 call_id 也不同。
ESP32-S3 返回结果时使用 conversation.item.create:
{
"type": "conversation.item.create",
"event_id": "本地生成的事件ID",
"items": [
{
"call_id": "模型下发的call_id",
"role": "tool",
"content": [
{
"type": "input_text",
"text": "{\"status\":\"已执行\"}"
}
]
}
]
}
容易踩坑的地方有两个:
call_id必须使用模型下发的原值,不能自己重新生成。content[].text是字符串。即使返回内容采用 JSON,也要先序列化成 JSON 字符串。
十、立即返回“已执行”
工具处理函数需要遍历全部调用,识别工具名,并聚合返回结果。核心逻辑如下:
static void handle_function_calls(cJSON *calls)
{
if (!cJSON_IsArray(calls)) {
ESP_LOGW(TAG, "function call event has no items array");
return;
}
cJSON *result_root = cJSON_CreateObject();
cJSON *result_items = cJSON_CreateArray();
char event_id[33];
make_random_hex_id(event_id, 32);
cJSON_AddStringToObject(
result_root, "type", "conversation.item.create");
cJSON_AddStringToObject(result_root, "event_id", event_id);
cJSON_AddItemToObject(result_root, "items", result_items);
bool trigger_greeting = false;
cJSON *call = NULL;
cJSON_ArrayForEach(call, calls) {
cJSON *call_id =
cJSON_GetObjectItemCaseSensitive(call, "call_id");
cJSON *name =
cJSON_GetObjectItemCaseSensitive(call, "name");
if (!cJSON_IsString(call_id) || !cJSON_IsString(name)) {
continue;
}
bool known_tool =
strcmp(name->valuestring, GREETING_TOOL_NAME) == 0;
const char *result_text = known_tool
? "{\"status\":\"已执行\"}"
: "{\"status\":\"未执行\",\"error\":\"unknown_tool\"}";
cJSON *result_item = cJSON_CreateObject();
cJSON *content = cJSON_CreateArray();
cJSON *content_item = cJSON_CreateObject();
cJSON_AddStringToObject(
result_item, "call_id", call_id->valuestring);
cJSON_AddStringToObject(result_item, "role", "tool");
cJSON_AddStringToObject(content_item, "type", "input_text");
cJSON_AddStringToObject(content_item, "text", result_text);
cJSON_AddItemToArray(content, content_item);
cJSON_AddItemToObject(result_item, "content", content);
cJSON_AddItemToArray(result_items, result_item);
trigger_greeting = trigger_greeting || known_tool;
}
char *result_json = cJSON_PrintUnformatted(result_root);
cJSON_Delete(result_root);
// 先向模型返回执行状态。
esp_err_t send_err = websocket_send_text(result_json);
free(result_json);
// 再唤醒独立的舵机任务。
if (trigger_greeting && s_servo_task_handle != NULL) {
xTaskNotifyGive(s_servo_task_handle);
}
}
实际工程还补充了内存分配失败、非法调用项、未知工具和 WebSocket 发送失败等检查,这里保留主要流程。
处理顺序特意写成:
先 websocket_send_text("已执行")
再 xTaskNotifyGive(servo_task)
这样工具结果不会等待 5 秒动作结束。模型可以立即知道调用已经被设备接受,而舵机在后台执行实际动作。
十一、用独立 FreeRTOS 任务执行动作
舵机任务在没有工具调用时永久休眠,不会持续轮询 GPIO:
static void servo_greeting_task(void *arg)
{
(void)arg;
while (true) {
// 没有工具调用时休眠,不消耗 CPU。
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
TickType_t deadline =
xTaskGetTickCount() +
pdMS_TO_TICKS(SERVO_GREETING_DURATION_MS);
bool move_left = true;
while ((int32_t)(deadline - xTaskGetTickCount()) > 0) {
ESP_ERROR_CHECK(
servo_set_pulse_us(
move_left
? SERVO_LEFT_PULSE_US
: SERVO_RIGHT_PULSE_US));
move_left = !move_left;
TickType_t now = xTaskGetTickCount();
int32_t remaining = (int32_t)(deadline - now);
if (remaining <= 0) {
break;
}
TickType_t wait_ticks =
pdMS_TO_TICKS(SERVO_SWING_HALF_PERIOD_MS);
if (remaining < (int32_t)wait_ticks) {
wait_ticks = (TickType_t)remaining;
}
// 动作过程中再次收到调用时,重新计时 5 秒。
if (ulTaskNotifyTake(pdTRUE, wait_ticks) > 0) {
deadline =
xTaskGetTickCount() +
pdMS_TO_TICKS(SERVO_GREETING_DURATION_MS);
}
}
ESP_ERROR_CHECK(
servo_set_pulse_us(SERVO_CENTER_PULSE_US));
}
}
任务通知非常适合这种“发生一个动作”的场景:
xTaskNotifyGive():发送一次动作通知
ulTaskNotifyTake():等待并消费通知
相比专门创建一个消息队列,任务通知占用的内存更少,逻辑也更直接。
重复调用怎么处理
如果用户在舵机摆动期间说“再来一次”,任务不会被重复创建。新的通知会被当前任务消费,并把截止时间更新为:
当前时刻 + 5 秒
这意味着动作会从第二次调用开始继续运行 5 秒。只有一个任务负责 LEDC,也避免了多个任务同时写 PWM 导致竞争。
十二、在 app_main 中初始化
先初始化 LEDC,再创建舵机任务:
ESP_ERROR_CHECK(init_buttons());
ESP_ERROR_CHECK(init_servo());
ESP_ERROR_CHECK(init_i2s_mic_rx());
ESP_ERROR_CHECK(init_i2s_amp_tx());
BaseType_t task_ok = xTaskCreate(
servo_greeting_task,
"servo_greeting_task",
3072,
NULL,
4,
&s_servo_task_handle);
ESP_ERROR_CHECK(task_ok == pdPASS ? ESP_OK : ESP_ERR_NO_MEM);
舵机任务优先级不需要高于音频链路。音频采集、播放和网络收发都有严格时序,而舵机每隔几百毫秒才更新一次目标位置,使用较低优先级即可。
十三、在 0.96 寸 OLED 上显示双行对话
实时语音已经可以“听、说、行动”,但只听声音不方便观察模型实际识别了什么。扩展板提供了一组 I2C OLED 接口,因此本次又增加了一个很实用的反馈通道:
第一行 U: 用户实时转写
第二行 AI: 模型流式回复
这里必须再次明确数据顺序。麦克风声音不是直接发送给模型,而是先进入 ESP32-S3:
数字麦克风
↓ I2S PCM
ESP32-S3 + ESP-SR AFE AEC
↓ WebSocket
豆包 Realtime
↓ 流式文本事件
ESP32-S3
↓ I2C
SSD1306 OLED
OLED 接线
本次使用 128x32 SSD1306 OLED,供电和 I2C 引脚如下:
| OLED 引脚 | ESP32-S3 | 说明 |
|---|---|---|
| GND | GND | 电源地 |
| VCC | 3.3V | OLED 供电 |
| SCL | GPIO42 | I2C 时钟 |
| SDA | GPIO41 | I2C 数据 |
SSD1306 常见的 7-bit I2C 地址是 0x3C,少部分模块是 0x3D。初始化时依次探测两个地址,OLED 没有安装时只打印警告,不能影响实时音频主流程。
#define OLED_I2C_PORT I2C_NUM_0
#define OLED_SDA_GPIO GPIO_NUM_41
#define OLED_SCL_GPIO GPIO_NUM_42
#define OLED_I2C_FREQUENCY_HZ 400000
#define OLED_PRIMARY_ADDRESS 0x3C
#define OLED_SECONDARY_ADDRESS 0x3D
OLED 使用 U8g2 完成 SSD1306 绘制和中文字形渲染,RGB 使用 Espressif 的 led_strip 组件。对应依赖统一写在 main/idf_component.yml:
dependencies:
espressif/led_strip: "^3.0.0"
u8g2:
git: https://github.com/olikraus/u8g2.git
version: ab9e48b2228351e9476682a70b7f3ee4909cd585
执行 idf.py reconfigure 或首次编译时,ESP-IDF Component Manager 会自动下载依赖。
不等待 completed,直接显示流式 delta
用户说话时,豆包会持续发送转写增量,而不是等整句话结束后才一次性返回。这里关注三个事件:
| Realtime 事件 | 用途 |
|---|---|
conversation.item.input_audio_transcription.delta | 追加用户流式转写 |
conversation.item.input_audio_transcription.completed | 用最终结果修正用户行 |
response.output_text.delta | 追加 AI 流式回复 |
如果只处理 completed,屏幕会在用户说完后突然出现整句话,缺少实时反馈。使用 delta 后,每识别出几个字就能更新一次 OLED;completed 仍然保留,因为最终转写可能修正前面的流式结果。
WebSocket 事件处理中的关键代码如下:
if (strcmp(type->valuestring,
"conversation.item.input_audio_transcription.delta") == 0) {
cJSON *delta = cJSON_GetObjectItem(root, "text");
if (!cJSON_IsString(delta)) {
delta = cJSON_GetObjectItem(root, "delta");
}
if (cJSON_IsString(delta) && delta->valuestring != NULL) {
oled_display_append(OLED_SPEAKER_USER, delta->valuestring);
}
} else if (strcmp(type->valuestring,
"conversation.item.input_audio_transcription.completed") == 0) {
cJSON *transcript = cJSON_GetObjectItem(root, "transcript");
if (cJSON_IsString(transcript) && transcript->valuestring != NULL) {
oled_display_replace(OLED_SPEAKER_USER, transcript->valuestring);
}
} else if (strcmp(type->valuestring, "response.output_text.delta") == 0) {
cJSON *delta = cJSON_GetObjectItem(root, "delta");
if (cJSON_IsString(delta) && delta->valuestring != NULL) {
oled_display_append(OLED_SPEAKER_AI, delta->valuestring);
}
}
用户和 AI 必须使用独立缓冲
最初版本只有一个文本缓冲,并用 USER 或 AI 表示当前说话方。这样虽然简单,但说话方一切换,上一方的内容就会被覆盖。
128x32 屏幕纵向能够放下两行 12px 中文,因此后来把状态拆成两个独立缓冲:
#define OLED_TEXT_BUFFER_SIZE 768
typedef struct {
char text[OLED_TEXT_BUFFER_SIZE];
size_t text_len;
} oled_text_line_t;
typedef struct {
oled_speaker_t active_speaker;
oled_text_line_t user;
oled_text_line_t ai;
} oled_text_state_t;
active_speaker 用于判断是否开始了新一轮发言。用户开始说话时只清空用户行,AI 开始回复时只清空 AI 行,另一行继续保留上一轮内容:
oled_text_line_t *line = text_line_for_speaker(&s_text_state, speaker);
if (s_text_state.active_speaker != speaker) {
s_text_state.active_speaker = speaker;
line->text_len = 0;
line->text[0] = '\0';
}
memcpy(line->text + line->text_len, utf8_text, incoming_len);
line->text_len += incoming_len;
line->text[line->text_len] = '\0';
最终布局左侧使用紧凑的 ASCII 标签,右侧使用 U8g2 的文泉驿 12px GB2312 字体:
#define OLED_WIDTH_PX 128
#define OLED_SPEAKER_LABEL_WIDTH_PX 14
#define OLED_USER_TEXT_BASELINE_Y 13
#define OLED_AI_TEXT_BASELINE_Y 29
u8g2_SetFont(&s_u8g2, u8g2_font_5x7_tr);
u8g2_DrawStr(&s_u8g2, 0, 11, "U:");
u8g2_DrawStr(&s_u8g2, 0, 27, "AI:");
u8g2_SetFont(&s_u8g2, u8g2_font_wqy12_t_gb2312);
u8g2_DrawUTF8(&s_u8g2, 14, 13, visible_user_text);
u8g2_DrawUTF8(&s_u8g2, 14, 29, visible_ai_text);
中文滚动必须沿 UTF-8 字符边界
一个中文 UTF-8 字符通常占 3 个字节,不能简单地从字符串的任意字节位置截取。否则很容易从汉字中间切开,屏幕上出现乱码。
实现中先从末尾向前寻找完整 UTF-8 字符,再用 u8g2_GetUTF8Width() 测量实际像素宽度。如果一行仍然超过可用宽度,就继续删除最前面的完整字符,直到最新内容能够放进屏幕:
while (offset < text_len &&
u8g2_GetUTF8Width(&s_u8g2, text + offset) > max_width_px) {
offset = utf8_next_offset(text, text_len, offset);
}
这样处理后,长句不会缩小字体,而是像终端一样持续保留最新的一段内容。
OLED 刷新不能阻塞 WebSocket
字体排版和 u8g2_SendBuffer() 都需要时间。如果每收到一个 delta 就直接在 WebSocket 回调中刷新 I2C,密集文本事件可能拖慢音频分片接收,引起播放卡顿或上行队列积压。
因此 WebSocket 任务只做三件事:
加锁 -> 更新内存文本 -> 通知 OLED 任务
OLED 使用独立的低优先级 FreeRTOS 任务。它收到通知后等待 100ms,把这个窗口内连续到达的多个汉字合并成一次刷新,最多约 10FPS:
#define OLED_REFRESH_INTERVAL_MS 100
#define OLED_TASK_STACK_SIZE 6144
#define OLED_TASK_PRIORITY 3
static void oled_display_task(void *arg)
{
oled_text_state_t snapshot;
while (true) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(OLED_REFRESH_INTERVAL_MS));
ulTaskNotifyTake(pdTRUE, 0);
if (xSemaphoreTake(s_text_mutex, pdMS_TO_TICKS(20)) != pdTRUE) {
continue;
}
memcpy(&snapshot, &s_text_state, sizeof(snapshot));
xSemaphoreGive(s_text_mutex);
draw_oled_frame(&snapshot);
}
}
这里的 snapshot 很重要:复制完成后立即释放互斥锁,耗时的 U8g2 排版和 I2C 发送都基于快照完成,不会长时间阻塞 WebSocket 对文本缓冲的更新。
双行改造时遇到的栈溢出
第一次把单行改成双行后,屏幕一直停在“正在启动”,串口则反复出现:
***ERROR*** A stack overflow in task oled_display_ta has been detected.
原因是 OLED 任务原本只有 4KB 栈。双行状态快照约占 1.5KB,而早期的 draw_oled_frame() 又把用户和 AI 文本各复制一次到局部数组中,三个大对象叠加后挤爆了任务栈。
最终做了两项修复:
draw_oled_frame()直接读取任务持有的稳定快照,不再重复复制两行文本。- OLED 任务栈从 4KB 增加到 6KB,为 U8g2 字形查找和 I2C 调用保留余量。
这次问题也说明,ESP32-S3 虽然带有 8MB PSRAM,但 FreeRTOS 任务栈默认仍然是有限的内部内存。增加局部大数组时,不能只关注整个系统还剩多少堆内存。
十四、用板载 RGB 灯显示对话状态
OLED 适合显示文字,但用户不一定始终盯着屏幕。核心板中间还有一颗带乳白灯罩的可寻址 RGB LED,可以用颜色快速表示设备当前处于哪个对话阶段:
| Realtime 状态 | RGB 颜色 |
|---|---|
| 其他时间,包括启动、空闲和断线 | 白色 |
用户转写 started 到 completed | 红色 |
AI 音频 started 到 done | 绿色 |
先确认它不是普通三色 LED
这颗大方形灯并不是由三个 GPIO 分别控制 R、G、B 的普通共阳或共阴 LED,而是一颗兼容 WS2812 的单线可寻址灯珠。实板使用独立探测固件逐个尝试可用 GPIO 后,最终确认其数据输入连接到 GPIO48。
板上另外还有红色 PWR 灯和蓝色 TX/RX 灯,它们是独立的电源、串口指示灯,不受这里的 RGB 驱动控制。
ESP-IDF 可以直接使用 led_strip 组件,通过 RMT 外设生成 WS2812 所需的亚微秒波形:
#define STATUS_LED_GPIO 48
#define STATUS_LED_COUNT 1
static led_strip_handle_t s_led_strip;
const led_strip_config_t strip_config = {
.strip_gpio_num = STATUS_LED_GPIO,
.max_leds = STATUS_LED_COUNT,
.led_model = LED_MODEL_WS2812,
.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB,
};
const led_strip_rmt_config_t rmt_config = {
.resolution_hz = 10 * 1000 * 1000,
.flags.with_dma = false,
};
ESP_ERROR_CHECK(led_strip_new_rmt_device(
&strip_config, &rmt_config, &s_led_strip));
这里有三个容易混淆的地方:
GPIO48是单根数据线,不是绿色通道。- 灯珠内部按 GRB 顺序接收颜色,所以显式指定
LED_STRIP_COLOR_COMPONENT_FMT_GRB。 resolution_hz = 10MHz表示 RMT 的时间分辨率为0.1us,不是让 LED 以 10MHz 闪烁。
把颜色封装成三个业务状态
上层代码不应该到处传递 RGB 数值,而是只关心“用户、AI、其他”三个业务状态:
typedef enum {
STATUS_LED_IDLE = 0, // 其他时间:白色
STATUS_LED_USER, // 用户讲话:红色
STATUS_LED_AI, // AI 回复:绿色
} status_led_state_t;
static void apply_led_state(status_led_state_t state)
{
switch (state) {
case STATUS_LED_USER:
led_strip_set_pixel(s_led_strip, 0, 64, 0, 0);
break;
case STATUS_LED_AI:
led_strip_set_pixel(s_led_strip, 0, 0, 64, 0);
break;
case STATUS_LED_IDLE:
default:
led_strip_set_pixel(s_led_strip, 0, 32, 32, 32);
break;
}
led_strip_refresh(s_led_strip);
}
WS2812 每个通道的范围是 0~255。实物隔着乳白灯罩,64 已经足够醒目;空闲白色同时点亮三个通道,因此每个通道使用更低的 32,避免过亮。
初始化后先执行一次“红色 1 秒 -> 绿色 1 秒 -> 白色”的上电自检。这样还没连上 Wi-Fi 就能确认 GPIO、RMT、颜色顺序和灯珠本身是否正常。
颜色必须跟随服务端协议事件
早期版本曾根据本地麦克风能量判断用户是否讲话,也曾根据 MAX98357 播放缓冲是否有数据判断 AI 是否讲话。实际效果总是错位,原因是这些本地现象和服务端语义并不等价:
麦克风有能量 != 服务端已经进入用户转写区间
播放缓冲暂时为空 != AI 的一轮音频回复已经结束
最终版本严格以豆包 Realtime 事件作为颜色边界:
conversation.item.input_audio_transcription.started
-> 用户区间开始,亮红色
conversation.item.input_audio_transcription.completed
-> 用户区间结束
response.output_audio.started
-> AI 音频区间开始,亮绿色
response.output_audio.done
-> AI 音频区间结束,立即恢复白色
response.done / response.canceled
-> 无音频回复或取消场景的兜底清理
注意这里以 response.output_audio.done 作为绿色的正常结束边界,而不是继续等 response.done。音频已经结束时,RGB 就应该立即退出绿色。
插话时需要两个状态标志
全双工对话中,用户可能在 AI 讲话时插话。此时“用户转写区间”和“AI 音频区间”会短暂重叠,不能只用一个当前状态变量,否则后到的事件会把仍然有效的前一个区间覆盖掉。
代码分别保存两个布尔值,并定义固定优先级:
用户红色 > AI 绿色 > 其他白色
核心代码如下:
static portMUX_TYPE s_realtime_led_mux = portMUX_INITIALIZER_UNLOCKED;
static bool s_user_transcription_active;
static bool s_response_output_active;
static void sync_status_led_from_realtime_events(void)
{
bool user_active;
bool response_active;
portENTER_CRITICAL(&s_realtime_led_mux);
user_active = s_user_transcription_active;
response_active = s_response_output_active;
portEXIT_CRITICAL(&s_realtime_led_mux);
if (user_active) {
status_led_set(STATUS_LED_USER);
} else if (response_active) {
status_led_set(STATUS_LED_AI);
} else {
status_led_set(STATUS_LED_IDLE);
}
}
事件处理函数只负责修改对应标志:
if (strcmp(type->valuestring,
"conversation.item.input_audio_transcription.started") == 0) {
set_user_transcription_active(true);
} else if (strcmp(type->valuestring,
"conversation.item.input_audio_transcription.completed") == 0) {
set_user_transcription_active(false);
} else if (strcmp(type->valuestring, "response.output_audio.started") == 0) {
set_response_output_active(true);
} else if (strcmp(type->valuestring, "response.output_audio.done") == 0) {
set_response_output_active(false);
} else if (strcmp(type->valuestring, "response.done") == 0 ||
strcmp(type->valuestring, "response.canceled") == 0) {
set_response_output_active(false);
}
如果 AI 讲话时用户开始转写,灯会从绿色切到红色;用户转写完成后,如果 AI 音频区间仍未结束,就重新显示绿色。若收到 response.output_audio.done 时用户仍在转写,则保持红色;等用户 completed 后再恢复白色。
RMT 发送也放到独立任务
与 OLED 相同,WebSocket 解析路径不直接操作外设。status_led_set() 只向长度为 4 的 FreeRTOS 队列投递枚举值,低优先级 status_led_task 再更新灯珠:
while (true) {
if (xQueueReceive(s_state_queue,
&state,
pdMS_TO_TICKS(100)) == pdPASS) {
if (state != applied_state) {
apply_led_state(state);
applied_state = state;
}
} else {
led_strip_refresh(s_led_strip);
}
}
当前实板上的兼容灯珠偶尔会漏掉单次发送,因此任务每 100ms 重发一次当前像素缓冲。这里只发送一个像素的 24bit 波形,开销很小,同时让颜色保持更稳定。
OLED 和 RGB 都作为可选反馈外设初始化。即使没有安装 OLED,或者 RGB 初始化失败,也只输出警告,不能阻止 AEC、WebSocket 和实时对话继续运行:
esp_err_t status_led_err = status_led_init();
if (status_led_err != ESP_OK) {
ESP_LOGW(TAG, "status RGB LED disabled: %s",
esp_err_to_name(status_led_err));
}
esp_err_t oled_err = oled_display_init();
if (oled_err != ESP_OK) {
ESP_LOGW(TAG, "OLED disabled: %s", esp_err_to_name(oled_err));
}
十五、板上验证
编译与烧录:
cd projects/05_doubao_realtime
idf.py build
idf.py -p /dev/cu.usbmodem101 flash monitor
启动日志显示 RGB、OLED、SG90、AEC、Wi-Fi 和 Realtime 会话均已初始化:
onboard WS2812 ready: GPIO48 GRB USER=red AI=green OTHER=white refresh=100ms
startup self-test: RGB=red 1000ms -> green 1000ms -> white
SSD1306 ready: 128x32 address=0x3C SDA=GPIO41 SCL=GPIO42 font=WQY12 GB2312
SG90 ready: GPIO21 50Hz left=700us center=1500us right=2300us
ESP-SR AFE AEC ready: input=MR mode=FD_LOW_COST
Wi-Fi connected
websocket connected
session.created
对开发板说:
你给我打个招呼吧?
实际串口日志如下:
user transcript: 你给我打个招呼吧?
function call: name=perform_greeting_action
function result returned immediately: status=已执行 items=1
SG90 greeting action started: duration=5000ms, maximum-speed endpoint switching
response.output_audio.started
response.output_audio.done
response.done
SG90 greeting action finished: returned to center
从时间顺序可以确认:
- 模型正确理解了用户意图。
- ESP32-S3 正确识别工具名和
call_id。 - 工具结果在动作开始前立即返回。
- 舵机摆动期间,模型语音仍能正常播放。
- 5 秒后舵机自动回到中位。
- OLED 第一行显示用户转写,第二行同步显示 AI 回复。
- 用户转写期间 RGB 为红色,AI 音频期间为绿色,其余时间恢复白色。
这说明 Function Calling、WebSocket、实时音频、舵机 PWM、OLED 流式字幕和 RGB 状态提示能够同时工作。
十六、本次遇到的几个关键问题
1. 模型只说“你好”,却不调用工具
仅仅注册工具不代表模型一定使用工具。解决办法是同时强化:
工具 description
系统 instructions
并明确写出“每次都必须调用,不能只做口头回复”。
2. 不能在 WebSocket 处理函数中摆动 5 秒
任何 vTaskDelay()、长循环或机械动作等待都不应该放在 WebSocket 接收路径中。正确方式是发送任务通知,把动作交给独立任务。
3. 工具结果不能等动作完成再返回
本次工具的语义是“设备已经接受并开始执行”,不是“整个动作已经完成”。所以应立即返回“已执行”,提高对话响应速度。
如果未来某个工具必须等待最终结果,例如读取传感器或执行校准,可以返回:
{"status":"执行中"}
或者让任务结束后再发送新的状态事件,不能把所有工具都套成同一种处理方式。
4. 相同工具的 call_id 不能复用
用户每说一次“再来一次”,模型都会生成新的 call_id。返回结果必须与当前调用逐一对应,否则服务端无法知道结果属于哪次调用。
5. 舵机供电会影响实时音频
SG90 高速换向会产生明显的瞬时电流。电源压降和电磁干扰可能表现为:
喇叭杂音
麦克风底噪增大
Wi-Fi 丢包
ESP32-S3 Brownout 或重启
因此硬件供电不是附属问题,而是实时语音设备稳定性的一部分。
6. OLED 刷新也属于实时系统的一部分
OLED 看似只是一个低速外设,但字体排版、UTF-8 宽度计算和 I2C 全屏刷新都会占用 CPU 时间。把这些工作直接塞进 WebSocket 回调,同样会破坏实时音频链路。
显示模块也应该遵守与舵机任务相同的原则:事件处理路径只更新状态,具体工作交给独立任务异步完成。
7. RGB 状态不能根据本地音量猜测
本地麦克风能量、音频播放队列和服务端对话状态之间存在延迟,也不是一一对应关系。根据它们猜测状态,会出现用户讲话时还是绿灯、AI 已经说完却迟迟不恢复等问题。
最终应以 Realtime 协议事件定义状态边界,并为可能重叠的用户、AI 区间分别保存标志。状态显示因此也成为协议状态机的一部分,而不是音频任务的附属判断。
十七、这次学到了什么
完成这个 Demo 后,Realtime 对话不再只是音频输入和音频输出:
听见用户
↓
理解自然语言意图
↓
选择结构化工具
↓
ESP32-S3 执行真实动作
↓
把执行结果反馈给模型
其中最重要的工程经验是:
- Function Calling 是模型和设备之间的结构化协议,不是模型直接运行本地函数。
- 工具声明必须清楚描述能力、参数和调用条件。
call_id是请求和结果之间的对应关系,必须原样返回。- 实时网络路径只负责快速解析和调度,耗时动作交给独立任务。
- “立即返回”和“动作完成”是两个不同时间点,要根据工具语义设计。
- 舵机最大速度来自直接切换目标位置,而不是提高 PWM 频率。
- 模型行为不仅取决于代码,也取决于工具描述和系统提示词。
- 让 AI 控制物理设备时,还要同时考虑供电、并发和故障边界。
- Realtime 的文本 delta 可以直接驱动屏幕,不需要等完整转写结束。
- 中文截取必须遵守 UTF-8 边界,并按照字体实际像素宽度决定显示范围。
- OLED 刷新要与网络、AEC 和音频任务隔离,同时警惕大局部数组造成任务栈溢出。
- WS2812 是单线时序器件,可以使用 ESP-IDF 的
led_strip和 RMT 驱动。 - 用户与 AI 状态可能在插话时重叠,需要独立标志和明确的显示优先级。
- RGB 的颜色边界应来自 Realtime 协议事件,不能根据本地音量或播放缓冲猜测。
到这一步,这块 ESP32-S3 已经从实时语音终端继续向 AI Agent 设备演进:模型不仅能听和说,还能通过工具调用影响真实世界。
后面可以继续扩展:
- 为 OLED 增加联网、执行工具和错误状态图标。
- 增加更多工具,例如读取电量、控制灯光和查询传感器。
- 给舵机动作增加参数,例如方向、次数和持续时间。
- 增加工具白名单、参数范围检查和执行超时。
- 将 Function Calling 抽象成统一的工具注册表,避免大量
if/else。 - 让 Qwen Omni Realtime 和豆包共用同一套本地硬件工具。