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 模型通常有两种输出方式:

  1. 直接生成文字或语音。
  2. 请求客户端执行一个提前声明好的工具。

例如用户说“给我打个招呼”,模型本身无法直接控制 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舵机电源
黄色GPIO21PWM 控制信号

本次选择 GPIO21,它没有被当前工程中的麦克风、MAX98357、音量按键和 ESP32-S3-N16R8 的 Octal PSRAM 占用。

需要特别注意:

  1. SG90 的红线接 5V,不要接 ESP32-S3 的 3.3V。
  2. 舵机电源地和 ESP32-S3 的 GND 必须共地,否则 PWM 没有共同参考电平。
  3. 舵机启动和换向时电流会突然增大,供电不足可能导致 ESP32-S3 重启、音频杂音或 Wi-Fi 断线。
  4. 如果舵机动作干扰音频,可使用独立 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\":\"已执行\"}"
        }
      ]
    }
  ]
}

容易踩坑的地方有两个:

  1. call_id 必须使用模型下发的原值,不能自己重新生成。
  2. 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

从时间顺序可以确认:

  1. 模型正确理解了用户意图。
  2. ESP32-S3 正确识别工具名和 call_id
  3. 工具结果在动作开始前立即返回。
  4. 舵机摆动期间,模型语音仍能正常播放。
  5. 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 执行真实动作
把执行结果反馈给模型

其中最重要的工程经验是:

  1. Function Calling 是模型和设备之间的结构化协议,不是模型直接运行本地函数。
  2. 工具声明必须清楚描述能力、参数和调用条件。
  3. call_id 是请求和结果之间的对应关系,必须原样返回。
  4. 实时网络路径只负责快速解析和调度,耗时动作交给独立任务。
  5. “立即返回”和“动作完成”是两个不同时间点,要根据工具语义设计。
  6. 舵机最大速度来自直接切换目标位置,而不是提高 PWM 频率。
  7. 模型行为不仅取决于代码,也取决于工具描述和系统提示词。
  8. 让 AI 控制物理设备时,还要同时考虑供电、并发和故障边界。

到这一步,这块 ESP32-S3 已经从实时语音终端继续向 AI Agent 设备演进:模型不仅能听和说,还能通过工具调用影响真实世界。

后面可以继续扩展:

  1. 为 OLED 增加聆听、思考、说话和执行动作状态。
  2. 增加更多工具,例如读取电量、控制灯光和查询传感器。
  3. 给舵机动作增加参数,例如方向、次数和持续时间。
  4. 增加工具白名单、参数范围检查和执行超时。
  5. 将 Function Calling 抽象成统一的工具注册表,避免大量 if/else
  6. 让 Qwen Omni Realtime 和豆包共用同一套本地硬件工具。