WIP: Music callback

- 头几拍会抖动一下,导致对不上拍子
This commit is contained in:
2026-04-07 10:06:02 +08:00
parent 0f204aa794
commit 49a502e647
21 changed files with 353 additions and 61 deletions
@@ -66,6 +66,9 @@ public static class AudioObjectDefinitions
{ "Chinese Number 08", 49 },
{ "Chinese Number 09", 49 },
{ "Chinese Number 10", 49 },
{ "Bar", 52 },
{ "Beat", 53 },
{ "Grid", 54 },
{ "sfx_amb_desert", 2000 },
{ "sfx_amb_forest", 2001 },
{ "sfx_anim_common_item_fly", 3000 },
@@ -26,6 +26,11 @@ namespace OCES.Audio
// 公开接口
// ─────────────────────────────────────────────
public event Action<uint> OnBeat;
public event Action<uint> OnBar;
public event Action<uint> OnGrid;
public void Play(uint audioId, Action onPlay = null)
{
AudioObject obj = this.m_audioObjects.QueryById(audioId);
@@ -170,6 +175,10 @@ namespace OCES.Audio
musicPool,
ambiencePool);
this.m_musicSystem.OnBeat += id => this.OnBeat?.Invoke(id);
this.m_musicSystem.OnBar += id => this.OnBar?.Invoke(id);
this.m_musicSystem.OnGrid += id => this.OnGrid?.Invoke(id);
// ── 注册 StateGroup ──
EnumIds.RegisterAllGameState();
@@ -84,4 +84,19 @@ namespace OCES.Audio
public partial class MusicPath : IPathEntry { }
public partial class AmbiencePath : IPathEntry { }
public partial class MusicContainerConfig
{
/// <summary>
/// 解析拍号字符串(如 "4/4", "3/4"),返回每小节拍数。
/// </summary>
public static int GetBeatsPerBar(string timeSig)
{
if (string.IsNullOrEmpty(timeSig)) return 4;
string[] parts = timeSig.Split('/');
if (parts.Length >= 1 && int.TryParse(parts[0], out int beats))
return beats;
return 4;
}
}
}
@@ -7,7 +7,7 @@ namespace OCES.Audio
/// 环境音通道播放器。
/// 与 MusicChannelPlayer 逻辑相同,但使用 AmbienceTransition 表,不涉及节拍对齐。
/// </summary>
public class AmbienceChannelPlayer
class AmbienceChannelPlayer
{
readonly AmbienceTransitionConfig m_transitionConfig;
readonly MonoBehaviour m_coroutineHost;
@@ -20,7 +20,7 @@ namespace OCES.Audio
uint m_currentContainerId;
public AmbienceChannelPlayer(
internal AmbienceChannelPlayer(
AmbienceTransitionConfig transitionConfig,
MusicContainerPlayer player,
MonoBehaviour coroutineHost)
@@ -34,7 +34,7 @@ namespace OCES.Audio
// 公开接口
// ─────────────────────────────────────────────
public void SwitchTo(uint newContainerId, uint fromPathId, uint toPathId)
internal void SwitchTo(uint newContainerId, uint fromPathId, uint toPathId)
{
if (newContainerId == this.m_currentContainerId && this.m_currentHandle != null)
return;
@@ -0,0 +1,129 @@
using System;
using System.Collections;
using UnityEngine;
namespace OCES.Audio
{
public class BeatClock
{
int m_beatsPerBar; // 从 TimeSig 解析
int m_barsPerGrid; // 从 Grid 字段读取
double m_startDspTime, m_secondsPerBeat; // 本次计数周期的起点
Coroutine m_beatCoroutine, m_barCoroutine, m_gridCoroutine;
readonly Action<uint> m_onBeat, m_onBar, m_onGrid;
readonly MonoBehaviour m_host;
uint m_containerId;
bool m_stopped, m_blendError; // 检测到 Blend 多 BPM 情况时置为 true
internal BeatClock(MonoBehaviour host, Action<uint> onBeat, Action<uint> onBar, Action<uint> onGrid)
{
this.m_host = host;
this.m_onBeat = onBeat;
this.m_onBar = onBar;
this.m_onGrid = onGrid;
}
public void Restart(MusicContainer container, float inheritedBpm, double dspTime)
{
Debug.Log($"[BeatClock] Restart called, container={container.Id}, bpm={container.Bpm}, inherited={inheritedBpm}");
StopAll();
this.m_blendError = this.m_stopped = false;
this.m_containerId = container.Id;
this.m_startDspTime = dspTime;
// BPM:优先用自己的,为 0 则用传入的继承值,最终回退 120
float bpm = container.Bpm > 0f ? container.Bpm : inheritedBpm;
if (bpm <= 30f) bpm = 120f; // 没见过速度小于30bpm的音乐,小于30要么是数填错了,要么是有奇怪的情况。要是真有这么慢的音乐再处理吧。
this.m_secondsPerBeat = 60 / bpm;
this.m_beatsPerBar = MusicContainerConfig.GetBeatsPerBar(container.TimeSig); // 沿用现有的解析逻辑
this.m_barsPerGrid = container.Grid > 0 ? container.Grid : 4;
this.m_beatCoroutine = this.m_host.StartCoroutine(BeatCoroutine());
this.m_barCoroutine = this.m_host.StartCoroutine(BarCoroutine());
this.m_gridCoroutine = this.m_host.StartCoroutine(GridCoroutine());
}
internal void OnBlendError(MusicContainer container)
{
this.m_blendError = true;
Debug.LogWarning($"[Music System] Blend Container {container.Id} 包含多个不同 BPM 的子 Container,节拍回调已停止。");
}
internal void StopAll()
{
this.m_stopped = true;
if (this.m_beatCoroutine != null) this.m_host.StopCoroutine(this.m_beatCoroutine);
if (this.m_barCoroutine != null) this.m_host.StopCoroutine(this.m_barCoroutine);
if (this.m_gridCoroutine != null) this.m_host.StopCoroutine(this.m_gridCoroutine);
this.m_beatCoroutine = this.m_barCoroutine = this.m_gridCoroutine = null;
}
IEnumerator BeatCoroutine()
{
long index = 0; // 从第 1 拍开始,第 0 拍是起始点本身
while (true)
{
double nextTime = this.m_startDspTime + index * this.m_secondsPerBeat;
if (index == 0)
{
Debug.Log($"[BeatClock] BeatCoroutine waiting, nextTime={nextTime}, now={AudioSettings.dspTime}");
}
yield return new WaitUntil(() => AudioSettings.dspTime >= nextTime - 0.02);
while (AudioSettings.dspTime < nextTime)
yield return null;
if (this.m_blendError || this.m_stopped){
Debug.Log($"[BeatClock] Coroutine exiting early, blendError={m_blendError}, stopped={m_stopped}");
yield break;
}
this.m_onBeat?.Invoke(this.m_containerId);
Debug.Log($"[Beat] index={index}, nextTime={nextTime:F4}, actualDspTime={AudioSettings.dspTime:F4}, diff={(AudioSettings.dspTime - nextTime)*1000:F1}ms");
index++;
}
}
IEnumerator BarCoroutine()
{
long index = 0;
while (true)
{
double nextTime = this.m_startDspTime + index * this.m_secondsPerBeat * this.m_beatsPerBar;
yield return new WaitUntil(() => AudioSettings.dspTime >= nextTime - 0.02);
while (AudioSettings.dspTime < nextTime)
yield return null;
if (this.m_blendError || this.m_stopped){
Debug.Log($"[BeatClock] Coroutine exiting early, blendError={m_blendError}, stopped={m_stopped}");
yield break;
}
this.m_onBar?.Invoke(this.m_containerId);
index++;
}
}
IEnumerator GridCoroutine()
{
long index = 0;
while (true)
{
double nextTime = this.m_startDspTime + index * this.m_secondsPerBeat * this.m_beatsPerBar * this.m_barsPerGrid;
yield return new WaitUntil(() => AudioSettings.dspTime >= nextTime - 0.02);
while (AudioSettings.dspTime < nextTime)
yield return null;
if (this.m_blendError || this.m_stopped){
Debug.Log($"[BeatClock] Coroutine exiting early, blendError={m_blendError}, stopped={m_stopped}");
yield break;
}
this.m_onGrid?.Invoke(this.m_containerId);
index++;
}
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5cb2b4c3391444bca4cd30f3015d0f91
timeCreated: 1775200354
@@ -14,11 +14,11 @@ namespace OCES.Audio
readonly MusicContainerPlayer m_player;
readonly MonoBehaviour m_coroutineHost;
public ContainerPlayHandle CurrentHandle { get; private set; }
internal ContainerPlayHandle CurrentHandle { get; private set; }
public uint CurrentContainerId { get; private set; }
public float CurrentVolume { get; private set; }
public ChannelFader(MusicContainerPlayer player, MonoBehaviour coroutineHost)
internal ChannelFader(MusicContainerPlayer player, MonoBehaviour coroutineHost)
{
this.m_player = player;
this.m_coroutineHost = coroutineHost;
@@ -1,3 +1,4 @@
using System;
using System.Collections;
using UnityEngine;
@@ -7,12 +8,13 @@ namespace OCES.Audio
/// 音乐通道播放器。
/// 负责:切换目标 Container、等待节拍对齐、执行淡入淡出 Transition。
/// </summary>
public class MusicChannelPlayer
class MusicChannelPlayer
{
readonly MusicContainerConfig m_containerConfig;
readonly MusicTransitionConfig m_transitionConfig;
readonly MonoBehaviour m_coroutineHost;
readonly ChannelFader m_fader;
readonly BeatClock m_beatClock;
Coroutine m_currentFadeInCoroutine;
Coroutine m_currentFadeOutCoroutine;
@@ -30,16 +32,23 @@ namespace OCES.Audio
// 正在进行的 transition 协程(防止重叠)
Coroutine m_transitionCoroutine;
public MusicChannelPlayer(
internal MusicChannelPlayer(
MusicContainerConfig containerConfig,
MusicTransitionConfig transitionConfig,
MusicContainerPlayer player,
MonoBehaviour coroutineHost)
MonoBehaviour coroutineHost,
Action<uint> onBeat,
Action<uint> onBar,
Action<uint> onGrid)
{
this.m_containerConfig = containerConfig;
this.m_transitionConfig = transitionConfig;
this.m_coroutineHost = coroutineHost;
this.m_fader = new ChannelFader(player, coroutineHost);
this.m_containerConfig = containerConfig;
this.m_transitionConfig = transitionConfig;
this.m_coroutineHost = coroutineHost;
this.m_fader = new ChannelFader(player, coroutineHost);
this.m_beatClock = new BeatClock(coroutineHost, onBeat, onBar, onGrid);
player.OnContainerEntered += this.m_beatClock.Restart;
player.OnBlendError += this.m_beatClock.OnBlendError;
}
// ─────────────────────────────────────────────
@@ -67,8 +76,10 @@ namespace OCES.Audio
/// <summary>
/// 立即停止当前播放(无淡出)
/// </summary>
public void Stop()
internal void Stop()
{
this.m_beatClock.StopAll();
if (this.m_transitionCoroutine != null)
this.m_coroutineHost.StopCoroutine(this.m_transitionCoroutine);
@@ -146,7 +157,7 @@ namespace OCES.Audio
}
else if (mode == AlignMode.Bar)
{
int beatsPerBar = ParseBeatsPerBar(container.TimeSig);
int beatsPerBar = MusicContainerConfig.GetBeatsPerBar(container.TimeSig);
double secondsPerBar = secondsPerBeat * beatsPerBar;
double barsElapsed = elapsed / secondsPerBar;
double nextBar = System.Math.Ceiling(barsElapsed);
@@ -155,18 +166,7 @@ namespace OCES.Audio
yield return new WaitForSeconds((float)waitSeconds);
}
}
/// <summary>
/// 解析拍号字符串(如 "4/4", "3/4"),返回每小节拍数。
/// </summary>
static int ParseBeatsPerBar(string timeSig)
{
if (string.IsNullOrEmpty(timeSig)) return 4;
string[] parts = timeSig.Split('/');
if (parts.Length >= 1 && int.TryParse(parts[0], out int beats))
return beats;
return 4;
}
// ─────────────────────────────────────────────
// 工具
// ─────────────────────────────────────────────
@@ -1,7 +1,9 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Random = UnityEngine.Random;
namespace OCES.Audio
{
@@ -10,7 +12,7 @@ namespace OCES.Audio
/// 被 MusicChannelPlayer 和 AmbienceChannelPlayer 共同使用。
/// 不持有状态机逻辑,只负责"把这个 Container 播完"。
/// </summary>
public class MusicContainerPlayer
class MusicContainerPlayer
{
readonly MusicContainerConfig m_containerConfig;
readonly MusicSegmentConfig m_segmentConfig;
@@ -18,6 +20,12 @@ namespace OCES.Audio
readonly MonoBehaviour m_coroutineHost;
UnityEngine.Audio.AudioMixerGroup m_mixerGroup;
/// <summary>
/// 开始播放Container时触发 音乐回调系统
/// container本身,继承来的bpm,进入时刻的dspTime
/// </summary>
internal event Action<MusicContainer, float, double> OnContainerEntered;
internal event Action<MusicContainer> OnBlendError;
// Sequence Step 模式的全局游标,key = containerId
readonly Dictionary<uint, int> m_sequenceStepIndex = new();
@@ -45,7 +53,7 @@ namespace OCES.Audio
/// 开始播放指定 Container,播放完毕后调用 onFinished。
/// 返回可用于外部停止的句柄。
/// </summary>
public ContainerPlayHandle Play(uint containerId, System.Action onFinished = null)
internal ContainerPlayHandle Play(uint containerId, Action onFinished = null, float inheritedBpm = 0f)
{
MusicContainer container = this.m_containerConfig.QueryById(containerId);
if (container == null)
@@ -58,7 +66,7 @@ namespace OCES.Audio
ContainerPlayHandle handle = new();
handle.TargetVolume = 1f;
handle.Coroutine = this.m_coroutineHost.StartCoroutine(
PlayContainerCoroutine(container, handle, onFinished));
PlayContainerCoroutine(container, handle, inheritedBpm, onFinished));
return handle;
}
@@ -93,14 +101,19 @@ namespace OCES.Audio
IEnumerator PlayContainerCoroutine(
MusicContainer container,
ContainerPlayHandle handle,
System.Action onFinished)
float inheritedBpm,
Action onFinished)
{
float effectiveBpm = container.Bpm > 0f ? container.Bpm : inheritedBpm;
Debug.Log($"[MusicContainerPlayer] OnContainerEntered firing, container={container.Id}, subscribers={OnContainerEntered != null}");
OnContainerEntered?.Invoke(container, effectiveBpm, AudioSettings.dspTime);
int loopsCompleted = 0;
while (true)
{
yield return this.m_coroutineHost.StartCoroutine(
PlayContainerOnce(container, handle.TargetVolume, handle));
PlayContainerOnce(container, handle.TargetVolume, handle, effectiveBpm));
if (handle.Cancelled) yield break;
@@ -126,7 +139,7 @@ namespace OCES.Audio
/// <summary>
/// 播放一个 Container 一轮(不含循环逻辑)
/// </summary>
IEnumerator PlayContainerOnce(MusicContainer container, float volumeScale, ContainerPlayHandle handle)
IEnumerator PlayContainerOnce(MusicContainer container, float volumeScale, ContainerPlayHandle handle, float inheritedBpm)
{
if (container.Segments == null || container.Segments.Count == 0)
yield break;
@@ -134,15 +147,15 @@ namespace OCES.Audio
switch (container.ContainerType)
{
case ContainerType.Blend:
yield return PlayBlend(container, volumeScale, handle);
yield return PlayBlend(container, volumeScale, handle, inheritedBpm);
break;
case ContainerType.Sequence:
yield return PlaySequence(container, volumeScale, handle);
yield return PlaySequence(container, volumeScale, handle, inheritedBpm);
break;
case ContainerType.Random:
yield return PlayRandom(container, volumeScale, handle);
yield return PlayRandom(container, volumeScale, handle, inheritedBpm);
break;
}
}
@@ -151,10 +164,21 @@ namespace OCES.Audio
// Blend(同时播放)
// ─────────────────────────────────────────────
IEnumerator PlayBlend(MusicContainer container, float volumeScale, ContainerPlayHandle handle)
IEnumerator PlayBlend(MusicContainer container, float volumeScale, ContainerPlayHandle handle, float inheritedBpm)
{
IEnumerable<float> tempos = container.Segments.Select(id =>
{
MusicContainer c = this.m_containerConfig.QueryById(id);
return c?.Bpm ?? 0f;
}).Where(b => b > 0f).Distinct();
if (tempos.Count() > 1)
{
OnBlendError?.Invoke(container);
}
// 同时启动所有子元素,等待全部结束
var childHandles = new List<ContainerPlayHandle>();
List<ContainerPlayHandle> childHandles = new();
bool allDone = false;
int remaining = container.Segments.Count;
@@ -166,7 +190,7 @@ namespace OCES.Audio
{
remaining--;
if (remaining <= 0) allDone = true;
});
}, inheritedBpm);
if (childHandle != null)
{
childHandles.Add(childHandle);
@@ -187,7 +211,7 @@ namespace OCES.Audio
// Sequence
// ─────────────────────────────────────────────
IEnumerator PlaySequence(MusicContainer container, float volumeScale, ContainerPlayHandle handle)
IEnumerator PlaySequence(MusicContainer container, float volumeScale, ContainerPlayHandle handle, float inheritedBpm)
{
bool isStep = container.ContainerPlayMode;
@@ -195,7 +219,7 @@ namespace OCES.Audio
{
// Step: 每次只播一个,游标全局推进
int index = GetNextSequenceIndex(container);
yield return PlayChildAndWait(container.Segments[index], volumeScale, handle);
yield return PlayChildAndWait(container.Segments[index], volumeScale, handle, inheritedBpm);
}
else
@@ -209,7 +233,7 @@ namespace OCES.Audio
if (i > 0 && container.StrategyParam > 0)
yield return new WaitForSeconds(container.StrategyParam);
yield return PlayChildAndWait(container.Segments[i], volumeScale, handle);
yield return PlayChildAndWait(container.Segments[i], volumeScale, handle, inheritedBpm);
}
}
}
@@ -218,7 +242,7 @@ namespace OCES.Audio
// Random
// ─────────────────────────────────────────────
IEnumerator PlayRandom(MusicContainer container, float volumeScale, ContainerPlayHandle handle)
IEnumerator PlayRandom(MusicContainer container, float volumeScale, ContainerPlayHandle handle, float inheritedBpm)
{
bool isStep = container.ContainerPlayMode; // 同上,音乐系统默认 Continuous
@@ -226,7 +250,7 @@ namespace OCES.Audio
{
// Step Random: 随机选一个播放,算一次 loopCount
uint chosen = PickRandomChild(container);
yield return PlayChildAndWait(chosen, volumeScale, handle);
yield return PlayChildAndWait(chosen, volumeScale, handle, inheritedBpm);
}
else
{
@@ -244,7 +268,7 @@ namespace OCES.Audio
if (container.StrategyParam > 0 && remaining.Count < container.Segments.Count - 1)
yield return new WaitForSeconds(container.StrategyParam);
yield return PlayChildAndWait(chosen, volumeScale, handle);
yield return PlayChildAndWait(chosen, volumeScale, handle, inheritedBpm);
}
}
}
@@ -256,10 +280,10 @@ namespace OCES.Audio
/// <summary>
/// 播放一个子元素(segment 或 container),等待其完成后返回。
/// </summary>
IEnumerator PlayChildAndWait(uint id, float volumeScale, ContainerPlayHandle parentHandle)
IEnumerator PlayChildAndWait(uint id, float volumeScale, ContainerPlayHandle parentHandle, float inheritedBpm)
{
bool done = false;
ContainerPlayHandle child = PlayChild(id, volumeScale, () => done = true);
ContainerPlayHandle child = PlayChild(id, volumeScale, () => done = true, inheritedBpm);
if (child != null)
parentHandle.ChildHandles.Add(child);
@@ -272,17 +296,17 @@ namespace OCES.Audio
/// <summary>
/// 启动一个子元素的播放,不等待,返回句柄。
/// </summary>
ContainerPlayHandle PlayChild(uint id, float volumeScale, System.Action onDone)
ContainerPlayHandle PlayChild(uint id, float volumeScale, Action onDone, float inheritedBpm)
{
// ID < 1000000 是 MusicSegment,否则是嵌套 Container
return id < 1000000u ? PlaySegment(id, volumeScale, onDone) : Play(id, onDone);
return id < 1000000u ? PlaySegment(id, volumeScale, onDone) : Play(id, onDone, inheritedBpm: inheritedBpm);
}
// ─────────────────────────────────────────────
// Segment 播放
// ─────────────────────────────────────────────
ContainerPlayHandle PlaySegment(uint segmentId, float volumeScale, System.Action onFinished)
ContainerPlayHandle PlaySegment(uint segmentId, float volumeScale, Action onFinished)
{
MusicSegment segment = this.m_segmentConfig.QueryById(segmentId);
if (segment == null)
@@ -313,7 +337,7 @@ namespace OCES.Audio
return handle;
}
IEnumerator WaitSegmentFinish(AudioSource source, ContainerPlayHandle handle, System.Action onFinished)
IEnumerator WaitSegmentFinish(AudioSource source, ContainerPlayHandle handle, Action onFinished)
{
yield return new WaitWhile(() => source.isPlaying && !handle.Cancelled);
@@ -370,18 +394,18 @@ namespace OCES.Audio
/// <summary>
/// 一次 Container 播放的句柄,用于外部停止或淡出时访问正在播放的 AudioSource。
/// </summary>
public class ContainerPlayHandle
class ContainerPlayHandle
{
public Coroutine Coroutine;
public bool Cancelled;
public float TargetVolume = 1f;
public List<AudioSource> ActiveSources = new();
public List<ContainerPlayHandle> ChildHandles = new();
internal Coroutine Coroutine;
internal bool Cancelled;
internal float TargetVolume = 1f;
internal List<AudioSource> ActiveSources = new();
internal List<ContainerPlayHandle> ChildHandles = new();
/// <summary>
/// 递归收集所有正在发声的 AudioSource(用于淡出)
/// </summary>
public void CollectActiveSources(List<AudioSource> result)
internal void CollectActiveSources(List<AudioSource> result)
{
result.AddRange(this.ActiveSources);
foreach (ContainerPlayHandle child in this.ChildHandles)
@@ -7,17 +7,21 @@ namespace OCES.Audio
/// 音乐与环境音系统。由 AudioSystem 持有并初始化。
/// 对外只暴露 OnStateChanged,由 AudioSystem.SetState 转发调用。
/// </summary>
public class MusicSystem : MonoBehaviour
class MusicSystem : MonoBehaviour
{
MusicStateRouter m_stateRouter;
MusicChannelPlayer m_musicChannel;
AmbienceChannelPlayer m_ambienceChannel;
internal event Action<uint> OnBeat;
internal event Action<uint> OnBar;
internal event Action<uint> OnGrid;
// 记录上一次两个通道各自匹配到的 PathId,用于查 Transition 表
uint m_lastMusicPathId;
uint m_lastAmbiencePathId;
public void Initialize(
internal void Initialize(
MusicSegmentConfig segments,
MusicContainerConfig containers,
MusicPathConfig musicPaths,
@@ -31,14 +35,20 @@ namespace OCES.Audio
MusicContainerPlayer ambientContainerPlayer = new(containers, segments, ambiencePool, this);
this.m_stateRouter = new MusicStateRouter(musicPaths, ambiencePaths);
this.m_musicChannel = new MusicChannelPlayer(containers, musicTransitions, musicContainerPlayer, this);
this.m_musicChannel = new MusicChannelPlayer(
containers, musicTransitions, musicContainerPlayer, this,
id => OnBeat?.Invoke(id),
id => OnBar?.Invoke(id),
id => OnGrid?.Invoke(id));
this.m_ambienceChannel = new AmbienceChannelPlayer(ambienceTransitions, ambientContainerPlayer, this);
}
/// <summary>
/// 由 AudioSystem.SetState 调用,更新状态并驱动两个通道切换。
/// </summary>
public void OnStateChanged<TEnum>(TEnum state) where TEnum : Enum
internal void OnStateChanged<TEnum>(TEnum state) where TEnum : Enum
{
this.m_stateRouter.SetState(
state,