codev / docs /friend /data-flow.md
chenbhao's picture
chore: docs v1.0
eeeb2b6
|
Raw
History Blame
15.8 kB
# Friend 数据流
本文档详细描述 Friend 系统中的四种核心数据流,以及 SSE 事件格式规范。
---
## 1. 文字对话流
用户在前端输入框输入文字,按下 Enter 发送。
```
用户输入文字
ChatInput.tsx ── POST /plugins/friend/chat ──────────────────┐
│ { message: "你好" } │
│ │
▼ │
handleFriendApi() ── friendService.start() │
│ friendService.sendText(message) │
▼ │
FriendService.sendText() │
│ │
├── 通知 inboundListeners (bridge hook turn tracking) │
│ │
└── messageQueueManager.enqueue() │
│ { mode: 'prompt', skipSlashCommands: true, │
│ bridgeOrigin: true, origin: { server: 'friend' }} │
│ │
▼ │
AI Provider (Anthropic/NVIDIA/OpenAI) │
│ │
├── 处理消息 │
├── 可调用 friend_emotion 工具设置表情 │
├── 可调用 friend_screen_observe 观察屏幕 │
│ │
▼ │
AI 回复流回 (通过 useFriendBridge / REPL) │
│ │
▼ │
FriendService.broadcastResponse(text) │
│ │
├── broadcastToVrm({ text }) │
│ │ │
│ ▼ SSE data │
│ TextBubble 收到 │
│ │ │
│ ├── 重置气泡,显示文字 │
│ ├── 启动打字机效果 (逐字符显示) │
│ │ - CJK: 200ms/char (TTS开启) / 80ms (关闭) │
│ │ - English: 60ms/char (TTS开启) / 30ms (关闭)│
│ ├── 打字机完成后渲染 Markdown │
│ └── 1秒后触发 onMessage → VRMScene 表情动作 │
│ │
├── if TTS enabled: │
│ ├── generateTts(text) │
│ │ ├── stripForTts() 清洗文本 │
│ │ └── EdgeTTS 或 QwenTTS 生成 MP3 │
│ │ │
│ ├── broadcastToVrm({ audioUrl, sendFirstTts }) │
│ │ │ │
│ │ ▼ SSE data │
│ │ TextBubble 开始音频队列播放 │
│ │ │ │
│ │ └── LipSync.playAudio(url) │
│ │ ├── fetch 音频文件 │
│ │ ├── decodeAudioData │
│ │ ├── 连接到 lipSyncNode (分析) + gainNode (扬声器) │
│ │ └── 播放时实时更新 VRM 嘴形 │
│ │ │
│ └── extendMuteForTts(audioId) │
│ └── 精确计算 MP3 时长,更新静音定时器 │
│ │
└── broadcastToVrm({ replyDone: true }) │
│ │
▼ SSE data │
TextBubble 调度气泡隐藏 (2s 延迟) │
或等待后续 appendText 消息 │
```
---
## 2. 语音捕获流 (F2 通话模式)
用户按下 F2 进入连续语音通话模式,再次按下 F2 结束通话。
### 2.1 启动通话
```
用户按 F2
ChatInput.startVoiceCall()
├── setVoiceCallActive(true)
└── useServerStt.startStreaming()
└── POST /plugins/friend/voice/start
handleFriendApi()
└── friendService.startVoiceCapture()
├── 检测 STT provider (Groq→Whisper→Anthropic→Doubao)
├── startSttConnection() (8s 超时)
│ ├── Groq: connectGroqStream()
│ ├── Local: connectLocalWhisperStream()
│ ├── Anthropic: connectVoiceStream()
│ └── Doubao: connectDoubaoStream()
├── loadAudioCapture()
│ ├── 尝试 arecord (ALSA)
│ │ args: -D default -r 16000 -f S16_LE -c 1 -t raw -q
│ ├── 失败则尝试 parecord (PulseAudio)
│ │ args: --raw --rate=16000 --format=s16le --channels=1
│ └── 500ms 验证窗口: 确认子进程输出音频数据
├── arecord 数据回调:
│ ├── if not muted → 转发到 STT connection.send(chunk)
│ └── if not muted → 转发到 VAD processAudio(float32)
└── vadInstance.start()
```
### 2.2 语音检测与转录
```
麦克风音频流 (16kHz S16LE)
├──► STT Connection.send(chunk) (实时流式转录)
└──► SileroVad.processAudio(float32)
├── RMS 预过滤 (阈值 0.004)
│ ├── < 阈值 → 概率 = 0 (跳过推理)
│ └── >= 阈值 → ONNX 推理
├── 状态机
│ ├── pre-speech: 需要连续 10 帧 (~320ms) 确认说话
│ ├── speaking: 语音持续中
│ └── silence redemption: 连续 20 帧 (~640ms) 静音触发 endSpeech
└── onSpeechEnd callback
FriendService._flushVadSegment()
├── 创建新的 STT 连接 (旧的连接继续处理)
├── 等待旧连接 finalize()
│ └── 获取转录文本推入 captureTranscripts
├── if 有转录文本:
│ ├── this.sendText(transcript)
│ │ │
│ │ ▼
│ │ messageQueueManager.enqueue() → AI 开始处理
│ │
│ └── this.startAiTurnMute()
│ ├── muted = true
│ ├── VAD pause
│ └── 30s 超时安全性定时器
└── 循环继续监听下一段语音
```
### 2.3 AI 回复与静音解除
```
AI 处理完成
FriendService.broadcastResponse(text)
├── broadcastToVrm({ text }) // 显示文字
├── if TTS enabled:
│ ├── generateTts(text)
│ │ │
│ │ ▼
│ ├── broadcastToVrm({ audioUrl, sendFirstTts: true })
│ │
│ └── extendMuteForTts(audioId)
│ ├── getMp3DurationMs() 精确计算
│ ├── 取消 30s 安全性定时器
│ └── 设定精确的播放时长定时器
└── broadcastToVrm({ replyDone: true })
播放完成后 → unmute()
├── muted = false
└── VAD resume (可继续接收语音)
```
### 2.4 结束通话
```
用户按 F2 (再次)
ChatInput.endVoiceCall()
└── useServerStt.stopStreaming()
└── POST /plugins/friend/voice/stop
friendService.stopVoiceCapture()
├── arecord.kill('SIGTERM') → 2s 后 SIGKILL
├── capturing = false
├── clearMute()
├── VAD reset()
├── STT connection.finalize() + close()
├── 发送剩余转录文本
└── 返回完整转录
```
---
## 3. 情绪表情流
LLM 调用 `friend_emotion` 工具触发情绪更新。
```
LLM 处理完成,调用 friend_emotion 工具
FriendEmotionTool.call({ emotion: 'happy', intensity: 0.8, mood_delta: 2 })
├── broadcastToVrm({ emotion: 'happy', emotionIntensity: 0.8 })
│ │
│ ▼ SSE data
│ App.tsx handleVrmMessage
│ │
│ ├── emotionActionMap['happy'] = 'happy'
│ │
│ ├── sceneRef.current.setEmotionWithReset('happy', 5000, 0.8)
│ │ │
│ │ ▼
│ │ VRMScene.setEmotionWithReset (via forwardRef)
│ │ │
│ │ └── EmoteController.setEmotionWithReset('happy', 5000, 0.8)
│ │ ├── setEmotion('happy', 0.8)
│ │ │ ├── 获取 happy 的 blend shapes: [{name:'happy', val:0.2}, {name:'aa', val:0.8}]
│ │ │ ├── 应用 intensity: aa = 0.8*0.8 = 0.64, happy = 0.2*0.8 = 0.16
│ │ │ ├── isTransitioning = true
│ │ │ └── 记录目标 blendshape 值
│ │ │
│ │ └── setTimeout(5000ms → setEmotion('neutral'))
│ │
│ └── sceneRef.current.playAction('happy')
│ │
│ ▼
│ MotionController.playAction('happy')
│ ├── loadClip('happy.fbx')
│ ├── crossFadeTo(clip, 0.3s)
│ ├── LoopOnce + clampWhenFinished
│ └── 完成后 crossFade 回 idle 动画
├── 处理 mood_delta
│ ├── 读取当前 moodIndex = 60
│ ├── newMood = clamp(60 + 2, 0, 100) = 62
│ ├── 持久化到 prefs
│ └── broadcastToVrm({ moodDelta: 2, moodIndex: 62 })
│ │
│ ▼ SSE data
│ MoodIndicator 收到
│ ├── 显示心情数值变化气泡 (+2)
│ ├── Canvas 动画: displayPercent 从 60 → 62 渐变
│ └── 5s 后自动隐藏
└── 返回 tool result
```
### 每帧更新循环 (VRMScene animate)
```
requestAnimationFrame 循环 (约 60fps)
├── 1. MotionController.update(delta)
│ └── AnimationMixer.update(delta)
├── 2. 应用 Relaxed Hand Pose (非舞蹈状态)
│ └── 手指自然弯曲 + 微妙颤动
├── 3. Humanoid.update()
├── 4. 眼球追踪 (camera 模式)
│ └── lookAtTarget = camera.position
├── 5. LookAt.update(delta)
├── 6. Eye Saccades Controller.update()
│ └── 每隔 400-1200ms 添加随机眼球微动偏移
├── 7. Blink State Machine.update()
│ └── 随机眨眼 (间隔 1-6s, 时长 150ms, sin 曲线)
├── 8. EmoteController.update(delta)
│ └── cubic ease 过渡到目标 blendshape 值
├── 9. LipSync.update(vrm, delta)
│ ├── 读取 wlipsync 音频分析节点的音素权重
│ ├── 选择胜者/亚军音素
│ ├── Attack/Release 平滑 (50/30)
│ └── 设置 VRM 嘴形 blendshapes (aa, ee, ih, oh, ou)
├── 10. ExpressionManager.update()
└── 11. SpringBoneManager.update(delta)
└── 物理头发/衣服/饰品模拟
```
---
## 4. SSE 事件格式
所有前端 SSE 事件通过 `GET /plugins/friend/events` 接收,格式为标准 SSE (`data: {json}\n\n`)。
### 4.1 VrmBroadcastPayload 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `text` | string | 否 | AI 回复文字,TextBubble 显示并启动打字机效果 |
| `emotion` | string | 否 | VRM 表情名,触发 EmoteController 切换 blend shapes |
| `emotionIntensity` | number | 否 | 表情强度 0-1,默认 1 |
| `audioUrl` | string | 否 | TTS 音频 URL,TextBubble 触发 LipSync 播放 |
| `audioIndex` | number | 否 | 音频播放顺序索引,用于多句排序 |
| `clearText` | boolean | 否 | 清空气泡文字和音频队列 |
| `imageUrl` | string | 否 | 图片 URL,在气泡中显示 |
| `moodDelta` | number | 否 | 心情变化量,MoodIndicator 显示浮动气泡 |
| `moodIndex` | number | 否 | 当前心情指数 0-100,MoodIndicator Canvas 更新 |
| `sendFirstTts` | boolean | 否 | 开始 TTS 播放的信号,重置音频队列 |
| `appendText` | boolean | 否 | 追加文字模式,后续句子的文字和音频配对 |
| `replyDone` | boolean | 否 | 回复完成信号,TextBubble 调度气泡隐藏 |
### 4.2 典型回复序列
```
1. { text: "你好!今天心情不错啊!", replyDone: true }
→ 显示文字,打字机效果,1s 后触发表情
2. { text: "一起玩吧!" }
{ audioUrl: "http://127.0.0.1:3456/plugins/friend/audio/123-1", sendFirstTts: true }
{ replyDone: true }
→ 显示文字 + 播放 TTS + 语音结束后隐藏气泡
3. { text: "今天天气真好。" }
{ audioUrl: "...", sendFirstTts: true, emotion: "happy", emotionIntensity: 0.8 }
{ appendText: true, text: "要不要出去走走?", audioUrl: "...", audioIndex: 1 }
{ appendText: true, text: "我知道一个好地方。", audioUrl: "...", audioIndex: 2 }
{ replyDone: true }
→ 多句子回复,每句独立音频,按索引顺序播放
→ 第一句发送时触发 happy 表情
4. { emotion: "think", emotionIntensity: 0.7 }
→ 思考阶段的表情更新(LLM 处理中)
5. { clearText: true }
→ 清空气泡(新会话)
6. { moodDelta: 3, moodIndex: 63 }
→ 心情更新,显示 +3 浮动气泡,Canvas 液态填充变化
```