音频会话: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跨地图回调落入新 WorldWorld 切换

测试应故意制造 Load A 后立刻改 B、Play-before-ready、Stop 后旧 End 回调、用户暂停叠加游戏暂停、World 销毁中完成下载等次序。全部通过,才说明音频生命周期不是靠正常时序碰巧工作。