# LiveKit 通话链路说明 本文档记录 `arcs_mini` 硬件端新增的通话链路:设备通过语音意图触发呼叫,连接 Node LiveKit 桥接服务,完成设备与设备、设备与 App/Web 之间的实时语音通话。 ## 背景 原有主链路主要负责唤醒、语音对话、MCP 工具调用、TTS 播放等能力。新增通话能力后,硬件端多了一条独立的通话链路: 1. 设备联网后主动连接 LiveKit Node 桥接服务的 WebSocket。 2. 用户通过主链路语音对话说“给某人/某设备打电话”。 3. 主链路意图识别命中通话工具,调用硬件端 MCP 工具 `ls.built_in.call_device`。 4. MCP 工具把目标设备号 `toId` 交给硬件端 LiveKit 桥接模块。 5. 桥接模块通过 WebSocket 给 Node 发送 `device_call`。 6. Node 创建 invite、加入 LiveKit 房间,并把双方音频转成硬件可消费的 PCM 帧。 7. 硬件端收下行 PCM 播放,同时采集本机麦克风 PCM 上行给 Node。 这条链路和主对话链路是并行关系:主对话链路负责“识别用户想打电话”和“调用工具”,通话链路负责“真正建立通话和传输音频”。 ## 核心链路 ```mermaid sequenceDiagram participant User as 用户 participant Device as 硬件端 arcs_mini participant MainCloud as 主链路云服务 participant MCP as MCP 工具 call_device participant Bridge as 硬件 LiveKit Bridge participant Node as Node LiveKit 桥接服务 participant Peer as 被叫端/对端 Device->>Node: WiFi IP ready 后连接 /ws/device?deviceId= User->>Device: 单击唤醒后说“给小红打电话” Device->>MainCloud: 主链路语音对话/意图识别 MainCloud->>Device: MCP tool call: ls.built_in.call_device(toId) Device->>MCP: 执行 MCP 工具 MCP->>Bridge: voice_livekit_bridge_request_call(toId) Bridge->>Node: {"type":"device_call","toId":"..."} Node->>Peer: incoming_call / invite Peer->>Node: accept Node->>Device: {"type":"call_start","sampleRate":16000,"channels":1} Node-->>Device: 二进制 PCM 下行帧 Device-->>Node: 二进制 PCM 上行帧 Device->>Node: 来电中三击 {"type":"device_reject","inviteId":"..."} Device->>Node: {"type":"device_hangup"} 或收到 call_end ``` ## 新增/修改代码清单 ### 1. LiveKit WebSocket 与音频桥接 文件:`src/category/comm/msgs/voice_livekit_poll_msg.c` 这是硬件端通话链路的核心文件,职责包括: - 设备联网后连接 Node 桥接服务。 - 维护通话状态机。 - 处理 Node 下发的 JSON 控制消息。 - 处理 Node 下发的二进制 PCM 音频帧。 - 从本机麦克风采集 PCM,上行发送给 Node。 - 处理来电响铃、双击接听、三击拒接、双击挂断。 - 通话结束后恢复唤醒和主云链路。 当前关键配置在文件顶部: ```c #define LK_BRIDGE_WS_SCHEME "ws" #define LK_BRIDGE_WS_HOST "39.108.110.91" #define LK_BRIDGE_WS_PORT "3000" #define LK_BRIDGE_WS_PATH_PREFIX "/ws/device?deviceId=" #define LK_BRIDGE_FALLBACK_DEVICE_ID "B" #define LK_BRIDGE_DEFAULT_CALL_TARGET "f17c0a1a322ed2ad" ``` 这些配置决定硬件端连接哪个 Node LiveKit 桥接服务。当前仍是编译期宏,部署到线上时需要改成目标服务器地址,或后续再改造成配置文件/云端下发。 #### 通话状态机 ```c typedef enum { LK_CALL_IDLE = 0, LK_CALL_RINGING, LK_CALL_PREPARING, LK_CALL_ACTIVE, } lk_call_state_e; ``` 含义: - `LK_CALL_IDLE`:空闲状态,可以发起呼叫或接收来电。 - `LK_CALL_RINGING`:收到来电,设备播放提示音并显示表情,等待双击接听。 - `LK_CALL_PREPARING`:预留状态,用于播放器准备阶段。 - `LK_CALL_ACTIVE`:通话中,正在收发 PCM 音频。 #### WebSocket 连接 初始化入口: ```c SYS_INIT(voice_livekit_poll_evt_init, SYS_INIT_LEVEL_PRE_APPLICATION, 50); ``` 模块初始化时订阅: ```c voice_msg_sub(VOICE_MSG_WIFI_IP_GOT, bridge_wifi_ip_got, NULL); voice_msg_sub(VOICE_MSG_BUTTON_CHANGE, bridge_button_event, NULL); ``` 当 WiFi 获取 IP 后,创建 `lk.bridge` 线程,线程会循环连接 Node: ```c bridge_connect(); ``` WebSocket 路径由设备 ID 拼出: ```c /ws/device?deviceId= ``` 设备 ID 优先来自: ```c get_app_datas()->did ``` 如果设备 ID 为空,则使用 `LK_BRIDGE_FALLBACK_DEVICE_ID`。 #### JSON 控制消息 `handle_text_frame()` 负责处理 Node 发来的文本 JSON。 目前支持的消息: - `hello`:连接确认。 - `play_url`:调试用,播放 URL。 - `stop`:停止播放。 - `incoming_call`:来电通知,进入响铃状态。 - `call_start`:通话开始,初始化音频播放和录音。 - `call_end`:通话结束,停止音频链路。 - `device_call_ack`:发起呼叫确认。 - `device_accept_ack`:接听确认。 - `device_reject_ack`:拒接确认。 - `device_hangup_ack`:挂断确认。 发给 Node 的消息: ```json {"type":"device_call","toId":"目标设备号"} {"type":"device_accept","inviteId":"来电 inviteId"} {"type":"device_reject","inviteId":"来电 inviteId"} {"type":"device_hangup"} ``` #### 二进制音频协议 二进制帧格式: ```c #define LK_BRIDGE_MAGIC 0xA5 #define LK_BRIDGE_VERSION 0x01 #define LK_BRIDGE_HEADER_SIZE 20 #define LK_BRIDGE_SAMPLE_RATE 16000 #define LK_BRIDGE_CHANNELS 1 #define LK_BRIDGE_BITS 16 ``` 每个音频包: - 前 20 字节:自定义 header。 - 后面 payload:PCM16 little-endian。 - 下行方向:Node -> 硬件,`flags = 0x00`。 - 上行方向:硬件 -> Node,`flags = 0x01`。 header 结构: ```c typedef struct { uint8_t magic; uint8_t version; uint8_t flags; uint8_t channels; uint32_t sample_rate; uint32_t samples_per_channel; uint32_t seq; uint32_t timestamp; } lk_bridge_header_t; ``` #### 下行播放 Node 下发的 PCM 二进制帧进入: ```c handle_binary_frame() ``` 处理流程: 1. 校验 magic/version/sampleRate/channels。 2. 确认当前处于 `LK_CALL_ACTIVE`。 3. 把 PCM payload 放入 `g_pcm_queue`。 4. `lk.audio.writer` 线程从队列取帧。 5. 调用 `lisa_audio_play_write()` 写入音频设备。 为降低断续感,播放端做了预填充: ```c #define LK_BRIDGE_AUDIO_START_PREFILL 4 ``` 队列满时丢弃最旧帧,优先保持实时性,避免旧音频追播。 #### 上行录音 通话开始时,`start_stream_playback()` 会配置 `audio0`: ```c lisa_audio_record_config() lisa_audio_register_callback() lisa_audio_record_start() ``` 录音回调: ```c livekit_audio_record_cb() ``` 处理流程: 1. 音频设备回调 PCM。 2. `uplink_accumulate_pcm()` 聚合为 640 字节一帧。 3. 放入 `g_uplink_queue`。 4. `lk.audio.uplink` 线程组装 20 字节 header。 5. 调用 `lisa_ws_send_binary()` 发给 Node。 当前上行帧大小: ```c #define LK_BRIDGE_UPLINK_FRAME_BYTES 640 ``` 对应 16kHz/16bit/mono 下约 20ms 音频。 #### 音频参数 当前通话链路使用: ```c #define LK_BRIDGE_RECORD_ANALOG_GAIN 20 #define LK_BRIDGE_RECORD_DIGITAL_GAIN 0 #define LK_BRIDGE_PLAY_DIGITAL_GAIN (-18) ``` 含义: - `RECORD_ANALOG_GAIN`:设备麦克风采集增益。 - `RECORD_DIGITAL_GAIN`:设备麦克风数字增益。 - `PLAY_DIGITAL_GAIN`:设备播放通话音频的数字增益。 之前调试噪音时,主要就是围绕这几个参数和 Node 端音频增益做过调整。当前版本里,硬件端播放数字增益为 `-18`,上行录音模拟增益为 `20`。 #### 来电响铃与表情 收到 `incoming_call` 后进入 `LK_CALL_RINGING`: ```c bridge_start_ringing() ``` 行为: - 保存 `inviteId` 和 `fromId`。 - 显示表情: ```c #define LK_BRIDGE_RING_EMOJI "wait" ``` - 循环播放本地提示音: ```c player_mgr_play(LOCAL, app_tone_get_url(TONE_ID_94), 0); ``` - 每 2500ms 重复一次: ```c #define LK_BRIDGE_RING_REPEAT_MS 2500 ``` 通话接通或结束后显示: ```c #define LK_BRIDGE_IDLE_EMOJI "neutral" ``` #### 按键交互 在 `bridge_button_event()` 中处理 button 0 的双击和三击: ```c if (evt->action == VOICE_MSG_BUTTON_ACTION_DOUBLE_CLICK) { if (g_call_state == LK_CALL_RINGING) { bridge_request_accept(); } else if (g_call_state == LK_CALL_IDLE) { // 当前不再用双击主动发起呼叫 } else { bridge_request_hangup(); } } else if (evt->action == VOICE_MSG_BUTTON_ACTION_TRIPLE_CLICK) { if (g_call_state == LK_CALL_RINGING) { bridge_request_reject(); } } ``` 当前设计: - 来电中:双击接听。 - 来电中:三击拒接。 - 通话中:双击挂断。 - 空闲中:双击不主动发起呼叫,发起呼叫改由语音意图/MCP 工具触发。 - 空闲中:三击不再打开二维码/信息页,避免和来电拒接冲突。 ### 2. MCP 通话工具 文件:`apps/arcs-mini/mcp-tools/mcp_tool_call_device.c` 这个文件新增 MCP 工具: ```c MCP_TOOL_DEFINE(ls.built_in.call_device, call_device_list, call_device_call); ``` 工具名: ```text ls.built_in.call_device ``` 入参: ```json { "toId": "目标设备号" } ``` 执行逻辑: ```c voice_livekit_bridge_request_call(to_id); ``` 也就是说,主链路意图识别到“打电话”后,只要返回 MCP tool call,硬件端就会通过该工具把目标设备号交给 LiveKit 桥接模块。 返回结果: ```json { "content": "呼叫已发起", "ret": 0 } ``` 如果失败则返回: ```json { "content": "呼叫发起失败", "ret": -1 } ``` ### 3. MCP 工具注册 文件:`apps/arcs-mini/mcp-tools/CMakeLists.txt` 新增编译文件: ```cmake mcp_tool_call_device.c ``` 这一步确保 `ls.built_in.call_device` 会进入固件,主链路才能调用到该工具。 ### 4. LiveKit 事件模块注册 文件:`src/category/comm/msgs/CMakeLists.txt` 新增编译文件: ```cmake voice_livekit_poll_msg.c ``` 这一步确保 LiveKit WebSocket/音频桥接模块会进入固件,并通过 `SYS_INIT` 自动初始化。 ### 5. 主按键逻辑调整 文件:`apps/arcs-mini/main.c` 主按键处理里,双击逻辑被调整为保留给 LiveKit 通话,不再触发原先可能冲突的行为: ```c case VOICE_MSG_BUTTON_ACTION_DOUBLE_CLICK: { LISA_LOGI(TAG, "power button double click, reserved for livekit call"); break; } ``` 实际通话状态下的双击接听/挂断由 `voice_livekit_poll_msg.c` 中订阅的 `bridge_button_event()` 处理。 三击逻辑也被调整为保留给 LiveKit 来电拒接,主按键处理不再打开信息/二维码页: ```c case VOICE_MSG_BUTTON_ACTION_TRIPLE_CLICK: { LISA_LOGI(TAG, "power button triple click, reserved for livekit reject"); break; } ``` 这样做的目的: - 避免双击同时触发拍照识图、唤醒或其他主链路行为。 - 避免来电中三击拒接后又打开二维码/信息页。 - 让通话状态机拥有双击接听、三击拒接、双击挂断的优先处理权。 ## 发起呼叫流程 ### 语音发起 1. 用户单击设备按钮唤醒。 2. 用户说“给小红打电话”。 3. 主链路云服务识别为通话意图。 4. 主链路根据联系人/设备映射得到目标 `toId`。 5. 主链路返回 MCP 调用: ```json { "name": "ls.built_in.call_device", "arguments": { "toId": "目标设备号" } } ``` 6. 硬件端执行 `mcp_tool_call_device.c`。 7. 调用 `voice_livekit_bridge_request_call(toId)`。 8. 硬件端通过 WebSocket 发: ```json {"type":"device_call","toId":"目标设备号"} ``` 9. Node 创建 invite,通知被叫端。 ### 代码入口 ```text apps/arcs-mini/mcp-tools/mcp_tool_call_device.c -> voice_livekit_bridge_request_call(toId) src/category/comm/msgs/voice_livekit_poll_msg.c -> lisa_ws_send_text({"type":"device_call","toId":"..."}) ``` ## 接听流程 1. Node 给硬件端发送: ```json { "type": "incoming_call", "inviteId": "...", "fromId": "..." } ``` 2. 硬件端进入 `LK_CALL_RINGING`。 3. 设备循环播放提示音并显示 `wait` 表情。 4. 用户双击按钮。 5. 硬件端发送: ```json {"type":"device_accept","inviteId":"..."} ``` 6. Node 完成接听,双方加入 LiveKit 房间。 7. Node 下发: ```json {"type":"call_start","sampleRate":16000,"channels":1} ``` 8. 硬件端启动录音和播放,进入 `LK_CALL_ACTIVE`。 ## 拒接流程 来电中三击按钮: ```json {"type":"device_reject","inviteId":"..."} ``` Node 收到后: 1. 校验 invite 仍为 `pending`。 2. 将 invite 状态更新为 `rejected`。 3. 回包给设备: ```json {"type":"device_reject_ack","inviteId":"...","status":"rejected"} ``` 4. 通知主叫端 `call_end`,reason 为 `rejected`。 硬件端发送拒接后会立即: - 停止本地来电提示音。 - 清空当前 pending invite。 - 状态回到 `LK_CALL_IDLE`。 - 显示空闲表情。 ## 挂断流程 ### 设备主动挂断 通话中双击按钮: ```json {"type":"device_hangup"} ``` Node 结束 invite,并通知双方 `call_end`。 ### 对端挂断 Node 下发: ```json {"type":"call_end","reason":"..."} ``` 硬件端执行: ```c stop_stream_playback(); ``` 然后: - 停止播放。 - 停止录音。 - 清空上下行队列。 - 关闭 PA。 - 恢复唤醒。 - 请求主云链路重连。 对应代码: ```c app_wakeup_start(); voice_cloud_reconnect_async(); ``` ## 与 Node LiveKit 桥接服务的协议约定 硬件端连接: ```text ws://:/ws/device?deviceId= ``` 文本消息使用 JSON。 音频消息使用二进制帧: ```text 20 字节 header + PCM16 LE payload ``` 当前固定音频格式: ```text sampleRate: 16000 channels: 1 bits: 16 frame: 20ms 左右 ``` 如果 Node 下发音频格式不一致,硬件端会丢帧并打印 `format mismatch`。 ## 与主链路的关系 这次硬件端新增的是“工具执行能力”和“实时通话音频能力”,而不是在硬件端自己做联系人识别。 联系人识别/目标设备号选择由主链路完成: - App/后台维护联系人别名与设备号映射。 - 主链路意图识别根据用户语音得到目标 `toId`。 - 硬件端只接受 `toId` 并发起呼叫。 所以硬件端不关心“小红”“爸爸”“卧室设备”这些自然语言名称,它只关心最终的设备标识。 ## 调试日志关键字 设备端常用日志关键字: ```text voice.app.lkbridge bridge ws connect bridge ws: connected incoming call ringing device call requested device accept requested device reject requested device_reject_ack device hangup requested call_start first pcm queued writer stats first uplink pcm sent uplink stats direct audio playback stopped ``` Node 端常用日志关键字: ```text [bridge.ws] device= connected [bridge.call] device invite created [bridge] incoming_call [bridge.ws] device= text: { type: 'device_reject', ... } [bridge.call] invite= ... pending->rejected by= [call.accept] [bridge] joined room [bridge.uplink] audio stats [bridge.ws] audio stats ``` ## 当前限制与后续优化点 ### 1. Node 地址仍是硬编码 当前硬件端地址在 `voice_livekit_poll_msg.c` 顶部宏里: ```c #define LK_BRIDGE_WS_HOST "39.108.110.91" #define LK_BRIDGE_WS_PORT "3000" ``` 后续如果要更适合测试/生产切换,可以改为: - 配置文件读取。 - BLE/云端配置下发。 - 编译参数注入。 ### 2. 默认目标仍有兜底值 ```c #define LK_BRIDGE_DEFAULT_CALL_TARGET "f17c0a1a322ed2ad" ``` 正常语音发起通话时,目标应该由 MCP 入参 `toId` 提供;默认目标只是防御性兜底。 ### 3. 来电拒接已接入,但拒接入口固定为三击 当前来电中支持: - 双击接听。 - 三击拒接。 - 对端取消或超时后 Node 下发结束。 为避免来电拒接后继续打开二维码页,主按键里的三击打开信息/二维码页逻辑已经移除。后续如果还需要二维码入口,建议放到设置菜单、五击/六击/七击组合,或由语音/页面触发,而不要复用三击。 ### 4. 音频参数仍需要按设备环境微调 当前播放/录音参数已经经过一轮调试,但不同硬件、外壳、麦克风距离、扬声器音量下仍可能需要调整: ```c LK_BRIDGE_RECORD_ANALOG_GAIN LK_BRIDGE_RECORD_DIGITAL_GAIN LK_BRIDGE_PLAY_DIGITAL_GAIN LK_BRIDGE_AUDIO_BUFFER_COUNT LK_BRIDGE_AUDIO_BUFFER_SAMPLES LK_BRIDGE_AUDIO_START_PREFILL ``` ### 5. 与主链路音频资源互斥 通话开始时会停止唤醒/录音/播放相关共享音频路径: ```c app_wakeup_stop(); lisa_audio_record_stop(); lisa_audio_play_stop(); ``` 通话结束后再恢复: ```c app_wakeup_start(); voice_cloud_reconnect_async(); ``` 这是为了避免主链路 ASR/TTS 和通话链路同时占用 `audio0` 导致噪声、卡顿或录音失败。