Files
arcs/LIVEKIT_CALL_LINK.md
2026-08-13 16:50:52 +08:00

711 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<did>
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=<device_did>
```
设备 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。
- 后面 payloadPCM16 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://<LK_BRIDGE_WS_HOST>:<LK_BRIDGE_WS_PORT>/ws/device?deviceId=<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=<id> connected
[bridge.call] device invite created
[bridge] incoming_call
[bridge.ws] device=<id> text: { type: 'device_reject', ... }
[bridge.call] invite=<id> ... pending->rejected by=<deviceId>
[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` 导致噪声、卡顿或录音失败。