跳转到内容

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.6pnpm 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.vueUI 装配层:面板/数字人/气泡/「停止播报」按钮
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
streamerListids = [] 说明 UE 端没在推流
signallingDisconnected信令 WebSocket 断开:UE 没推流,或 Cirrus 默认单玩家被别人挤掉
streamerDisconnectedUE 端主动退出推流

5.3 需要的方法与参数(核心约定) ​

所有指令通过 pixelStreaming.emitUIInteraction(payload) 下发。

前端 → UE

指令字段说明
BeginSound无一轮开始
UE:重置 index 计数
清空上一轮残留缓冲与队列
幂等
GenerateSoundindex: number
audioStream: string(PCM 的 base64)
追加一片音频
前端连续发送
不等任何回执
StartSound无起播,整轮只发一次。UE:已就绪→立即播;未就绪→挂起"待起播"
EndSound无本轮投递完毕:UE 播完已入队音频后回 Idle / 复位
StopSound无打断 / 收轮复位:立即停播 + 清空缓冲与队列 + 复位
ListStreamers无调试用,请求信令服返回 streamer 列表

音频格式

项取值
格式PCM(裸采样点,无容器头)
位深16bit 有符号
声道单声道
字节序小端
采样率16000Hz
单片长度默认 32KB ≈ 0.75 秒音频
单片对齐每片都是整数个采样点(偶数字节)
容器无任何头(就是纯采样点字节流)

⚠️ UE 侧接入要点(必须用 RAW PCM 导入,不要用容器格式导入) ​

audioStream base64 解码后是裸 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 + 16bit

32KB 时单片约 0.74 秒音频。放开到 64KB(≈1.5s)实测会让 UE 崩溃,不建议。

投喂节奏与背压(三层保护):

  1. 单片主动收紧到 32KB;
  2. 片间节流:相邻两片至少间隔 20ms(CHUNK_SEND_INTERVAL);
  3. 发送缓冲背压: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/mpegdecodeAudioData → OfflineAudioContext 重采样到 16k
/digital-human/tts/aliyun(整段合成)【即将弃用】audio/mpeg同上
双工 /aliyunStream/ws裸 PCM(后端 duplex-sample-rate=16000)与 16k 一致 ⇒ 直接透传

前端继续出声音,UE 只驱动口型(UE 侧应静音播放,避免回声)。


七、注意事项(踩坑清单) ​

  1. UE 版本必须与前端库版本一致:连不上时先看控制台协议版本。
  2. UE 必须正在推流:streamer 列表: 0 个 → UE 端没启动 PixelStreaming 推流。
  3. 单玩家限制:Cirrus 信令默认单玩家,多开会互相挤线。
  4. 浏览器自动播放策略:默认 StartVideoMuted: true,首次交互后解除静音。
  5. audioStream 必须 base64:直接传二进制会丢失。
  6. 采样率必须 16000:UE 按 16k 解读,不符会整体变调。
  7. 不要在 EndSound 之前复位:一轮内队列空了也要保持播报状态。
  8. index 跳号:UE 收到跳号应回 error。
  9. 透明/去背:当前用纯绿幕软 chroma key;真正透明需 UE 启用 WebRTC Alpha output。
  10. AFK 空闲检测必须关闭。
  11. 同一时刻只跟踪一轮:stopSound() 会立即作废本轮。
  12. 调试日志默认开启(debug = true),生产上线建议 __ueSound.setDebug(false)。
  13. 音色(voice)由前端 TTS 决定,UE 侧不再需要 voice 参数。
  14. 跨域 iframe 老方案:public/ue-bridge.js 仅当被迫嵌在 UE 播放器页面里时才需要。
  15. UE 回执文案不明时先看原始报文:__ueSound.setVerbose(true)。
  16. 段间空档会让口型永久静止(重点):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 闭嘴,真实音频到达后自然续上(静音长度 ≈ 等待时长,本地声音同样要等,声画仍对齐)。
  17. 不要指望"再发一次 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 dataUE 用容器格式导入解裸 PCM,改成 RAW PCM 导入
UE Fatal error! 崩溃单片过大(64KB/片),已默认 32KB/片
前端也没声音与 UE 无关:检查播报开关、/tts 返回、浏览器静音

数字人「小慧」项目文档