38 KiB
WebSocket 与音频上下行实现说明
本文描述
feature/st7789p3-new-board分支、提交07219e6附近的实际实现。参数取自当前sdkconfig。sdkconfig.defaults是新建配置时的默认值,二者不一致时,应以本次构建实际使用的sdkconfig为准。
1. 文档目的
本项目通过一个全双工 WebSocket 会话连接云端多模态服务。设备端主要完成以下工作:
- 获取设备身份和临时访问 Token。
- 建立带 TLS 的 WebSocket 连接。
- 通过 WebSocket 文本帧发送会话控制指令。
- 采集麦克风 PCM,并编码成 raw Opus。
- 通过 WebSocket 二进制帧上传 Opus 音频。
- 接收服务端 ASR、对话和 TTS 状态事件。
- 接收 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 文本帧包括:
StartStopSendSpeechStopSpeechCancelSpeechRequestToSpeakRequestToRespondUpdateInfoLocalRespondingStartedLocalRespondingEndedHeartBeat
服务端返回的文本事件包括:
task-startedtask-failedStartedDialogStateChangedRequestAcceptedSpeechStartedSpeechContentSpeechEndedRespondingStartedRespondingContentRespondingEndedStoppedError
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() 不重复创建整个音频系统,而是:
- 注册
control_plane_audio_uplink_send_cb()。 - 清空/复位上行队列。
- 设置
uplink_recording_active=true。 - 通知已经创建的采集任务开始工作。
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 仍失败时:
- 尝试丢弃队列最旧的一个包。
- 再放入最新包。
- 若仍失败,则丢弃最新包并记录告警。
这是实时语音常用策略:旧音频已经失去实时价值,优先保留最新数据。但如果经常出现:
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。客户端执行:
- 设置
s_remote_responding=true。 - 停止仍在运行的上行采集,防止边播边上传自身声音。
- 清除上一轮响应的播放状态。
- 调用
domain_runtime_multimodal_audio_begin_downstream_response()。 - 复位 Opus 解码器。
- 创建或确认播放任务、播放队列、静音定时器和 DMA bounce buffer。
- 预热输出路径,但保持静音,等待第一个有效 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():
- 检查音频系统、codec 和 Opus decoder 已初始化。
- 检查单包不超过 2048 B。
- 确保下行播放任务存在。
- 在 PSRAM 优先分配一份包缓存并复制数据。
- 用最多 20 ms 把包放入播放队列。
- 队列满时丢弃当前包并记录告警。
上行队列满时会尝试“丢旧保新”,下行队列满时则直接丢弃当前新包,两者策略不同。
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. 心跳、空闲和超时
项目有两层保活:
- WebSocket Ping/Pong:20 秒 Ping,120 秒 Pong 超时。
- 业务
HeartBeatDirective:默认 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_DISCONNECTEDDOMAIN_MULTIMODAL_TRANSPORT_EVENT_ERROR
Control Plane 会:
- 标记 transport unavailable。
- 停止音频上行。
- 清理或复位当前 session 状态。
- 由运行时连接策略决定后续重连。
本地主动 stop 导致的 disconnected 不会上报成异常断开,避免停止流程被误判成网络故障。
16.2 task-failed
收到 header.event=task-failed 时,代码提取:
task_iderror_codeerror_nameerror_messagerequest_iddialog_id
旧 task 的迟到失败事件会与当前 task_id 比较,不匹配时忽略,避免上一轮错误破坏新会话。
InvalidParameter 或包含 Invalid action 的错误被视为协议错误,会直接把 transport 标为不可用。
16.3 output Error
收到 payload.output.event=Error 时:
- 打印
code/name/message/request_id/dialog_id。 - 停止上行采集。
- 停止本地下行播放。
- 标记协议传输不可用。
- 触发
CONTROL_PLANE_MULTIMODAL_EVENT_OUTPUT_ERROR。 - 上层请求
SERVER_ERRORLCD 表情。
因此日志中 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,优先检查:
- PTT 极性和状态机是否真的进入 STREAMING。
ASR started是否出现。- 麦克风通道是否选对。
- 输入增益是否过低或削顶。
- 是否出现 uplink queue overflow/send failed。
- 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
只有文字没有声音时,按顺序检查:
- 是否收到 WebSocket binary。
s_drop_downstream_audio是否被误设为 true。- 单包是否超过 2048 B 或完整 WS binary 是否超过 4096 B。
- 播放队列是否满。
- Opus decoder 是否报错。
esp_codec_dev_write()是否成功。- 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. 当前实现需要特别注意的点
- ASR 文本没有携带到上层。
SPEECH_CONTENT只是无参数事件;本地需要显示/处理识别文本时应补充强类型回调。 - 文本和音频共用发送锁。 高频控制指令或异常重试会影响实时音频,反之亦然。
- WebSocket 收包队列同时承载文本和音频。 下行音频突发可能延迟关键状态事件。
- 下行队列配置很深。 它提高抗抖动能力,但最大理论缓存超过一分钟,需关注高延迟而非只关注是否丢包。
- 二进制完整消息重组上限为 4096 B。 即使播放层允许 2048 B,传输层上限仍需同步考虑;未来提高服务端单包大小时要成组修改。
- click-to-talk 与双击共用 PTT。 原始 PRESSED 阶段触发
RequestToSpeak可能与双击木鱼手势冲突,手势确认和协议动作需要明确仲裁。 - 服务端 SpeechEnded 可以先于本地 8 秒超时。 两条结束路径必须保持幂等,避免重复 StopSpeech 或错误复用上一轮状态。
sdkconfig是本地生成配置。 正式调整应同步确认sdkconfig.defaults或板级配置方案,否则 fullclean/set-target 后可能恢复旧值。
21. 最短代码阅读顺序
建议按下面顺序阅读,最容易形成完整心智模型:
components/control_plane/src/runtime/control_plane_system_events.ccomponents/control_plane/src/runtime/control_plane_runtime_workers.ccomponents/control_plane/src/multimodal/control_plane_multimodal_runtime.ccomponents/control_plane/src/multimodal/control_plane_multimodal_auth_service.ccomponents/control_plane/src/multimodal/control_plane_multimodal_session_service.ccomponents/domain/src/runtime/domain_runtime_adapters.ccomponents/platform/src/multimodal/transport_ws.ccomponents/platform/src/multimodal/transport_ws/transport_ws_runtime_impl.ccomponents/platform/src/multimodal/transport_ws/transport_ws_workers_impl.ccomponents/platform/src/multimodal/audio_pipeline.ccomponents/platform/src/multimodal/audio_pipeline/audio_pipeline_runtime_impl.ccomponents/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、说话、响应、播放完成和异常恢复的顺序正确。