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