ESP32-S3 IDF实时语音开发:Function Calling与SG90舵机联动
上一篇《ESP32-S3 IDF实时语音开发:Realtime直连与回声消除》 完成了 ESP32-S3 直连 Qwen Omni Realtime 和豆包 Realtime,并逐步解决了流式音频播放、AEC 回声消除和插话打断问题。到这里,开发板已经能够持续听、持续说,也能在模型讲话时接受新的语音输入。
这一次继续向前一步:让 Realtime 模型不只生成声音,还能通过 Function Calling 控制真实硬件。当用户说“给我打个招呼”时,豆包会调用 ESP32-S3 注册的工具;开发板立即返回“已执行”,同时启动一个独立任务,让 SG90 舵机以最大速度左右摆动 5 秒。
对应工程:
projects/05_doubao_realtime
最终交互效果如下:
用户:你给我打个招呼吧?
↓
豆包:调用 perform_greeting_action
↓
ESP32-S3:立即返回 {"status":"已执行"}
↓
SG90:高速左右摆动 5 秒,结束后回到中位
↓
豆包:继续通过语音和用户交流
一、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 秒计时
三、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);
舵机任务优先级不需要高于音频链路。音频采集、播放和网络收发都有严格时序,而舵机每隔几百毫秒才更新一次目标位置,使用较低优先级即可。
十三、板上验证
编译与烧录:
cd projects/05_doubao_realtime
idf.py build
idf.py -p /dev/cu.usbmodem101 flash monitor
启动日志显示 SG90、AEC、Wi-Fi 和 Realtime 会话均已初始化:
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 秒后舵机自动回到中位。
这说明 Function Calling、WebSocket、实时音频和舵机 PWM 四条逻辑已经能同时工作。
十四、本次遇到的几个关键问题
1. 模型只说“你好”,却不调用工具
仅仅注册工具不代表模型一定使用工具。解决办法是同时强化:
工具 description
系统 instructions
并明确写出“每次都必须调用,不能只做口头回复”。
2. 不能在 WebSocket 处理函数中摆动 5 秒
任何 vTaskDelay()、长循环或机械动作等待都不应该放在 WebSocket 接收路径中。正确方式是发送任务通知,把动作交给独立任务。
3. 工具结果不能等动作完成再返回
本次工具的语义是“设备已经接受并开始执行”,不是“整个动作已经完成”。所以应立即返回“已执行”,提高对话响应速度。
如果未来某个工具必须等待最终结果,例如读取传感器或执行校准,可以返回:
{"status":"执行中"}
或者让任务结束后再发送新的状态事件,不能把所有工具都套成同一种处理方式。
4. 相同工具的 call_id 不能复用
用户每说一次“再来一次”,模型都会生成新的 call_id。返回结果必须与当前调用逐一对应,否则服务端无法知道结果属于哪次调用。
5. 舵机供电会影响实时音频
SG90 高速换向会产生明显的瞬时电流。电源压降和电磁干扰可能表现为:
喇叭杂音
麦克风底噪增大
Wi-Fi 丢包
ESP32-S3 Brownout 或重启
因此硬件供电不是附属问题,而是实时语音设备稳定性的一部分。
十五、这次学到了什么
完成这个 Demo 后,Realtime 对话不再只是音频输入和音频输出:
听见用户
↓
理解自然语言意图
↓
选择结构化工具
↓
ESP32-S3 执行真实动作
↓
把执行结果反馈给模型
其中最重要的工程经验是:
- Function Calling 是模型和设备之间的结构化协议,不是模型直接运行本地函数。
- 工具声明必须清楚描述能力、参数和调用条件。
call_id是请求和结果之间的对应关系,必须原样返回。- 实时网络路径只负责快速解析和调度,耗时动作交给独立任务。
- “立即返回”和“动作完成”是两个不同时间点,要根据工具语义设计。
- 舵机最大速度来自直接切换目标位置,而不是提高 PWM 频率。
- 模型行为不仅取决于代码,也取决于工具描述和系统提示词。
- 让 AI 控制物理设备时,还要同时考虑供电、并发和故障边界。
到这一步,这块 ESP32-S3 已经从实时语音终端继续向 AI Agent 设备演进:模型不仅能听和说,还能通过工具调用影响真实世界。
后面可以继续扩展:
- 为 OLED 增加聆听、思考、说话和执行动作状态。
- 增加更多工具,例如读取电量、控制灯光和查询传感器。
- 给舵机动作增加参数,例如方向、次数和持续时间。
- 增加工具白名单、参数范围检查和执行超时。
- 将 Function Calling 抽象成统一的工具注册表,避免大量
if/else。 - 让 Qwen Omni Realtime 和豆包共用同一套本地硬件工具。