外观
UE 联调
适用项目:
bigscreen(大塔智慧管廊可视化大屏)对接方式:大屏前端直连 UE PixelStreaming
最后更新:2026-09-23(协议调整为整轮一条音频流:
BeginSound/GenerateSound{index,audioStream}/StartSound/EndSound/StopSound,采样率统一 16000Hz;TTS 统一采用流式双工
/tts/aliyunStream/ws(非流式/tts/aliyun整段 mp3 即将弃用);收轮时除
EndSound外会补发一次StopSound立即复位)
本地启动与资源说明见 本地启动 UE。
一、整体链路(一句话)
语音一律由前端播放(本地预生成 mp3 / 流式双工 /tts/aliyunStream/ws 实时合成 / 非流式 /tts/aliyun 整段合成即将弃用), 前端把同一份音频统一转成 PCM 16bit 单声道小端 16000Hz,按「整轮一条音频流」连续投喂 UE; UE 不做 TTS、也不做任何解码,只按采样序列驱动数字人口型与动作。
一轮(一次回答 / 一次导览)内 UE 一直保持播报状态,中间不复位。
一次回答 / 一次导览开始
└─ utils/audio.js beginUERound() ← ① 一轮只发一次 BeginSound
└─ 整段音频 → utils/uePcm.js 解码 + 重采样到 16k → feedUEAudio(blob)
└─ 流式音频 → 按该路采样率增量转 16k → feedUEPcmChunk(bytes, sampleRate)
└─ utils/ueSound.js 连续下发 GenerateSound{index, audioStream}(切片,不等回执)
└─ UE 收到首批数据 → 回 { "msg": "success" }
└─ 前端发 StartSound(整轮唯一一次)→ UE 起播口型
一次回答 / 一次导览结束(全部音频投递完)
└─ utils/audio.js endUERound() ← ⑦ 发 EndSound,UE 播完队列后回 Idle / 复位要点:
BeginSound/GenerateSound/StartSound/EndSound/StopSound是顶层的裸 type 字段,index、audioStream与type同级。- 音频统一为 PCM / 16bit / 单声道 / 小端 / 16000Hz,UE 侧不需要做任何解码。
- 所有播报路径都驱动 UE:本地 mp3、流式双工
/digital-human/tts/aliyunStream/ws(统一采用)、非流式/digital-human/tts/aliyun(即将弃用)。 - 音频超过数据通道单条上限时自动切片,分片序号
index从 0 在整轮内连续递增。 - 一轮 = 一次回答 / 一次导览:一轮内只发一次
BeginSound与一次StartSound,段与段之间不发StopSound。 - UE 通道不通不影响出声,只是数字人不做口型。
- 指令 type 与字段名集中在
utils/ueSound.js顶部常量里,UE 侧改名只需改这几处。
二、引入的库
| 库 | 版本 | 安装命令 |
|---|---|---|
@epicgames-ps/lib-pixelstreamingfrontend-ue5.4 | ^1.1.6 | pnpm add @epicgames-ps/lib-pixelstreamingfrontend-ue5.4 |
包名里的 UE 版本必须和推流端 UE 版本一致,否则信令握手会因协议版本不符失败。可选包名:-ue5.3 / -ue5.4 / -ue5.5。
三、涉及的文件
| 文件 | 职责 |
|---|---|
src/components/Avatar/Core/Avatar.vue | 数字人容器。配置了 VITE_APP_UE_SIGNALLING_URL → 渲染 UePixelStreaming;未配置或初始化失败 → 回退 iframe |
src/components/Avatar/Core/UePixelStreaming.vue | 创建 PixelStreaming 实例 挂 video 生命周期事件监听 绿幕抠像 window.__uePS 调试入口 |
src/components/vis-assistant/utils/uePcm.js | 音频格式转换:整段编码音频解码 + 重采样到 16k;流式裸 PCM 的有状态重采样器 |
src/components/vis-assistant/utils/ueSound.js | 对接核心 整轮会话管理 按单条消息上限切片 发送背压 UE 回传解析 跨域 iframe 桥接 |
src/components/vis-assistant/utils/audio.js | 前端音频播放。音频一律前端播放,同一份音频转 16k PCM 后连续投喂 UE |
src/components/vis-assistant/useAssistantCore.js | 会话组装层(组合根,约 410 行):解构入参、创建共享状态、拼装 core/ 各模块、对外暴露 API |
src/components/vis-assistant/core/ | 会话各职责模块:其中与 UE 直接相关的是 speech.js(一轮的起止编排:playTTSSequence / 增量队列 / 取消播报)、aiChatStream.js(智能交互边收边播 + 双工会话 ended → endUERound())、duplex.js(双工节流投喂状态与常量)、demoTour.js(导览分段播报与段间预投喂);其余见前端项目结构 → 第三节 |
src/components/vis-assistant/VisAssistant.vue | UI 装配层:面板/数字人/气泡/「停止播报」按钮 |
public/ue-bridge.js | 跨域 iframe 场景的桥接脚本(当前自集成模式用不到) |
四、环境配置
.env.development(.env.production / .env.staging 同理):
bash
# 配了它 → Avatar 走 UE 自集成模式;注释掉 → 回退 iframe 方案
VITE_APP_UE_SIGNALLING_URL = 'ws://localhost:91'
# iframe 兜底地址(旧 React 数字人页面)
VITE_APP_AVATAR_URL = 'http://localhost:91/'ws://是 UE 信令服务地址(Circle/Cirrus),不是 WebRTC 媒体地址。- WebSocket 不受同源策略限制,跨域直连没问题;生产环境改成 UE 主机真实 IP。
- 端口必须与 UE 端信令端口一致,否则一直卡在「正在连接」。
五、怎么和 UE 通信
5.1 建立连接
UePixelStreaming.vue 在 onMounted 里动态 import 像素流库并创建实例:
js
const { Config, PixelStreaming, Flags } = await import('@epicgames-ps/lib-pixelstreamingfrontend-ue5.4')
const config = new Config({
useUrlParams: false,
initialSettings: {
ss: props.signallingUrl,
AutoConnect: true,
AutoPlayVideo: true,
StartVideoMuted: props.startMuted,
HoveringMouse: false,
MatchViewportRes: true,
KeyboardInput: false,
MouseInput: true,
TouchInput: true,
GamepadInput: false,
XRControllerInput: false
}
})
config.setFlagEnabled(Flags.AFKDetection, false) // 关闭空闲踢线
pixelStreaming = new PixelStreaming(config, { videoElementParent: hostRef.value })
window.pixelStreaming = pixelStreaming
setPixelStreaming(pixelStreaming)5.2 监听的生命周期事件
| 事件 | 含义 / 处理 |
|---|---|
videoInitialized | 视频就绪 → 状态改 connected 尝试解除静音 启动绿幕抠像 |
webRtcConnecting / webRtcConnected | 仅打日志 |
webRtcDisconnected / webRtcFailed | 状态提示「UE WebRTC 已断开 / 建立失败」 |
config | 打印信令服 protocolVersion |
streamerList | ids = [] 说明 UE 端没在推流 |
signallingDisconnected | 信令 WebSocket 断开:UE 没推流,或 Cirrus 默认单玩家被别人挤掉 |
streamerDisconnected | UE 端主动退出推流 |
5.3 需要的方法与参数(核心约定)
所有指令通过 pixelStreaming.emitUIInteraction(payload) 下发。
前端 → UE
| 指令 | 字段 | 说明 |
|---|---|---|
BeginSound | 无 | 一轮开始 UE:重置 index 计数清空上一轮残留缓冲与队列 幂等 |
GenerateSound | index: numberaudioStream: string(PCM 的 base64) | 追加一片音频 前端连续发送 不等任何回执 |
StartSound | 无 | 起播,整轮只发一次。UE:已就绪→立即播;未就绪→挂起"待起播" |
EndSound | 无 | 本轮投递完毕:UE 播完已入队音频后回 Idle / 复位 |
StopSound | 无 | 打断 / 收轮复位:立即停播 + 清空缓冲与队列 + 复位 |
ListStreamers | 无 | 调试用,请求信令服返回 streamer 列表 |
音频格式
| 项 | 取值 |
|---|---|
| 格式 | PCM(裸采样点,无容器头) |
| 位深 | 16bit 有符号 |
| 声道 | 单声道 |
| 字节序 | 小端 |
| 采样率 | 16000Hz |
| 单片长度 | 默认 32KB ≈ 0.75 秒音频 |
| 单片对齐 | 每片都是整数个采样点(偶数字节) |
| 容器 | 无任何头(就是纯采样点字节流) |
⚠️ UE 侧接入要点(必须用 RAW PCM 导入,不要用容器格式导入)
audioStreambase64 解码后是裸 PCM 采样点字节流,没有 RIFF/MP3 头。若用RuntimeAudioImporter的容器导入接口,UE 会把它判成Mp3并报错(现象:数字人手势正常,但口型完全不动)。✅ 正确做法是用 RAW PCM 导入接口:
ImportAudioFromRAWBuffer(WorldContext, AudioData, SampleRate=16000, NumOfChannels=1, RAWFormat=Int16)。三个参数必须与前端约定一致:16000Hz / 1 声道 / Int16。
UE → 前端回传(通过 addResponseEventListener('handle_responses', handler) 监听):
json
{ "msg": "success" }前端对回执文案做宽松匹配(ueSound.js 的 isAudioReadyText),命中任意一条即视为就绪:音频构建完成 / 音频xxx完成 / 含构建完成 / 含成功 / 含success。
回执时机:整轮只回一次 —— UE 收到「能播的第一批数据」后回。
⚠️ 前端不依赖该回执起播(重要):前端在首发后 300ms(
UE_START_FALLBACK_MS)内没收到回执就主动发StartSound;回执若更早到达则立即起播。
起播对齐(本地出声与 UE 口型同时开始)
StartSound走插队立即发送,不排在音频片之后;- 本轮第一次出声前调用
waitUEStartForAlignment(),等 UE 起播后再放音,最多等 900ms(超时照常出声); - 由于整轮是一条连续流,偏差只会出现在整轮第一段。
可按需微调:__ueSound.setStartAlignment(true, 900, 120)(启用;等待上限 900ms;额外补偿 120ms)。
音频分片
emitUIInteraction 底层是 RTCDataChannel.send(),单条消息上限即 SDP 的 max-message-size(默认约 64KB)。前端取「协议上限」与「默认单片上限 32KB」的较小值:
chunkBytes = min(maxMessageSize, DEFAULT_CHUNK_BYTES=32KB)
maxSamples = floor((chunkBytes - 1024) * 3/4 / 2) // base64 + 16bit32KB 时单片约 0.74 秒音频。放开到 64KB(≈1.5s)实测会让 UE 崩溃,不建议。
投喂节奏与背压(三层保护):
- 单片主动收紧到 32KB;
- 片间节流:相邻两片至少间隔 20ms(
CHUNK_SEND_INTERVAL); - 发送缓冲背压:
bufferedAmount > 1MB时暂停投喂(20ms 轮询,最长等 6s)。
一轮的完整时序
jsonc
{ "type": "BeginSound" }
{ "type": "GenerateSound", "index": 0, "audioStream": "//uQxAAA…" }
{ "type": "GenerateSound", "index": 1, "audioStream": "…" }
// UE → 前端:{ "msg": "success" }
{ "type": "StartSound" }
{ "type": "GenerateSound", "index": 3, "audioStream": "…" }
// … 省略 …
{ "type": "EndSound" }
{ "type": "StopSound" } // 收轮时补发,立即复位
// 中途打断:{ "type": "StopSound" }收轮复位(EndSound 后补发 StopSound)
EndSound 语义是「UE 播完自己已入队的音频后再复位」。但 UE 是静音播放、只驱动口型,用户听到的声音完全来自前端,而 UE 播放位置通常滞后于前端。为此前端在收轮时紧接着补发一次 StopSound,使数字人动作与前端声音同时结束。可用 __ueSound.setRoundEndForceStop(false) 关掉。
5.4 ueSound.js 对外 API
| 方法 | 说明 |
|---|---|
beginUERound() | 一轮开始(幂等):已在轮内直接复用,新一轮等通道就绪后发 BeginSound |
feedUEAudio(audio) | 核心:整段编码音频(mp3/wav)→ 解码 + 重采样 16k → 连续 GenerateSound |
feedUEPcmChunk(bytes, sampleRate) | 流式裸 PCM 分片 → 增量重采样到 16k → 连续 GenerateSound |
endUERound() | 本轮投递完毕:发 EndSound,并按需补发 StopSound |
stopSound() / abortUERound() | 打断:作废本轮 + StopSound |
setRoundEndForceStop(bool) | 收轮时是否补发 StopSound(默认 true) |
speakByUEAudio(audio) | 一次性下发一段音频并自动收轮(调试用) |
getRoundInfo() | 当前轮次状态(调试用) |
getMaxMessageSize() | 数据通道单条消息上限 |
setPixelStreaming(instance) | 外部注入实例 |
setUESoundEnabled(bool) | 是否向 UE 发驱动指令(只影响口型,不影响前端出声) |
setDebug(bool) / setVerbose(bool) | 收发日志 / 每条 postMessage 原文 |
5.5 业务侧的触发点
一轮的开始(无需业务层显式调用):utils/audio.js 每次投喂音频前调 beginUERound()(幂等),一轮内多段音频自动归入同一轮。
一轮的结束(均会发出 EndSound,默认紧随一条 StopSound):
| 场景 | 入口 |
|---|---|
| 智能交互增量播报整轮结束 | runAudioQueue() → endUERound() |
| 专家问答 / 系统导览自定义问题 | playTTSSequence() 的 finally |
| 系统导览多段 | speakDemoSegments() / doSendDemoMessage() |
| 单段兜底回复 | speakLocalAudio() / speakDemoAnswer() 的 ended |
| 双工播报整轮回流完 | 双工会话 ended + deliveryDone |
上表入口的代码位置(
useAssistantCore.js已拆分为组装层 +core/模块):runAudioQueue/playTTSSequence/speakLocalAudio在core/speech.js;speakDemoSegments/doSendDemoMessage/speakDemoAnswer在core/demoTour.js;双工会话收轮在core/aiChatStream.js。
停止(均会发出 StopSound):点击「停止播报」、系统导览浮层「打断播报」、关闭助手面板、组件卸载、切换模式 / 新提问 / 关闭语音播报开关(系统导览播报中除外——导览始终播报、不受该开关影响,开关关闭不会打断导览)。
六、音频来源与 16000Hz 的保证
UE 侧不参与任何音频处理,因此「统一为 16k PCM」完全由前端保证:
| 音源 | 原始格式 | 转 16k 的方式 |
|---|---|---|
本地预生成 mp3(public/audios/) | audio/mpeg | decodeAudioData → OfflineAudioContext 重采样到 16k |
/digital-human/tts/aliyun(整段合成)【即将弃用】 | audio/mpeg | 同上 |
双工 /aliyunStream/ws | 裸 PCM(后端 duplex-sample-rate=16000) | 与 16k 一致 ⇒ 直接透传 |
前端继续出声音,UE 只驱动口型(UE 侧应静音播放,避免回声)。
七、注意事项(踩坑清单)
- UE 版本必须与前端库版本一致:连不上时先看控制台协议版本。
- UE 必须正在推流:
streamer 列表: 0 个→ UE 端没启动 PixelStreaming 推流。 - 单玩家限制:Cirrus 信令默认单玩家,多开会互相挤线。
- 浏览器自动播放策略:默认
StartVideoMuted: true,首次交互后解除静音。 audioStream必须 base64:直接传二进制会丢失。- 采样率必须 16000:UE 按 16k 解读,不符会整体变调。
- 不要在
EndSound之前复位:一轮内队列空了也要保持播报状态。 index跳号:UE 收到跳号应回error。- 透明/去背:当前用纯绿幕软 chroma key;真正透明需 UE 启用 WebRTC Alpha output。
- AFK 空闲检测必须关闭。
- 同一时刻只跟踪一轮:
stopSound()会立即作废本轮。 - 调试日志默认开启(
debug = true),生产上线建议__ueSound.setDebug(false)。 - 音色(voice)由前端 TTS 决定,UE 侧不再需要 voice 参数。
- 跨域 iframe 老方案:
public/ue-bridge.js仅当被迫嵌在 UE 播放器页面里时才需要。 - UE 回执文案不明时先看原始报文:
__ueSound.setVerbose(true)。 - 段间空档会让口型永久静止(重点):
StartSound整轮只发一次,所以一旦 UE 的播放队列在EndSound之前被播空并自行停/复位,本轮内后续GenerateSound不会再被"起播"——表现为数字人动作还在、口型从某一段起再不张开。系统导览里最容易触发的是「智能解说」第 5 段(无本地 mp3、需现请求/tts/aliyun合成,接口往返 1~3s,段间天然有等待窗口)。前端现有两道对策(均在utils/audio.js/core/demoTour.js):- 轮次开始预热:进入导览轮次时就为"无本地音频"的段预取 TTS 音频(日志
[demo] 预热第 N 段实时 TTS 音频),把等待窗口挪到本轮最开始,段间不再现合成; - 段间静音保活:若该段音频没能提前进入 UE 队列,则在"等待实时音频就绪"的窗口内按 300ms 节奏补发静音 PCM(日志
[UE] 静音保活开始/结束),让 UE 队列永不播空;静音期间 UE 闭嘴,真实音频到达后自然续上(静音长度 ≈ 等待时长,本地声音同样要等,声画仍对齐)。
- 轮次开始预热:进入导览轮次时就为"无本地音频"的段预取 TTS 音频(日志
- 不要指望"再发一次
StartSound"续播:第 16 条的根因只能从源头规避(别让队列播空);若要支持"播空后自动续播",需 UE 端确认BeginSound/StartSound幂等,并提供"队列播空 / 已复位"回执。
八、调试命令速查
js
__uePS.status // idle | connecting | connected | error
__uePS.listStreamers() // 看信令服有没有 UE 在推流
__uePS.unmute() // 手动解除静音
__uePS.reconnect() // 重连
__ueSound.getPixelStreaming() // 看有没有拿到实例(null = 通道不通)
__ueSound.getMaxMessageSize() // 数据通道单条消息上限
__ueSound.setMaxChunkBytes(32768) // 单片降到 32KB;0 = 恢复自动探测
__ueSound.getRoundInfo() // 当前轮次状态
__ueSound.speakByUEAudio(blob) // 手动下发一段音频验证
__ueSound.stopSound() // 停止数字人动作
__ueSound.setStartAlignment(true, 900, 120)
__ueSound.setUESoundEnabled(false) // 临时停用 UE 驱动
__ueSound.setRoundEndForceStop(false)
__ueSound.setDebug(false)
__ueSound.setVerbose(true)
__ueSound.listCapturedChunks() // 看抓到哪些分片
__ueSound.copyCapturedChunk(0) // 复制第 0 片 base64
__ueSound.downloadCapturedChunk(0) // 下载第 0 片裸 PCM(.pcm)
__ueSound.downloadCapturedChunk(0, true) // 下载第 0 片 WAV
__ueSound.setChunkCapture(false) // 关闭抓取并释放内存
__ueSound.setLogFullAudio(true) // 日志不再截断 audioStream排查顺序:__uePS.status → listStreamers() → __ueSound.getPixelStreaming() → unmute() → 播放一次导览,对照控制台 [UE] -> UE emitUIInteraction: 与 [UE] <- UE 回传: 两条日志定位问题。
九、一次播报的完整日志参考
[UE] -> UE emitUIInteraction: { type: 'StopSound' } ← ① 新提问:cancelTTS 先停上一轮
[TTS] blob: { size: 123456, type: 'audio/mpeg' }
[UE] -> UE emitUIInteraction: { type: 'BeginSound' } ← ③ 本轮开始
[UE] -> UE emitUIInteraction: { type: 'GenerateSound', index: 0, audioStream: '//uQx…' }
[UE] <- UE 回传: { msg: 'success' } ← ④ UE 首批就绪
[UE] -> StartSound 已下发(收到 UE 就绪回执后)
[UE] -> UE emitUIInteraction: { type: 'StartSound' } ← ⑤ 数字人开始口型
[UE] -> UE emitUIInteraction: { type: 'GenerateSound', index: 5, audioStream: '…' }
[UE] 本轮投喂完成:共 12 片 / 18.40s 音频,下发 EndSound
[UE] -> UE emitUIInteraction: { type: 'EndSound' } ← ⑥ 整轮结束异常分支对照:
| 日志 | 含义 / 处理 |
|---|---|
只有音频日志、没有 BeginSound / GenerateSound | 通道不通;数字人不会做口型,但前端仍有声音 |
Trying to send message larger than max-message-size | 单条消息超上限(理论上不会) |
有 GenerateSound、无 <- UE 回传 | UE 没回就绪回执,前端 300ms 后主动起播 |
整轮都不动、也没有口型,且无 StartSound | 老版本症状;现版本已改主动起播 + 收尾补发 |
手势正常、口型完全不动,出现 Decoding MP3 audio data / Invalid MP3 data | UE 用容器格式导入解裸 PCM,改成 RAW PCM 导入 |
UE Fatal error! 崩溃 | 单片过大(64KB/片),已默认 32KB/片 |
| 前端也没声音 | 与 UE 无关:检查播报开关、/tts 返回、浏览器静音 |