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