chore: migrate project into clean repository

This commit is contained in:
yuuux
2026-08-13 16:50:52 +08:00
commit d1d25a09e7
27405 changed files with 9422808 additions and 0 deletions

710
LIVEKIT_CALL_LINK.md Normal file
View File

@@ -0,0 +1,710 @@
# 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` 导致噪声、卡顿或录音失败。