Files
lucky-cat/docs/knowledge vault/websocket-and-audio-flow.md
T

38 KiB
Raw Blame History

WebSocket 与音频上下行实现说明

本文描述 feature/st7789p3-new-board 分支、提交 07219e6 附近的实际实现。参数取自当前 sdkconfig。sdkconfig.defaults 是新建配置时的默认值,二者不一致时,应以本次构建实际使用的 sdkconfig 为准。

1. 文档目的

本项目通过一个全双工 WebSocket 会话连接云端多模态服务。设备端主要完成以下工作:

  1. 获取设备身份和临时访问 Token。
  2. 建立带 TLS 的 WebSocket 连接。
  3. 通过 WebSocket 文本帧发送会话控制指令。
  4. 采集麦克风 PCM,并编码成 raw Opus。
  5. 通过 WebSocket 二进制帧上传 Opus 音频。
  6. 接收服务端 ASR、对话和 TTS 状态事件。
  7. 接收 WebSocket 二进制音频,解码 Opus 并通过 ES8311 播放。

需要首先理解的一点是:

  • ASR、LLM 和 TTS 都在云端完成。
  • ESP32-S3 不运行本地 ASR 或大模型。
  • 设备端负责会话状态管理、音频采集/编解码、数据传输和播放。

2. 分层和主要文件

项目依赖方向为:

app_composition -> control_plane -> domain -> platform

WebSocket 与音频链路对应关系如下:

层 主要职责 关键文件
control_plane 鉴权、会话状态机、PTT 策略、协议 JSON、错误恢复 components/control_plane/src/multimodal/control_plane_multimodal_auth_service.c
control_plane Start/Stop/SendSpeech、服务端事件解析、音频回调桥接 components/control_plane/src/multimodal/control_plane_multimodal_session_service.c
control_plane PTT 会话推进、心跳、超时、上下行桥接 components/control_plane/src/multimodal/control_plane_multimodal_runtime.c
control_plane PTT 状态机执行 components/control_plane/src/runtime/control_plane_runtime_workers.c
domain 对上层屏蔽 platform 类型,完成契约映射 components/domain/src/runtime/domain_runtime_adapters.c
platform ESP WebSocket Client、收发锁、消息队列、分片重组 components/platform/src/multimodal/transport_ws.c 及其子目录
platform ES8311、I2S、Opus 编解码、音频任务与队列 components/platform/src/multimodal/audio_pipeline.c 及其子目录

3. 总体链路

sequenceDiagram
    participant Key as PTT 按键
    participant CP as Control Plane
    participant Auth as Token 服务
    participant WS as WebSocket Transport
    participant Cloud as 云端多模态服务
    participant Mic as ES8311/I2S 麦克风
    participant Speaker as ES8311/I2S 扬声器

    Key->>CP: 单击,开始一次 click-to-talk
    CP->>Auth: 获取 voice session Token
    Auth-->>CP: token/workspace_id/app_id
    CP->>WS: 建立 WSS,携带 Bearer 和 Workspace
    WS-->>CP: Connected
    CP->>Cloud: 文本帧 Start
    Cloud-->>CP: DialogStateChanged(Listening)
    CP->>Cloud: 文本帧 SendSpeech
    CP->>Mic: 启动上行采集
    loop 每个 60 ms 音频帧
        Mic-->>CP: 16 kHz/16 bit/mono PCM
        CP->>CP: Opus 编码并进入发送队列
        CP->>Cloud: 二进制帧 raw Opus
    end
    Cloud-->>CP: SpeechStarted
    Cloud-->>CP: SpeechContent(ASR 文本)
    CP->>Cloud: 文本帧 StopSpeech
    Cloud-->>CP: SpeechEnded
    Cloud-->>CP: RespondingStarted
    loop TTS 流式音频
        Cloud-->>WS: 二进制帧 raw Opus
        WS-->>CP: 下行音频回调
        CP->>CP: Opus 解码为 PCM
        CP->>Speaker: I2S 播放
    end
    Cloud-->>CP: RespondingEnded
    CP->>Cloud: LocalRespondingEnded

4. 当前关键配置参数

4.1 服务地址和鉴权

参数 当前值 含义
CONFIG_ENABLE_MULTIMODAL_INTERACTION y 启用多模态交互
CONFIG_MULTIMODAL_USE_STATIC_CREDENTIALS 未启用 不直接使用静态 API Key
CONFIG_MULTIMODAL_AUTH_BYPASS_TOKEN_SERVICE 未启用 不绕过设备 Token 服务
CONFIG_AUTH_V2_ENABLE y 获取业务 Token 时使用设备 JWT 鉴权
CONFIG_MULTIMODAL_DEVICE_TOKEN_URL https://gz.api.talkingq.com/token/api/device/token 设备业务 Token 接口
CONFIG_MULTIMODAL_WS_URI wss://dashscope.aliyuncs.com/api-ws/v1/inference 多模态 WebSocket 地址
CONFIG_MULTIMODAL_CONNECT_TIMEOUT_MS 10000 建连网络超时,10 秒
CONFIG_MULTIMODAL_RECONNECT_DELAY_MS 2000 WebSocket 自动重连间隔,2 秒

Token、JWT、Wi-Fi 密码等敏感信息不应写入文档、日志或提交到 Git。

4.2 WebSocket 参数

参数 当前值 含义
CONFIG_MULTIMODAL_WEBSOCKET_TASK_STACK_BYTES 32768 B ESP WebSocket Client 任务栈目标值
CONFIG_MULTIMODAL_WEBSOCKET_TASK_PRIORITY 8 WebSocket Client 任务优先级
CONFIG_MULTIMODAL_WS_BUFFER_SIZE 2048 B WebSocket Client 收发缓冲尺寸
CONFIG_MULTIMODAL_WS_PING_INTERVAL_SEC 20 s WebSocket Ping 周期
CONFIG_MULTIMODAL_WS_PINGPONG_TIMEOUT_SEC 120 s Ping/Pong 超时
CONFIG_MULTIMODAL_WS_ENABLE_TCP_KEEPALIVE y 启用 TCP Keepalive
CONFIG_MULTIMODAL_WS_TCP_KEEPALIVE_IDLE_SEC 30 s TCP 空闲多久后开始探测
CONFIG_MULTIMODAL_WS_TCP_KEEPALIVE_INTERVAL_SEC 10 s TCP Keepalive 探测间隔
CONFIG_MULTIMODAL_WS_TCP_KEEPALIVE_COUNT 3 连续失败次数
CONFIG_MULTIMODAL_WS_REALTIME_LOCK_WAIT_MS 80 ms 实时发送等待共用发送锁的上限
CONFIG_MULTIMODAL_WS_REALTIME_SEND_TIMEOUT_MS 300 ms 实时发送单次超时
CONFIG_MULTIMODAL_WS_DISPATCH_TASK_STACK_BYTES 4096 B 有序收包 worker 的基础栈要求
CONFIG_MULTIMODAL_WS_JSON_TASK_STACK_BYTES 8192 B 文本处理所需栈配置
CONFIG_MULTIMODAL_WS_OPUS_TASK_STACK_BYTES 12288 B 二进制音频处理所需栈配置
CONFIG_MULTIMODAL_WS_MSG_QUEUE_LEN 512 WebSocket 入站消息队列深度
CONFIG_TQ_NETWORK_TASK_CORE 0 WebSocket 有序收包 worker 固定在 CPU Core 0

当前实现不是分别创建 dispatch、JSON、Opus 三个任务,而是取上述三个栈配置中的最大值,创建一个保持消息顺序的 mm_ws_ordered worker。因此当前有序 worker的实际目标栈是 12288 B。

代码中的固定限制:

限制 数值 说明
单个收到的 WebSocket 数据片段 4096 B 超过 MULTIMODAL_WS_MSG_MAX_BYTES 的片段直接丢弃
文本消息重组上限 32768 B 允许一个完整文本 payload 被拆成多个片段
二进制消息重组上限 4096 B 完整下行二进制 payload 超过该值会丢弃
普通文本发送超时 1200 ms 非实时控制指令发送超时
普通文本发送重试 最多 4 次 仅对 WANT_READ/WANT_WRITE/EAGAIN 等可重试错误
停止 WS worker 等待 1200 ms 有界停止,超时后进入清理兜底

4.3 上行音频参数

参数 当前值 含义
CONFIG_MULTIMODAL_UPSTREAM_MODE push2talk 由客户端显式发送 SendSpeech/StopSpeech
CONFIG_MULTIMODAL_AUDIO_CODEC_SAMPLE_RATE 16000 Hz 采样率
CONFIG_MULTIMODAL_AUDIO_CODEC_CHANNELS 1 单声道
CONFIG_MULTIMODAL_AUDIO_CODEC_BITS_PER_SAMPLE 16 bit PCM 位深
CONFIG_MULTIMODAL_AUDIO_CHUNK_SIZE 1920 B 60 ms 单声道 PCM 数据量
CONFIG_MULTIMODAL_OPUS_FRAME_DURATION_MS 60 ms 每个 Opus 帧的时长
CONFIG_MULTIMODAL_OPUS_BITRATE_KBPS 16 kbit/s Opus 目标码率
CONFIG_MULTIMODAL_OPUS_COMPLEXITY 0 Opus 编码复杂度,优先降低 MCU 负载
CONFIG_MULTIMODAL_AUDIO_IN_GAIN_DB 30 dB ES8311 输入增益
CONFIG_MULTIMODAL_AUDIO_MIC_CHANNEL 0 从 codec 数据中选取的麦克风通道
CONFIG_MULTIMODAL_AUDIO_UPLINK_TASK_STACK 32768 B 采集/编码任务栈
CONFIG_MULTIMODAL_AUDIO_UPLINK_SEND_TASK_STACK 12288 B 上传发送任务栈
CONFIG_MULTIMODAL_AUDIO_UPLINK_SEND_QUEUE_LEN 24 Opus 上行队列容量
CONFIG_MULTIMODAL_AUDIO_UPLINK_SEND_TIMEOUT_MS 5 ms 编码任务向上行队列投递的等待时间
CONFIG_MULTIMODAL_AUDIO_UPLINK_MAX_PACKET_BYTES 1024 B 单个上行 Opus 包最大尺寸
CONFIG_MULTIMODAL_AUDIO_PREWARM_OPUS_BEFORE_WS y 建立 WS 前预热 Opus,降低开始对话时的内存压力和延迟
CONFIG_MULTIMODAL_AUDIO_TASK_CORE 1 音频任务固定在 CPU Core 1

由这些参数可以推导:

每帧采样点 = 16000 × 60 / 1000 = 960 samples
每帧 PCM    = 960 × 16 / 8 × 1 = 1920 bytes
PCM 裸码率  = 16000 × 16 × 1 = 256 kbit/s = 32 kB/s
Opus 目标包大小约 = 16 kbit/s × 60 ms / 8 = 120 bytes/frame
每秒 Opus 帧数约 = 1000 / 60 = 16.67 frame/s
24 格上行队列对应约 = 24 × 60 ms = 1.44 s 音频

120 B 是按目标码率计算的平均值,不代表每个实际 Opus 包都固定为 120 B。

4.4 下行音频参数

参数 当前值 含义
下行协议格式 raw-opus 服务端通过 WebSocket 二进制帧发送
下行采样率 16000 Hz Start 中与 codec 采样率保持一致
下行帧时长 60 ms Start 的 frame_size
下行码率 16 kbit/s Start 的 bit_rate
CONFIG_MULTIMODAL_DOWNSTREAM_RATE_LIMIT 2000 B/s 请求服务端限制下发速率,约等于 16 kbit/s
CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_MAX_PACKET_BYTES 2048 B 单个播放队列音频包最大尺寸
CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_QUEUE_LEN 1024 下行播放队列最大包数
CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_TASK_STACK 12288 B 解码/播放任务栈
CONFIG_MULTIMODAL_AUDIO_OUT_VOLUME 60 ES8311 输出音量配置
CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_IDLE_MUTE_MS 250 ms 最后一个音频包后延时静音,减少爆音
下行积压告警阈值 5000 ms 估算播放队列积压超过 5 秒时记录告警

理论上,1024 个 60 ms 包可对应约 61.44 秒音频。实际每个 WebSocket 二进制消息不一定严格等于一个 60 ms Opus 帧,因此这是容量估算,不是协议保证。

4.5 I2S、I2C 和功放引脚

功能 当前值
I2C Port 0
ES8311 I2C 地址 0x18
I2C SCL GPIO2
I2C SDA GPIO1
I2S Port 0
I2S MCLK GPIO38
I2S BCLK GPIO39
I2S WS/LRCK GPIO41
I2S DOUT,ESP32 到 ES8311 GPIO40
I2S DIN,ES8311 到 ESP32 GPIO42
功放 PA 控制 GPIO11
I2S DMA descriptor 数量 4
每个 I2S DMA descriptor 帧数 160

5. 鉴权和 WebSocket 建连

5.1 当前鉴权模式

当前不是静态 API Key 模式,而是设备身份模式:

设备身份/序列号
    -> AUTH_V2 challenge/response
    -> 获取或刷新 device JWT
    -> POST /token/api/device/token
    -> 得到 token、workspace_id、app_id、voice_id
    -> 使用 token 建立 DashScope WSS

入口函数:

control_plane_multimodal_auth_fetch()

当前 CONFIG_AUTH_V2_ENABLE=y,Token 请求使用设备 JWT 作为 Bearer。若 Token 接口返回 401 或 403,代码会使缓存 JWT 失效,强制刷新后再重试一次。

Token 接口请求体包含可用的设备身份字段:

{
  "deviceId": "...",
  "serialNumber": "..."
}

返回结果会解析为:

control_plane_multimodal_auth_info_t {
    token;
    workspace_id;
    app_id;
    voice_id;
    device_id;
    serial_number;
    valid;
}

5.2 启动调用链

control_plane_start_multimodal_client()
  -> control_plane_prepare_for_multimodal_start()
  -> 注册 control_plane_multimodal_event_bridge()
  -> 注册 control_plane_downstream_audio_bridge()
  -> 创建 multimodal_watchdog_task
  -> control_plane_multimodal_start()
       -> control_plane_multimodal_auth_fetch()
       -> 注册 downstream_playback_event_bridge()
       -> domain_runtime_multimodal_transport_start()
            -> platform_multimodal_transport_start()
                 -> multimodal_ws_start()

5.3 WebSocket 握手头

multimodal_ws_start() 构造:

Authorization: Bearer <token>
X-DashScope-WorkSpace: <workspace_id>

X-DashScope-WorkSpace 很重要。代码中特别说明:只在 Start JSON 中携带 workspace_id 不一定能选择正确工作空间,握手阶段也要带这个 Header,否则可能返回 Invalid workspace_id。

TLS 使用 ESP-IDF 的证书 Bundle:

.crt_bundle_attach = esp_crt_bundle_attach

5.4 WebSocket Client 配置

底层由 esp_websocket_client 实现,关键配置如下:

esp_websocket_client_config_t cfg = {
    .uri = CONFIG_MULTIMODAL_WS_URI,
    .headers = s_headers,
    .network_timeout_ms = 10000,
    .reconnect_timeout_ms = 2000,
    .disable_auto_reconnect = false,
    .enable_close_reconnect = true,
    .task_stack = 32768,
    .task_prio = 8,
    .buffer_size = 2048,
    .ping_interval_sec = 20,
    .pingpong_timeout_sec = 120,
    .keep_alive_enable = true,
    .keep_alive_idle = 30,
    .keep_alive_interval = 10,
    .keep_alive_count = 3,
    .crt_bundle_attach = esp_crt_bundle_attach,
};

如果目标 WebSocket 任务栈创建失败,代码会根据可用内部内存尝试多个栈候选值。因为 ESP WebSocket Client 的原生任务栈主要占用内部内存,所以日志中的 largest_internal 对排查建连失败很重要。

6. WebSocket 上承载的两类数据

6.1 文本帧:协议控制和状态事件

客户端发送的 JSON 文本帧包括:

  • Start
  • Stop
  • SendSpeech
  • StopSpeech
  • CancelSpeech
  • RequestToSpeak
  • RequestToRespond
  • UpdateInfo
  • LocalRespondingStarted
  • LocalRespondingEnded
  • HeartBeat

服务端返回的文本事件包括:

  • task-started
  • task-failed
  • Started
  • DialogStateChanged
  • RequestAccepted
  • SpeechStarted
  • SpeechContent
  • SpeechEnded
  • RespondingStarted
  • RespondingContent
  • RespondingEnded
  • Stopped
  • Error

6.2 二进制帧:raw Opus 音频

二进制方向由当前会话状态决定:

  • 设备到服务端:麦克风 raw Opus,上行 ASR 音频。
  • 服务端到设备:TTS raw Opus,下行播放音频。

二进制帧没有在设备端附加自定义包头。音频格式、采样率、帧长和码率通过最初的 Start 参数约定。

7. Start 消息

control_plane_multimodal_send_start() 生成 UUID 作为 task_id,然后发送类似:

{
  "header": {
    "action": "run-task",
    "task_id": "<generated-uuid>",
    "streaming": "duplex"
  },
  "payload": {
    "task_group": "aigc",
    "task": "multimodal-generation",
    "function": "generation",
    "model": "multimodal-dialog",
    "input": {
      "directive": "Start",
      "workspace_id": "<workspace-id>",
      "app_id": "<app-id>"
    },
    "parameters": {
      "upstream": {
        "type": "AudioOnly",
        "mode": "push2talk",
        "audio_format": "raw-opus",
        "sample_rate": 16000
      },
      "downstream": {
        "audio_format": "raw-opus",
        "sample_rate": 16000,
        "frame_size": 60,
        "bit_rate": 16,
        "speech_rate": 110,
        "incremental_response": false,
        "intermediate_text": "transcript,dialog",
        "transmit_rate_limit": 2000,
        "voice": "<optional-voice-id>"
      },
      "client_info": {
        "user_id": "<device-id>",
        "device": {
          "uuid": "<serial-number>"
        }
      }
    }
  }
}

其中 voice 只有在解析到有效 voice ID 时才添加。

Start 发送成功只代表客户端数据成功交给 WebSocket,不代表服务端已经可以接收音频。必须继续等待 DialogStateChanged(Listening)。

8. 通用 Directive 格式

大部分会话控制指令由 send_directive() 统一构造:

{
  "header": {
    "action": "continue-task",
    "task_id": "<active-task-id>",
    "streaming": "duplex"
  },
  "payload": {
    "input": {
      "directive": "SendSpeech",
      "dialog_id": "<optional-dialog-id>"
    }
  }
}

规则:

  • 没有有效 task_id 时拒绝发送。
  • dialog_id 只有非空时才加入 JSON。
  • Stop 使用 action=finish-task。
  • 普通控制指令使用普通文本发送策略,可重试。
  • HeartBeat、LocalRespondingStarted/Ended 等实时指令使用较短锁等待和较短发送超时,不进行长时间重试。

9. PTT 和会话状态机

当前使用 click-to-talk:

CONFIG_TQ_PTT_MODE_CLICK_TO_TALK=y
CONFIG_TQ_PTT_CLICK_CAPTURE_TIMEOUT_MS=8000

短按被确认后:

control_plane_system_events.c
  -> control_plane_set_ptt_active(true)
  -> 唤醒 runtime worker
  -> control_plane_drive_ptt_state_machine()

状态推进如下:

状态 含义 下一步
PTT_STATE_IDLE 没有本次语音请求 等待短按
PTT_STATE_WAIT_WIFI 等待 STA 网络 网络可用后检查 WS
PTT_STATE_WAIT_WS 等待 WebSocket 触发前台连接或后台连接任务
PTT_STATE_WAIT_START_SENT 当前没有活跃 task 发送 Start
PTT_STATE_WAIT_LISTENING Start 已发但服务端未 Listening 等待 DialogStateChanged(Listening)
PTT_STATE_WAIT_SPEECH_STARTED 准备本轮说话 发送 SendSpeech
PTT_STATE_STREAMING 允许麦克风上传 启动或保持 uplink
PTT_STATE_TEARDOWN_LOCAL 本地停止 停止采集并排空队列
PTT_STATE_TEARDOWN_REMOTE 协议停止 发送 StopSpeech
PTT_STATE_DENIED 等待超时或条件不满足 本轮拒绝

相关固定超时:

超时 数值 行为
等待 Listening 3000 ms 标记传输不可用并尝试恢复
等待 SpeechStarted 阶段 1500 ms 标记传输不可用并尝试恢复
PTT teardown 2000 ms 防止停止流程无限卡住
PTT 总等待拒绝 10000 ms 进入 PTT_STATE_DENIED
click-to-talk 采集 8000 ms 自动结束本次输入
下行播放保护间隔 220 ms 刚收到 TTS 音频时不立即启动上行,减少回声/冲突

10. 音频上传详细流程

10.1 协议允许条件

服务端进入 Listening 后,control_plane_multimodal_send_speech_start() 发送 SendSpeech。成功后设置:

s_client_speech_active = true;
s_ptt_release_required = false;
s_request_to_speak_pending = false;

真正上传每个二进制包之前,control_plane_multimodal_send_audio_realtime() 仍会检查:

s_task_id[0] != '\0' &&
s_server_listening &&
s_client_speech_active &&
!s_ptt_release_required &&
!s_remote_responding &&
!s_drop_downstream_audio

任何一个条件不满足,返回 ESP_ERR_INVALID_STATE,音频不会发给 WebSocket。

10.2 启动调用链

control_plane_multimodal_handle_ptt_press_step()
  -> control_plane_start_uplink_if_ready()
  -> control_plane_multimodal_start_uplink()
  -> domain_runtime_multimodal_audio_start_uplink()
  -> platform_multimodal_audio_start_uplink()
  -> multimodal_audio_start_uplink()

multimodal_audio_start_uplink() 不重复创建整个音频系统,而是:

  1. 注册 control_plane_audio_uplink_send_cb()。
  2. 清空/复位上行队列。
  3. 设置 uplink_recording_active=true。
  4. 通知已经创建的采集任务开始工作。

10.3 采集任务

uplink_capture_task() 的循环:

esp_codec_dev_read()
  -> 从 ES8311/I2S 读取 codec PCM
  -> extract_mic_channel_16()
  -> 得到 16 kHz、16 bit、单声道、60 ms PCM
  -> esp_opus_enc_process()
  -> 得到 raw Opus
  -> xQueueSend(uplink_send_queue)

采集和发送分成两个任务的原因:

  • I2S 采集需要按音频节奏持续运行。
  • TLS/WebSocket 发送可能因锁、网络抖动而短暂阻塞。
  • 分离任务可以避免网络阻塞直接打断 I2S 采集。

10.4 上行队列溢出策略

队列容量是 24 个 Opus 包。投递等待 5 ms 仍失败时:

  1. 尝试丢弃队列最旧的一个包。
  2. 再放入最新包。
  3. 若仍失败,则丢弃最新包并记录告警。

这是实时语音常用策略:旧音频已经失去实时价值,优先保留最新数据。但如果经常出现:

Mic uplink queue overflow
Mic uplink queue full, drop packet

说明网络发送速度长期低于音频生成速度,ASR 会出现缺字、断句或 NoSpeechRecognized。

10.5 发送任务和跨层回调

uplink_send_task() 从队列读取一个 Opus 包:

uplink_send_task()
  -> control_plane_audio_uplink_send_cb()
  -> control_plane_multimodal_send_audio_realtime()
  -> domain_runtime_multimodal_transport_send_binary_realtime()
  -> platform_multimodal_transport_send_binary_realtime()
  -> multimodal_ws_send_binary_realtime()
  -> esp_websocket_client_send_bin()

上行音频使用 WebSocket 共用发送互斥锁:

  • 等锁最多 80 ms。
  • 单次发送最多 300 ms。
  • 实时二进制包不执行普通文本指令那种多次重试。

这样可以避免一个过期音频包长时间占住发送链路,但会在严重网络拥塞时主动丢包。

10.6 结束上传

本地主动结束:

control_plane_set_ptt_active(false)
  -> PTT_STATE_TEARDOWN_LOCAL
  -> control_plane_multimodal_stop_uplink()
  -> multimodal_audio_stop_uplink(6000 ms)
  -> 停止继续采集
  -> 等待队列和 in-flight 包排空
  -> 注销 uplink callback
  -> PTT_STATE_TEARDOWN_REMOTE
  -> control_plane_multimodal_send_speech_stop()
  -> 发送 StopSpeech

服务端也可能先返回 SpeechEnded。收到它时,客户端立即停止上行并设置 ptt_release_required,避免当前手势状态导致下一轮音频被错误上传。

11. ASR 文本事件

服务端识别阶段的事件:

11.1 SpeechStarted

表示服务端检测到语音起点:

ASR started

11.2 SpeechContent

text 是从本次识别开始到当前时刻的完整累计文本,不应简单地把每次 text 追加到旧文本后面。

示例:

{"payload":{"output":{"event":"SpeechContent","text":"你好","finished":false}}}
{"payload":{"output":{"event":"SpeechContent","text":"你好世界","finished":true}}}

当前代码在 finished=true 时打印最终 ASR 文本,但只向上层发送一个不带 payload 的 CONTROL_PLANE_MULTIMODAL_EVENT_SPEECH_CONTENT 事件。也就是说:

  • 云端对话可以继续使用识别结果。
  • 本地日志可以看到最终文字。
  • 本地 UI/业务层目前拿不到 text 内容。

如果后续要在 LCD 显示用户说的话或做本地命令,应增加类似:

asr_text_callback(const char *text, bool finished, void *user_data)

不能只扩展现有无参数事件枚举。

11.3 SpeechEnded

表示服务端检测到 ASR 语音终点。当前代码会停止麦克风上行并记录:

ASR ended

12. 音频下发详细流程

12.1 响应开始

服务端先通过文本帧发送 RespondingStarted。客户端执行:

  1. 设置 s_remote_responding=true。
  2. 停止仍在运行的上行采集,防止边播边上传自身声音。
  3. 清除上一轮响应的播放状态。
  4. 调用 domain_runtime_multimodal_audio_begin_downstream_response()。
  5. 复位 Opus 解码器。
  6. 创建或确认播放任务、播放队列、静音定时器和 DMA bounce buffer。
  7. 预热输出路径,但保持静音,等待第一个有效 PCM 帧。

12.2 WebSocket 收到二进制帧

底层事件入口:

websocket_event_handler(..., WEBSOCKET_EVENT_DATA, ...)

处理步骤:

ESP WebSocket Event Task
  -> 校验 opcode 是 TEXT 或 BINARY
  -> 将当前片段复制到 PSRAM 优先分配的内存
  -> xQueueSend(s_ws_msg_queue)
  -> ws_dispatch_task()
  -> process_binary_fragment()
  -> 必要时按 payload_offset 重组
  -> emit_event(MULTIMODAL_WS_EVENT_BINARY_MESSAGE)

如果 worker pipeline 因内存不足创建失败,代码会回退到事件回调中直接处理,但这种路径会增加 WebSocket 事件任务的负担。

12.3 分片重组

ESP WebSocket Client 可能把一个完整消息拆成多个 WEBSOCKET_EVENT_DATA:

  • payload_len:完整消息总长度。
  • payload_offset:当前片段在完整消息中的偏移。
  • data_len:当前片段长度。

代码要求片段连续且按顺序到达。出现 gap、乱序、越界或总长度超过限制时,重组缓存会被清空并丢弃该消息。

12.4 Control Plane 音频桥接

二进制消息被 Domain 映射后进入:

transport_event_bridge(DOMAIN_MULTIMODAL_TRANSPORT_EVENT_BINARY_MESSAGE)
  -> 检查 s_drop_downstream_audio
  -> 更新 last_downstream_audio_tick
  -> control_plane_downstream_audio_bridge()
  -> control_plane_multimodal_play_downstream_audio()
  -> domain_runtime_multimodal_audio_play_downstream_opus()
  -> platform_multimodal_audio_play_downstream_opus()
  -> multimodal_audio_handle_downstream_opus()

如果用户发起打断,s_drop_downstream_audio=true,后续仍在网络中到达的旧 TTS 音频会被直接丢弃,不再送入播放队列。

12.5 下行播放队列

multimodal_audio_handle_downstream_opus():

  1. 检查音频系统、codec 和 Opus decoder 已初始化。
  2. 检查单包不超过 2048 B。
  3. 确保下行播放任务存在。
  4. 在 PSRAM 优先分配一份包缓存并复制数据。
  5. 用最多 20 ms 把包放入播放队列。
  6. 队列满时丢弃当前包并记录告警。

上行队列满时会尝试“丢旧保新”,下行队列满时则直接丢弃当前新包,两者策略不同。

12.6 Opus 解码和 I2S 播放

downstream_playback_task() 从队列取包,调用:

handle_downstream_opus_sync()
  -> esp_opus_dec_decode()
  -> 得到 16 bit PCM
  -> 必要时扩展为 codec 的通道布局
  -> prime_downstream_output_path()
  -> 解除静音
  -> esp_codec_dev_write()
  -> I2S TX
  -> ES8311 DAC
  -> PA/扬声器

当前 codec 配置是单声道,所以解码 PCM 通常可直接写入 codec。如果未来 codec 通道数改为多声道,代码会调用 expand_mono_to_multichannel_16() 将单声道复制到目标通道布局。

12.7 播放完成握手

本项目不仅接收云端 RespondingStarted/Ended,还会把本地真正的播放状态反馈给服务端:

  • 第一帧实际开始播放后,发送 LocalRespondingStarted。
  • 收到 RespondingEnded 后,不一定立即发送本地结束。
  • 需要等播放队列中的剩余音频真正播放完,再发送 LocalRespondingEnded。

这解决了“服务端已经下发完,但扬声器还在播放缓存”的时间差。若用户打断播放,则停止队列后也会走本地结束通知。

12.8 静音和爆音控制

下行开始时先保持静音,输出路径准备完成、首帧即将写入时才解除静音。最后一包播放后由 idle mute timer 延迟 250 ms 再静音。

这部分用于减少:

  • PA 打开瞬间的爆音。
  • Opus 包之间短间隔导致的频繁 mute/unmute。
  • 响应切换时残留 PCM 被播放。

13. 文本响应与音频响应的关系

服务端可能同时下发:

  • RespondingContent.text/spoken:模型或 TTS 的文字内容。
  • WebSocket Binary:对应的 raw Opus 音频。

当前 RespondingContent 会通过 responding text callback 交给上层,用于模型命令提取;二进制帧走独立音频回调。因此文本和音频虽然属于同一响应,但设备端是两条并行链路,不能假定“一条文本事件紧跟一个音频包”。

14. 打断链路 RequestToSpeak

当服务端正在 TTS 响应,用户再次触发语音输入时:

PTT
  -> 检测 state.remote_responding
  -> Send RequestToSpeak
  -> s_request_to_speak_pending=true
  -> s_drop_downstream_audio=true
  -> 停止本地下行播放
  -> 服务端返回 RequestAccepted
  -> server_listening=true
  -> 再发送 SendSpeech
  -> 启动新一轮音频上行

RequestToSpeak 与 SendSpeech 不是同一件事:

  • RequestToSpeak:请求打断当前服务端回答并切回 Listening。
  • SendSpeech:在已经 Listening 后,声明马上开始上传本轮用户音频。

必须等待 RequestAccepted 或新的 Listening 状态后再走 SendSpeech,否则服务端可能返回协议状态错误。

15. 心跳、空闲和超时

项目有两层保活:

  1. WebSocket Ping/Pong:20 秒 Ping,120 秒 Pong 超时。
  2. 业务 HeartBeat Directive:默认 50 秒一次。

Watchdog 每秒检查一次运行状态:

参数/常量 当前值 行为
CONFIG_MULTIMODAL_HEARTBEAT_INTERVAL_MS 50000 ms 有活跃 task 时发送 HeartBeat
CONFIG_MULTIMODAL_NO_RESPONSE_TIMEOUT_MS 60000 ms 长时间无下行时发送 Stop
CONFIG_MULTIMODAL_DIALOG_IDLE_TIMEOUT_MS 60000 ms 协议配置的对话空闲概念,需结合具体使用点确认
CONTROL_PLANE_IDLE_TRANSPORT_SHUTDOWN_MS 30000 ms WS 已连但没有 active task,30 秒后停止 client

注意:TCP Keepalive、WebSocket Ping/Pong 和业务 HeartBeat 作用不同,不能互相替代。

16. 错误处理与恢复

16.1 WebSocket 断开或错误

底层产生:

  • DOMAIN_MULTIMODAL_TRANSPORT_EVENT_DISCONNECTED
  • DOMAIN_MULTIMODAL_TRANSPORT_EVENT_ERROR

Control Plane 会:

  1. 标记 transport unavailable。
  2. 停止音频上行。
  3. 清理或复位当前 session 状态。
  4. 由运行时连接策略决定后续重连。

本地主动 stop 导致的 disconnected 不会上报成异常断开,避免停止流程被误判成网络故障。

16.2 task-failed

收到 header.event=task-failed 时,代码提取:

  • task_id
  • error_code
  • error_name
  • error_message
  • request_id
  • dialog_id

旧 task 的迟到失败事件会与当前 task_id 比较,不匹配时忽略,避免上一轮错误破坏新会话。

InvalidParameter 或包含 Invalid action 的错误被视为协议错误,会直接把 transport 标为不可用。

16.3 output Error

收到 payload.output.event=Error 时:

  1. 打印 code/name/message/request_id/dialog_id。
  2. 停止上行采集。
  3. 停止本地下行播放。
  4. 标记协议传输不可用。
  5. 触发 CONTROL_PLANE_MULTIMODAL_EVENT_OUTPUT_ERROR。
  6. 上层请求 SERVER_ERROR LCD 表情。

因此日志中 Expression request id=1 trigger=2 通常不是 LCD 自身故障,而是 WebSocket 服务端错误触发的错误表情。

16.4 音频发送失败窗口

control_plane_multimodal_send_audio_realtime() 会统计一段时间内的连续音频发送失败。达到阈值后会把传输标记为不可用,避免设备在已经坏掉的连接上持续编码和丢包。

17. 并发与内存模型

17.1 发送方向

文本和二进制共用 s_ws_send_lock,因为 ESP WebSocket Client 的同一连接不能被多个任务无保护地并发发送。

普通控制指令和实时音频采用不同策略:

类型 等锁/发送策略 设计目标
Start、Stop、SendSpeech 等普通文本 最长约 2 秒等锁、1200 ms 发送、可重试 尽量保证关键控制指令送达
HeartBeat、本地播放状态等实时文本 80 ms 等锁、300 ms 发送、不长重试 避免阻塞实时链路
上行 Opus 二进制 80 ms 等锁、300 ms 发送、不长重试 过期语音包及时放弃

17.2 接收方向

WebSocket 事件回调尽量只复制数据并入队。实际文本解析、二进制重组和上层回调由 worker 处理,降低阻塞 WebSocket 内部事件任务的风险。

消息内容和重组缓冲优先从 PSRAM 分配,失败后回退到普通 8-bit heap。WebSocket Client 原生任务栈仍对内部内存大块较敏感。

17.3 关键队列

队列 当前深度 元素含义 满时策略
WebSocket 入站消息队列 512 文本/二进制片段描述符与数据指针 丢当前片段,完整消息可能因此重组失败
音频上行发送队列 24 已编码 Opus 包 尝试丢最旧包、保留最新包
音频下行播放队列 1024 待解码 Opus 包 丢当前新包

18. 常见日志与定位方法

18.1 正常建连

Request device token: ...
Token fetched (workspace=..., app=...)
WebSocket starting: wss://...
WebSocket connected
Start sent (upstream_mode=push2talk ...)

18.2 正常上行和 ASR

Mic uplink capture task started ...
Mic uplink send task started ...
ASR started
ASR ... finished=1
ASR ended

若有 NoSpeechRecognized,优先检查:

  1. PTT 极性和状态机是否真的进入 STREAMING。
  2. ASR started 是否出现。
  3. 麦克风通道是否选对。
  4. 输入增益是否过低或削顶。
  5. 是否出现 uplink queue overflow/send failed。
  6. Start 的 sample rate/frame/audio format 是否与实际编码一致。

18.3 正常下行

TTS responding started
Downstream response begin: sample_rate=16000 frame=60ms ...
Downstream opus queued ...
Downstream opus played ...
TTS responding ended

只有文字没有声音时,按顺序检查:

  1. 是否收到 WebSocket binary。
  2. s_drop_downstream_audio 是否被误设为 true。
  3. 单包是否超过 2048 B 或完整 WS binary 是否超过 4096 B。
  4. 播放队列是否满。
  5. Opus decoder 是否报错。
  6. esp_codec_dev_write() 是否成功。
  7. PA GPIO11、输出音量和 mute 状态是否正确。

18.4 关键异常日志

日志关键词 含义
Invalid workspace_id 握手 Workspace、Token 所属空间或 Start workspace 不一致
WebSocket send text lock timeout 音频或其他文本发送长时间占用共用锁
WebSocket send bin failed 上行音频未完整写入 WS
WS ingress queue full 收包处理不及,当前片段被丢弃
WS text/binary fragment gap/out-of-order 分片缺失或乱序,完整消息被丢弃
Mic uplink queue overflow 网络发送慢于音频编码速度
Downstream playback queue full 解码/播放速度跟不上下发速度
Downstream playback backlog high 估算积压超过 5 秒,交互延迟明显
Downstream opus decode failed 格式、帧边界或数据完整性异常
WS output Error 服务端业务/协议错误,不是单纯网络断线
task-failed 云端任务级失败,需看 code/name/message

19. 修改参数时必须成组检查

19.1 修改采样率

不能只改 CONFIG_MULTIMODAL_AUDIO_CODEC_SAMPLE_RATE。需要同时确认:

  • ES8311/I2S 实际采样率。
  • Opus encoder/decoder 采样率。
  • Start upstream.sample_rate。
  • Start downstream.sample_rate。
  • PCM frame buffer 大小。
  • 服务端是否支持该采样率。

协议代码只接受 8000、16000、24000、48000 Hz。

19.2 修改 Opus 帧长

修改 CONFIG_MULTIMODAL_OPUS_FRAME_DURATION_MS 时要同时检查:

  • 每帧 PCM 字节数。
  • CONFIG_MULTIMODAL_AUDIO_CHUNK_SIZE 是否匹配。
  • Start downstream.frame_size。
  • 上下行队列换算的缓存时长。
  • 服务端是否支持该帧长。

当前 16 kHz/16 bit/mono/60 ms 对应 1920 B PCM。

19.3 修改码率

修改 CONFIG_MULTIMODAL_OPUS_BITRATE_KBPS 时要检查:

  • Start downstream.bit_rate。
  • CONFIG_MULTIMODAL_DOWNSTREAM_RATE_LIMIT 是否仍合理。
  • UPLINK_MAX_PACKET_BYTES 是否能容纳极端帧。
  • 网络带宽、Wi-Fi 稳定性和音质需求。

19.4 修改队列深度

队列越大不一定越好:

  • 上行队列太大:ASR 收到的是越来越旧的声音,实时性变差。
  • 下行队列太大:不会立即丢包,但用户听到的回答延迟越来越大。
  • 每个下行包单独分配内存,超大队列可能造成 PSRAM 分配碎片和额外生命周期成本。

20. 当前实现需要特别注意的点

  1. ASR 文本没有携带到上层。 SPEECH_CONTENT 只是无参数事件;本地需要显示/处理识别文本时应补充强类型回调。
  2. 文本和音频共用发送锁。 高频控制指令或异常重试会影响实时音频,反之亦然。
  3. WebSocket 收包队列同时承载文本和音频。 下行音频突发可能延迟关键状态事件。
  4. 下行队列配置很深。 它提高抗抖动能力,但最大理论缓存超过一分钟,需关注高延迟而非只关注是否丢包。
  5. 二进制完整消息重组上限为 4096 B。 即使播放层允许 2048 B,传输层上限仍需同步考虑;未来提高服务端单包大小时要成组修改。
  6. click-to-talk 与双击共用 PTT。 原始 PRESSED 阶段触发 RequestToSpeak 可能与双击木鱼手势冲突,手势确认和协议动作需要明确仲裁。
  7. 服务端 SpeechEnded 可以先于本地 8 秒超时。 两条结束路径必须保持幂等,避免重复 StopSpeech 或错误复用上一轮状态。
  8. sdkconfig 是本地生成配置。 正式调整应同步确认 sdkconfig.defaults 或板级配置方案,否则 fullclean/set-target 后可能恢复旧值。

21. 最短代码阅读顺序

建议按下面顺序阅读,最容易形成完整心智模型:

  1. components/control_plane/src/runtime/control_plane_system_events.c
  2. components/control_plane/src/runtime/control_plane_runtime_workers.c
  3. components/control_plane/src/multimodal/control_plane_multimodal_runtime.c
  4. components/control_plane/src/multimodal/control_plane_multimodal_auth_service.c
  5. components/control_plane/src/multimodal/control_plane_multimodal_session_service.c
  6. components/domain/src/runtime/domain_runtime_adapters.c
  7. components/platform/src/multimodal/transport_ws.c
  8. components/platform/src/multimodal/transport_ws/transport_ws_runtime_impl.c
  9. components/platform/src/multimodal/transport_ws/transport_ws_workers_impl.c
  10. components/platform/src/multimodal/audio_pipeline.c
  11. components/platform/src/multimodal/audio_pipeline/audio_pipeline_runtime_impl.c
  12. components/platform/src/multimodal/audio_pipeline/audio_pipeline_codec_impl.c

22. 一句话总结

该项目使用一条长期全双工 WSS 同时承载控制 JSON 和 raw Opus:Control Plane 负责鉴权与会话状态,Platform 负责 WebSocket 和 ES8311/I2S/Opus;上行是“PCM 采集 → Opus 编码 → 队列 → WS binary → 云端 ASR”,下行是“云端 TTS WS binary → 分片重组 → 播放队列 → Opus 解码 → PCM → ES8311”,文本事件贯穿两条音频链路,用来保证 Listening、说话、响应、播放完成和异常恢复的顺序正确。