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

  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 秒计时

显示与状态提示也采用相同的异步思路:

Realtime 文本事件 -> 更新双行文本缓冲 -> oled_display_task -> I2C OLED
Realtime 状态事件 -> 计算对话状态     -> status_led_task  -> RMT RGB

WebSocket 任务只解析事件和更新状态,I2C 刷屏、RMT 发送和舵机动作都交给各自的低优先级任务,避免这些外设拖慢实时音频链路。

三、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);

舵机任务优先级不需要高于音频链路。音频采集、播放和网络收发都有严格时序,而舵机每隔几百毫秒才更新一次目标位置,使用较低优先级即可。

十三、在 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说明
GNDGND电源地
VCC3.3VOLED 供电
SCLGPIO42I2C 时钟
SDAGPIO41I2C 数据

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 必须使用独立缓冲

最初版本只有一个文本缓冲,并用 USERAI 表示当前说话方。这样虽然简单,但说话方一切换,上一方的内容就会被覆盖。

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 文本各复制一次到局部数组中,三个大对象叠加后挤爆了任务栈。

最终做了两项修复:

  1. draw_oled_frame() 直接读取任务持有的稳定快照,不再重复复制两行文本。
  2. OLED 任务栈从 4KB 增加到 6KB,为 U8g2 字形查找和 I2C 调用保留余量。

这次问题也说明,ESP32-S3 虽然带有 8MB PSRAM,但 FreeRTOS 任务栈默认仍然是有限的内部内存。增加局部大数组时,不能只关注整个系统还剩多少堆内存。

十四、用板载 RGB 灯显示对话状态

OLED 适合显示文字,但用户不一定始终盯着屏幕。核心板中间还有一颗带乳白灯罩的可寻址 RGB LED,可以用颜色快速表示设备当前处于哪个对话阶段:

Realtime 状态RGB 颜色
其他时间,包括启动、空闲和断线白色
用户转写 startedcompleted红色
AI 音频 starteddone绿色

先确认它不是普通三色 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));

这里有三个容易混淆的地方:

  1. GPIO48 是单根数据线,不是绿色通道。
  2. 灯珠内部按 GRB 顺序接收颜色,所以显式指定 LED_STRIP_COLOR_COMPONENT_FMT_GRB
  3. 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

从时间顺序可以确认:

  1. 模型正确理解了用户意图。
  2. ESP32-S3 正确识别工具名和 call_id
  3. 工具结果在动作开始前立即返回。
  4. 舵机摆动期间,模型语音仍能正常播放。
  5. 5 秒后舵机自动回到中位。
  6. OLED 第一行显示用户转写,第二行同步显示 AI 回复。
  7. 用户转写期间 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 执行真实动作
把执行结果反馈给模型

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

  1. Function Calling 是模型和设备之间的结构化协议,不是模型直接运行本地函数。
  2. 工具声明必须清楚描述能力、参数和调用条件。
  3. call_id 是请求和结果之间的对应关系,必须原样返回。
  4. 实时网络路径只负责快速解析和调度,耗时动作交给独立任务。
  5. “立即返回”和“动作完成”是两个不同时间点,要根据工具语义设计。
  6. 舵机最大速度来自直接切换目标位置,而不是提高 PWM 频率。
  7. 模型行为不仅取决于代码,也取决于工具描述和系统提示词。
  8. 让 AI 控制物理设备时,还要同时考虑供电、并发和故障边界。
  9. Realtime 的文本 delta 可以直接驱动屏幕,不需要等完整转写结束。
  10. 中文截取必须遵守 UTF-8 边界,并按照字体实际像素宽度决定显示范围。
  11. OLED 刷新要与网络、AEC 和音频任务隔离,同时警惕大局部数组造成任务栈溢出。
  12. WS2812 是单线时序器件,可以使用 ESP-IDF 的 led_strip 和 RMT 驱动。
  13. 用户与 AI 状态可能在插话时重叠,需要独立标志和明确的显示优先级。
  14. RGB 的颜色边界应来自 Realtime 协议事件,不能根据本地音量或播放缓冲猜测。

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

后面可以继续扩展:

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