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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
View File
@@ -2579,6 +2579,7 @@ GameObject:
m_Component: m_Component:
- component: {fileID: 2093584671} - component: {fileID: 2093584671}
- component: {fileID: 2093584670} - component: {fileID: 2093584670}
- component: {fileID: 2093584672}
m_Layer: 0 m_Layer: 0
m_Name: AudioSystem m_Name: AudioSystem
m_TagString: Untagged m_TagString: Untagged
@@ -2613,6 +2614,18 @@ Transform:
m_Children: [] m_Children: []
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &2093584672
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2093584669}
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a3d8ad76f12545caa287f26889c674e3, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!1660057539 &9223372036854775807 --- !u!1660057539 &9223372036854775807
SceneRoots: SceneRoots:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -244,7 +244,7 @@ namespace OCES.Audio
{ {
// ── 启动默认音乐与环境音 ── // ── 启动默认音乐与环境音 ──
// 触发一次初始状态,让音乐系统从默认状态开始匹配 // 触发一次初始状态,让音乐系统从默认状态开始匹配
//SetState(GameState.Home); SetState(GameState.Home);
} }
AudioObject ResolveSwitchContainer(AudioObject switchContainer) AudioObject ResolveSwitchContainer(AudioObject switchContainer)
@@ -65,7 +65,8 @@ namespace OCES.Audio
IEnumerator BeatCoroutine() IEnumerator BeatCoroutine()
{ {
long index = 0; // 从第 1 拍开始,第 0 拍是起始点本身 double elapsed = AudioSettings.dspTime - this.m_startDspTime;
long index = elapsed > 0.05 ? (long)(elapsed / this.m_secondsPerBeat) : 0;
while (true) while (true)
{ {
double nextTime = this.m_startDspTime + index * this.m_secondsPerBeat; double nextTime = this.m_startDspTime + index * this.m_secondsPerBeat;
@@ -84,10 +85,12 @@ namespace OCES.Audio
IEnumerator BarCoroutine() IEnumerator BarCoroutine()
{ {
long index = 0; double elapsed = AudioSettings.dspTime - this.m_startDspTime;
double secondsPerBar = this.m_secondsPerBeat * this.m_beatsPerBar;
long index = elapsed > 0.05 ? (long)(elapsed / secondsPerBar) : 0;
while (true) while (true)
{ {
double nextTime = this.m_startDspTime + index * this.m_secondsPerBeat * this.m_beatsPerBar; double nextTime = this.m_startDspTime + index * secondsPerBar;
yield return new WaitUntil(() => AudioSettings.dspTime >= nextTime - 0.02); yield return new WaitUntil(() => AudioSettings.dspTime >= nextTime - 0.02);
while (AudioSettings.dspTime < nextTime) while (AudioSettings.dspTime < nextTime)
@@ -102,10 +105,12 @@ namespace OCES.Audio
IEnumerator GridCoroutine() IEnumerator GridCoroutine()
{ {
long index = 0; double elapsed = AudioSettings.dspTime - this.m_startDspTime;
double secondsPerGrid = this.m_secondsPerBeat * this.m_beatsPerBar * this.m_barsPerGrid;
long index = elapsed > 0.05 ? (long)(elapsed / secondsPerGrid) : 0;
while (true) while (true)
{ {
double nextTime = this.m_startDspTime + index * this.m_secondsPerBeat * this.m_beatsPerBar * this.m_barsPerGrid; double nextTime = this.m_startDspTime + index * secondsPerGrid;
yield return new WaitUntil(() => AudioSettings.dspTime >= nextTime - 0.02); yield return new WaitUntil(() => AudioSettings.dspTime >= nextTime - 0.02);
while (AudioSettings.dspTime < nextTime) while (AudioSettings.dspTime < nextTime)
@@ -6,6 +6,18 @@ using DG.Tweening;
namespace OCES.Audio 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> /// <summary>
/// 与Transition无关的音量/生命周期管理逻辑 /// 与Transition无关的音量/生命周期管理逻辑
/// </summary> /// </summary>
@@ -47,7 +59,11 @@ namespace OCES.Audio
/// <summary> /// <summary>
/// 淡出分支:fire-and-forget,由调用方 StartCoroutine /// 淡出分支:fire-and-forget,由调用方 StartCoroutine
/// </summary> /// </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 (outgoingHandle == null) yield break;
@@ -68,7 +84,7 @@ namespace OCES.Audio
/// 淡入分支:等待 FadeInOffset 后启动新音乐并淡入。 /// 淡入分支:等待 FadeInOffset 后启动新音乐并淡入。
/// 主协程 yield return 此分支,以便 DoTransition 在新音乐就绪后才结束。 /// 主协程 yield return 此分支,以便 DoTransition 在新音乐就绪后才结束。
/// </summary> /// </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) if (transition?.FadeInOffset > 0f)
{ {
@@ -80,11 +96,40 @@ namespace OCES.Audio
{ {
CurrentHandle = null; CurrentHandle = null;
CurrentContainerId = 0; CurrentContainerId = 0;
if (syncState != null) syncState.Ready = true;
yield break; yield break;
} }
float startVolume = transition?.FadeInTime > 0f ? 0f : 1f; float startVolume = transition?.FadeInTime > 0f ? 0f : 1f;
StartNew(newContainerId, startVolume); 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(); onContainerStarted?.Invoke();
if (transition?.FadeInTime > 0f) if (transition?.FadeInTime > 0f)
@@ -47,7 +47,6 @@ namespace OCES.Audio
this.m_fader = new ChannelFader(player, coroutineHost); this.m_fader = new ChannelFader(player, coroutineHost);
this.m_beatClock = new BeatClock(coroutineHost, onBeat, onBar, onGrid); this.m_beatClock = new BeatClock(coroutineHost, onBeat, onBar, onGrid);
player.OnContainerEntered += this.m_beatClock.Restart;
player.OnBlendError += this.m_beatClock.OnBlendError; player.OnBlendError += this.m_beatClock.OnBlendError;
} }
@@ -104,6 +103,45 @@ namespace OCES.Audio
this.m_currentFadeOutCoroutine = null; this.m_currentFadeOutCoroutine = null;
} }
// ── 构建 SyncPoint 状态 ──
SyncPointState syncState = new()
{
Mode = transition?.SyncPoint ?? SyncPoint.Start,
Ready = false,
};
//Debug.Log($"[MusicChannelPlayer] DoTransition: transition?.SyncPoint={transition?.SyncPoint}, Mode={syncState.Mode}");
if (syncState.Mode == SyncPoint.SameAsCurrentSegment)
{
// 旧 Container 不存在(如游戏首次启动),静默降级为 Start
if (this.m_fader.CurrentHandle == null)
{
syncState.Mode = SyncPoint.Start;
}
// Blend 类型不支持 SameAsCurrentSegment,降级为 Start
else if (this.m_currentContainer is { ContainerType: ContainerType.Blend })
{
Debug.LogWarning($"[MusicChannelPlayer] SyncPoint.SameAsCurrentSegment 不支持 Blend 类型 Container({this.m_currentContainer.Id}),降级为 Start");
syncState.Mode = SyncPoint.Start;
}
else
{
AudioSource oldSource = this.m_fader.CurrentHandle.GetFirstLeafSource();
if (oldSource && oldSource.clip)
{
syncState.BaseTimeSamples = oldSource.timeSamples;
syncState.BaseDspTime = AudioSettings.dspTime;
syncState.SampleRate = oldSource.clip.frequency;
}
else
{
Debug.LogWarning("[MusicChannelPlayer] SameAsCurrentSegment: 旧 Source 不存在,降级为 Start");
syncState.Mode = SyncPoint.Start;
}
}
}
// ── 1. 等待节拍对齐(由 Transition 的 AlignMode 决定)── // ── 1. 等待节拍对齐(由 Transition 的 AlignMode 决定)──
if (transition != null && this.m_currentContainer != null) if (transition != null && this.m_currentContainer != null)
{ {
@@ -119,15 +157,32 @@ namespace OCES.Audio
float outVol = this.m_fader.CurrentVolume; float outVol = this.m_fader.CurrentVolume;
this.m_currentFadeOutCoroutine = this.m_coroutineHost.StartCoroutine( this.m_currentFadeOutCoroutine = this.m_coroutineHost.StartCoroutine(
this.m_fader.FadeOutBranch(outgoing, outVol, transition)); this.m_fader.FadeOutBranch(outgoing, outVol, transition, syncState));
this.m_currentFadeInCoroutine = this.m_coroutineHost.StartCoroutine( this.m_currentFadeInCoroutine = this.m_coroutineHost.StartCoroutine(
this.m_fader.FadeInBranch(newContainerId, transition, this.m_fader.FadeInBranch(newContainerId, transition, syncState,
onContainerStarted: () => onContainerStarted: () =>
{ {
// Music 独有:记录新 container 和播放起始时间 MusicContainer container = this.m_containerConfig.QueryById(newContainerId);
this.m_currentContainer = this.m_containerConfig.QueryById(newContainerId); float bpm = container.Bpm > 0f ? container.Bpm : 120f;
this.m_playStartTime = AudioSettings.dspTime;
// SyncPoint: BeatClock 用调整后的 dspTime,对齐到音频实际播放位置
double dspTime;
if (syncState is { Mode: SyncPoint.SameAsCurrentSegment })
{
double elapsedSeconds = AudioSettings.dspTime - syncState.BaseDspTime;
int elapsedSamples = (int)(elapsedSeconds * syncState.SampleRate);
double audioTime = (double)(syncState.BaseTimeSamples + elapsedSamples) / syncState.SampleRate;
dspTime = AudioSettings.dspTime - audioTime;
}
else
{
dspTime = AudioSettings.dspTime;
}
this.m_currentContainer = container;
this.m_playStartTime = dspTime;
this.m_beatClock.Restart(container, bpm, dspTime);
})); }));
yield return this.m_currentFadeInCoroutine; yield return this.m_currentFadeInCoroutine;
this.m_currentFadeInCoroutine = null; this.m_currentFadeInCoroutine = null;
@@ -147,23 +202,28 @@ namespace OCES.Audio
double secondsPerBeat = 60.0 / container.Bpm; double secondsPerBeat = 60.0 / container.Bpm;
if (mode == AlignMode.Beat) switch (mode)
{
case AlignMode.Beat:
{ {
double beatsElapsed = elapsed / secondsPerBeat; double beatsElapsed = elapsed / secondsPerBeat;
double nextBeat = System.Math.Ceiling(beatsElapsed); double nextBeat = Math.Ceiling(beatsElapsed);
double waitSeconds = (nextBeat - beatsElapsed) * secondsPerBeat; double waitSeconds = (nextBeat - beatsElapsed) * secondsPerBeat;
if (waitSeconds > 0.001) if (waitSeconds > 0.001)
yield return new WaitForSeconds((float)waitSeconds); yield return new WaitForSeconds((float)waitSeconds);
break;
} }
else if (mode == AlignMode.Bar) case AlignMode.Bar:
{ {
int beatsPerBar = MusicContainerConfig.GetBeatsPerBar(container.TimeSig); int beatsPerBar = MusicContainerConfig.GetBeatsPerBar(container.TimeSig);
double secondsPerBar = secondsPerBeat * beatsPerBar; double secondsPerBar = secondsPerBeat * beatsPerBar;
double barsElapsed = elapsed / secondsPerBar; double barsElapsed = elapsed / secondsPerBar;
double nextBar = System.Math.Ceiling(barsElapsed); double nextBar = Math.Ceiling(barsElapsed);
double waitSeconds = (nextBar - barsElapsed) * secondsPerBar; double waitSeconds = (nextBar - barsElapsed) * secondsPerBar;
if (waitSeconds > 0.001) if (waitSeconds > 0.001)
yield return new WaitForSeconds((float)waitSeconds); yield return new WaitForSeconds((float)waitSeconds);
break;
}
} }
} }
@@ -411,5 +411,20 @@ namespace OCES.Audio
foreach (ContainerPlayHandle child in this.ChildHandles) foreach (ContainerPlayHandle child in this.ChildHandles)
child.CollectActiveSources(result); child.CollectActiveSources(result);
} }
/// <summary>
/// 获取第一个正在播放的 Leaf Segment 的 AudioSource。
/// 递归遍历子句柄,优先取当前层级的 ActiveSources,找不到再深入子层级。
/// </summary>
internal AudioSource GetFirstLeafSource()
{
if (this.ActiveSources.Count > 0) return this.ActiveSources[0];
foreach (ContainerPlayHandle child in this.ChildHandles)
{
AudioSource src = child.GetFirstLeafSource();
if (src) return src;
}
return null;
}
} }
} }
@@ -55,7 +55,7 @@ namespace OCES.Audio
{ {
this.m_clipConcurrentCount[id] = GetClipCount(id) + 1; this.m_clipConcurrentCount[id] = GetClipCount(id) + 1;
#if UNITY_EDITOR #if UNITY_EDITOR
Debug.Log($"{id} count added to {GetClipCount(id)}"); //Debug.Log($"{id} count added to {GetClipCount(id)}");
#endif #endif
} }
+1 -1
View File
@@ -7,7 +7,7 @@ namespace OCES
{ {
public class SetStateBind : MonoBehaviour public class SetStateBind : MonoBehaviour
{ {
public TileMaterial targetGameState; public GameState targetGameState;
public bool enableLowpass; public bool enableLowpass;
public Text buttonText; public Text buttonText;