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

17 KiB
Raw Permalink Blame History

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。

这条链路和主对话链路是并行关系:主对话链路负责“识别用户想打电话”和“调用工具”,通话链路负责“真正建立通话和传输音频”。

核心链路

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。
  • 后面 payloadPCM16 little-endian。
  • 下行方向Node -> 硬件,flags = 0x00
  • 上行方向:硬件 -> Nodeflags = 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()

处理流程:

  1. 校验 magic/version/sampleRate/channels。
  2. 确认当前处于 LK_CALL_ACTIVE
  3. 把 PCM payload 放入 g_pcm_queue
  4. lk.audio.writer 线程从队列取帧。
  5. 调用 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()

处理流程:

  1. 音频设备回调 PCM。
  2. uplink_accumulate_pcm() 聚合为 640 字节一帧。
  3. 放入 g_uplink_queue
  4. lk.audio.uplink 线程组装 20 字节 header。
  5. 调用 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()

行为:

  • 保存 inviteIdfromId
  • 显示表情:
#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;
}

这样做的目的:

  • 避免双击同时触发拍照识图、唤醒或其他主链路行为。
  • 避免来电中三击拒接后又打开二维码/信息页。
  • 让通话状态机拥有双击接听、三击拒接、双击挂断的优先处理权。

发起呼叫流程

语音发起

  1. 用户单击设备按钮唤醒。
  2. 用户说“给小红打电话”。
  3. 主链路云服务识别为通话意图。
  4. 主链路根据联系人/设备映射得到目标 toId
  5. 主链路返回 MCP 调用:
{
  "name": "ls.built_in.call_device",
  "arguments": {
    "toId": "目标设备号"
  }
}
  1. 硬件端执行 mcp_tool_call_device.c
  2. 调用 voice_livekit_bridge_request_call(toId)
  3. 硬件端通过 WebSocket 发:
{"type":"device_call","toId":"目标设备号"}
  1. 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":"..."})

接听流程

  1. Node 给硬件端发送:
{
  "type": "incoming_call",
  "inviteId": "...",
  "fromId": "..."
}
  1. 硬件端进入 LK_CALL_RINGING
  2. 设备循环播放提示音并显示 wait 表情。
  3. 用户双击按钮。
  4. 硬件端发送:
{"type":"device_accept","inviteId":"..."}
  1. Node 完成接听,双方加入 LiveKit 房间。
  2. Node 下发:
{"type":"call_start","sampleRate":16000,"channels":1}
  1. 硬件端启动录音和播放,进入 LK_CALL_ACTIVE

拒接流程

来电中三击按钮:

{"type":"device_reject","inviteId":"..."}

Node 收到后:

  1. 校验 invite 仍为 pending
  2. 将 invite 状态更新为 rejected
  3. 回包给设备:
{"type":"device_reject_ack","inviteId":"...","status":"rejected"}
  1. 通知主叫端 call_endreason 为 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 导致噪声、卡顿或录音失败。