feat: SyncPoint.SameAsCurrentSegment music transition

- Add SameAsCurrentSegment mode to align new container's timeSamples with the old container's playback position, accounting for FadeInOffset
- Fix BeatClock callback burst when Restart is called with a past dspTime
- Add GetFirstLeafSource() for resolving playback position across nested containers
- Manual BeatClock.Restart replaces OnContainerEntered subscription for accurate timing with SyncPoint
This commit is contained in:
2026-04-16 20:50:34 +08:00
parent caca71c63a
commit 0fdd76022d
13 changed files with 186 additions and 48 deletions
@@ -6,6 +6,18 @@ using DG.Tweening;
namespace OCES.Audio
{
/// <summary>
/// SyncPoint 协调状态,在 FadeOutBranch 和 FadeInBranch 之间共享。
/// </summary>
internal class SyncPointState
{
public SyncPoint Mode;
public int BaseTimeSamples; // 读取旧 Source 时的 timeSamples
public double BaseDspTime; // 读取旧 Source 时的 dspTime
public int SampleRate; // 旧 Source 的采样率
public bool Ready;
}
/// <summary>
/// 与Transition无关的音量/生命周期管理逻辑
/// </summary>
@@ -47,16 +59,20 @@ namespace OCES.Audio
/// <summary>
/// 淡出分支:fire-and-forget,由调用方 StartCoroutine
/// </summary>
internal IEnumerator FadeOutBranch(ContainerPlayHandle outgoingHandle, float outgoingVolume, ITransitionConfig transition)
internal IEnumerator FadeOutBranch(
ContainerPlayHandle outgoingHandle,
float outgoingVolume,
ITransitionConfig transition,
SyncPointState syncState = null)
{
if (outgoingHandle == null) yield break;
if (transition?.FadeOutOffset > 0f)
{
//Debug.Log($"Waiting for {transition.FadeOutOffset} to fade out.");
yield return new WaitForSeconds(transition.FadeOutOffset);
}
if (transition?.FadeOutTime > 0f )
yield return this.m_coroutineHost.StartCoroutine(
FadeOut(outgoingHandle, outgoingVolume, transition.FadeOutTime));
@@ -68,23 +84,52 @@ namespace OCES.Audio
/// 淡入分支:等待 FadeInOffset 后启动新音乐并淡入。
/// 主协程 yield return 此分支,以便 DoTransition 在新音乐就绪后才结束。
/// </summary>
internal IEnumerator FadeInBranch(uint newContainerId, ITransitionConfig transition, Action onContainerStarted = null)
internal IEnumerator FadeInBranch(uint newContainerId, ITransitionConfig transition, SyncPointState syncState = null, Action onContainerStarted = null)
{
if (transition?.FadeInOffset > 0f)
{
//Debug.Log($"Waiting {transition.FadeInOffset} to fade in.");
yield return new WaitForSeconds(transition.FadeInOffset);
}
if (newContainerId == 0)
{
CurrentHandle = null;
CurrentContainerId = 0;
if (syncState != null) syncState.Ready = true;
yield break;
}
float startVolume = transition?.FadeInTime > 0f ? 0f : 1f;
StartNew(newContainerId, startVolume);
// SyncPoint: 获取新 Segment 的 AudioSource 并设置 timeSamples
if (syncState is { Mode: SyncPoint.SameAsCurrentSegment })
{
AudioSource newSource = CurrentHandle?.GetFirstLeafSource();
if (newSource != null && newSource.clip != null)
{
// 计算从读取旧 Source 到现在经过的 samples
double elapsedSeconds = AudioSettings.dspTime - syncState.BaseDspTime;
int elapsedSamples = (int)(elapsedSeconds * syncState.SampleRate);
int targetTimeSamples = syncState.BaseTimeSamples + elapsedSamples;
if (targetTimeSamples < newSource.clip.samples)
{
newSource.timeSamples = targetTimeSamples;
}
else
{
Debug.LogError($"[ChannelFader] SyncPoint.SameAsCurrentSegment: 新音频 samples({newSource.clip.samples}) <= 目标 samples({targetTimeSamples}),降级为 Start");
}
}
else
{
Debug.LogError($"[ChannelFader] SyncPoint.SameAsCurrentSegment: 未能获取新 Segment 的 AudioSource,降级为 Start");
}
syncState.Ready = true;
}
onContainerStarted?.Invoke();
if (transition?.FadeInTime > 0f)