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 液态填充变化