音频会话:Play 是意图,PlayingId 才是本次播放
自动关联目录:音频会话:Play 是意图,PlayingId 才是本次播放
核心判断:异步音频里,Play 只是当前意图;一次真实播放必须由 LoadGeneration、PlayingId 和持有的媒体生命周期共同定义。
一、业务冲突:播放命令经常早于资源可用
音频可能来自官方资产、内容包内存、外部媒体文件、平台流或 UE SoundWave。脚本可以在加载完成前 Play,也可以连续改源、Seek、Pause、切 2D/3D 或销毁对象;旧回调若只按对象地址应用,就会把上一首内容播到新对象状态上。
外部媒体回调结束前还必须保持原始内存有效。仅让 UObject 存活并不能证明中间件仍持有的数据缓冲区有效。
二、实际实现:每个对象一条 Slot,异步结果按代际提交
工程在 UWorldSubsystem 中为逻辑声音对象维护 VoiceSlot,保存路由、媒体、组件、空间参数和生命周期。每次 BeginLoad 增加 LoadGeneration,停止旧流、清理组件与 PlayingId;回调同时校验对象存在与代际。加载前的 Play 被记录为 bPlayWhenLoaded 和起播时间,资源就绪后再兑现。
内容包内存通过共享持有对象进入中间件 Callback Context,直到 EndOfEvent 才释放。中间件线程回调切回 Game Thread 后,还会核对 PlayingId 是否仍属于当前 Slot,避免 Stop/Replay 后的旧结束事件关闭新播放。
Slot 同时保留 2D/3D 路由、附着目标、循环、播放区间、速度、声音组、局部音量、距离与滚降参数;空间播放使用独立 Actor/Component 表示。游戏暂停与用户暂停分别记录,音乐可先 Fade 再暂停,短音效可立即冻结。场景音乐切换是上层策略,最终仍通过同一 Session 转换停止旧播放并提交新源;多人同步只传播放意图、时间锚点和必要参数,不传本地组件或 PlayingId。
stateDiagram-v2
[*] --> Empty
Empty --> Loading: SetSource / generation++
Loading --> Loading: SetSource again / invalidate old
Loading --> Ready: matching callback
Loading --> PendingPlay: Play before ready
PendingPlay --> Playing: matching callback + start
Ready --> Playing: Play / PlayingId++
Playing --> Paused: user or game pause
Playing --> Ready: matching EndOfEvent
Playing --> Empty: teardown / generation++三、值得保留:资源身份、播放身份和 UObject 身份分离
不同来源先经过严格路由:外部相对路径拒绝穿越、绝对路径、保留段和错误扩展名,调试用 SoftObjectPath 回退默认关闭。资源能否解析与能否播放分开报告,避免任意路径成为内容逃逸口。
LoadGeneration 拒绝旧资源,PlayingId 拒绝旧播放回调,Callback Context 负责媒体内存存活,三者解决的是不同问题。Subsystem 随 World 清理,也避免进程静态播放状态泄漏到下一张地图。
四、结构性缺陷:布尔值组合正在模拟隐式状态机
加载中、待播、播放、用户暂停、游戏暂停、淡出后暂停、虚拟化和失败由多个布尔值组合表示,非法状态很难断言。例如用户暂停期间发生游戏恢复,是否应播放取决于两个原因,而不是最后一次 SetPaused(false)。
时间由 SeekOffset、WorldTime 与速度推导,World Pause、切换速度和异步起播都要求在转换点重新锚定。若每个分支单独改时间,查询值会跳变。场景切换时“只允许一条地图音乐”的策略也不应隐藏在某个资源路由分支里。
五、目标设计:显式 Session State 与正交 Pause Reasons
VoiceSession 使用枚举状态 Empty/Loading/Ready/Playing/Virtualized/Stopping/Failed,并以 PauseReason 位集表达 User、Game、Focus 等正交原因。所有转换通过单一函数更新时间锚点、循环/区间、音量层、Fade、附着组件、中间件句柄与诊断事件;从 Disabled 回到 Enabled 时依据保留意图重新求解,而不是无条件 Replay。非法转换直接拒绝。
资源路由输出统一 AudioPayloadHandle,声明来源、校验摘要、平台格式与释放策略。Play 创建新的 SessionGeneration;异步 Load、Seek、Fade 和 End 回调都携带该代际。Replication 只发送可恢复的播放意图与服务器时间,不复制本地 PlayingId。
void OnMediaReady(ObjectId id, LoadGeneration g, PayloadHandle media)
{
VoiceSession* s = sessions.Find(id);
if (!s || s->loadGeneration != g) return;
s->payload = MoveTemp(media);
Transition(*s, s->pendingPlay ? State::Playing : State::Ready);
}六、决策与验证:每种异步结果都要回答属于哪一代
| 身份 | 防止的问题 | 何时递增/更换 |
|---|---|---|
| ObjectId | 逻辑对象串音 | 内容对象重建 |
| LoadGeneration | 旧资源覆盖新 Source | 每次 BeginLoad/Teardown |
| SessionGeneration | 旧 Seek/Fade 影响新播放 | 每次新的 Play 会话 |
| PlayingId | 中间件结束回调串台 | 每次物理播放 |
| Payload Handle | 媒体缓冲提前释放 | 资源载荷更换 |
| World Generation | 跨地图回调落入新 World | World 切换 |
测试应故意制造 Load A 后立刻改 B、Play-before-ready、Stop 后旧 End 回调、用户暂停叠加游戏暂停、World 销毁中完成下载等次序。全部通过,才说明音频生命周期不是靠正常时序碰巧工作。