first commit
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
|
||||
namespace OCES.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 音频调度器
|
||||
/// </summary>
|
||||
public class AudioScheduler : MonoBehaviour
|
||||
{
|
||||
const int k_maxGlobalConcurrent = 128;
|
||||
|
||||
//记录某个 AudioObject 最近一次触发播放的时刻,用于 MinInterval 节流判断,是"上次什么时候播过"。
|
||||
readonly Dictionary<uint, double> m_lastPlayTime = new();
|
||||
readonly Dictionary<uint, int> m_clipConcurrentCount = new();
|
||||
readonly List<ActiveSound> m_activeSounds = new();
|
||||
|
||||
AudioGroupConfig m_groupConfig;
|
||||
AudioMixerGroup m_sfxGroup;
|
||||
AudioMixerGroup m_musicGroup;
|
||||
AudioMixerGroup m_voiceGroup;
|
||||
AudioMixerGroup m_ambienceGroup;
|
||||
AudioSourcePool m_pool;
|
||||
AudioContainerSelector m_containerSelector;
|
||||
PitchStepManager m_pitchStepManager;
|
||||
|
||||
|
||||
int GetClipCount(uint id)
|
||||
{
|
||||
return this.m_clipConcurrentCount.GetValueOrDefault(id, 0);
|
||||
}
|
||||
|
||||
void IncrementClipCount(uint id)
|
||||
{
|
||||
this.m_clipConcurrentCount[id] = GetClipCount(id) + 1;
|
||||
//Debug.Log($"{id} count added to {GetClipCount(id)}");
|
||||
}
|
||||
|
||||
void DecrementClipCount(uint id)
|
||||
{
|
||||
int cur = GetClipCount(id);
|
||||
if (cur > 0) this.m_clipConcurrentCount[id] = cur - 1;
|
||||
//Debug.Log($"{id} clip count minus to {GetClipCount(id)}");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 初始化
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
public void Initialize(AudioGroupConfig groups, AudioMixer mixer)
|
||||
{
|
||||
this.m_groupConfig = groups;
|
||||
|
||||
this.m_pool = new AudioSourcePool(transform);
|
||||
this.m_containerSelector = new AudioContainerSelector();
|
||||
this.m_pitchStepManager = new PitchStepManager();
|
||||
|
||||
AudioMixerGroup[] sfx = Find("Master/SFX");
|
||||
if (sfx.Length > 0) this.m_sfxGroup = sfx[0];
|
||||
AudioMixerGroup[] ambience = Find("Master/SFX/Ambience");
|
||||
if (ambience.Length > 0) this.m_ambienceGroup = ambience[0];
|
||||
AudioMixerGroup[] voice = Find("Master/Voice");
|
||||
if (voice.Length > 0) this.m_voiceGroup = voice[0];
|
||||
return;
|
||||
|
||||
AudioMixerGroup[] Find(string path) => mixer.FindMatchingGroups(path);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 节流 & 调度入口
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
internal void TryPlay(AudioObject audioObject)
|
||||
{
|
||||
double now = Time.realtimeSinceStartupAsDouble * 1000.0;
|
||||
|
||||
// 第一层:时间节流 单位毫秒
|
||||
if (this.m_lastPlayTime.TryGetValue(audioObject.Id, out double lastTime) &&
|
||||
now - lastTime < audioObject.MinInterval)
|
||||
{
|
||||
Debug.LogWarning($"[Throttle] {audioObject.Name[0]} 未达到最小间隔,取消播放。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 第二层:单 AudioObject/Clip 并发控制
|
||||
if (audioObject.ThrottleCount != 0)
|
||||
{ // TODO 这里和下面的组控制,每次都会分配新List,可以考虑做成成员变量,每次用完clear
|
||||
// TODO Linq表达式好用但是GC性能差。如果性能有问题可以考虑换成普通表达
|
||||
List<ActiveSound> sameObject = this.m_activeSounds
|
||||
.Where(a => a.AudioObject.Id == audioObject.Id)
|
||||
.ToList();
|
||||
|
||||
if (sameObject.Count >= audioObject.ThrottleCount &&
|
||||
!TryKill(sameObject, audioObject.KillMode, $"[Object] {audioObject.Name[0]}"))
|
||||
return;
|
||||
}
|
||||
|
||||
// 第三层:Group 并发控制
|
||||
AudioGroup groupConfig = this.m_groupConfig.QueryById(audioObject.Group);
|
||||
if (groupConfig == null)
|
||||
{
|
||||
groupConfig = this.m_groupConfig.QueryById(1);
|
||||
Debug.Log($"未找到{audioObject.Id}对应的组文件,已使用默认配置组1。");
|
||||
}
|
||||
List<ActiveSound> sameGroup = this.m_activeSounds
|
||||
.Where(a => a.AudioObject.Group == audioObject.Group)
|
||||
.ToList();
|
||||
|
||||
if (sameGroup.Count >= groupConfig.GroupThrottleCount)
|
||||
{
|
||||
List<ActiveSound> lowerPriority = sameGroup
|
||||
.Where(a => a.AudioObject.Priority > audioObject.Priority)
|
||||
.ToList();
|
||||
|
||||
List<ActiveSound> killCandidates = lowerPriority.Count > 0 ? lowerPriority : sameGroup;
|
||||
KillMode killMode = lowerPriority.Count > 0 ? groupConfig.KillMode : audioObject.KillMode;
|
||||
|
||||
if (!TryKill(killCandidates, killMode, $"[Group] {audioObject.Name[0]}"))
|
||||
return;
|
||||
}
|
||||
|
||||
// 第四层:全局并发控制
|
||||
if (this.m_activeSounds.Count >= k_maxGlobalConcurrent)
|
||||
{
|
||||
List<ActiveSound> lowerPriority = this.m_activeSounds
|
||||
.Where(a => a.AudioObject.Priority > audioObject.Priority)
|
||||
.ToList();
|
||||
|
||||
if (lowerPriority.Count == 0 || !TryKill(lowerPriority, KillMode.Oldest, "[Global]"))
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行播放
|
||||
float pitch = this.m_pitchStepManager.ResolvePitch(audioObject, now); //算一下用不用变调
|
||||
PlayNewSound(audioObject, pitch);
|
||||
this.m_lastPlayTime[audioObject.Id] = now;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 播放逻辑
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
void PlayNewSound(AudioObject audioObject, float pitch)
|
||||
{
|
||||
// Blend:同时播放所有音轨,无需走后续逻辑
|
||||
if (audioObject.ContainerType == ContainerType.Blend)
|
||||
{
|
||||
for (int i = 0; i < audioObject.Name.Count; i++)
|
||||
ConfigureSource(this.m_pool.AcquireAudioSource(), audioObject, pitch, clipIndex: i, registerRemove: true);
|
||||
return;
|
||||
|
||||
// TODO BlendRanges, BlendCrossFadeType, BlendReactParam 支持
|
||||
}
|
||||
|
||||
AudioSource source = this.m_pool.AcquireAudioSource();
|
||||
|
||||
// 持续播放模式(Continuous)
|
||||
if (audioObject.Name.Count > 1 && audioObject.ContainerPlayMode)
|
||||
{
|
||||
ActiveSound chainActive = new()
|
||||
{
|
||||
Source = source,
|
||||
AudioObject = audioObject,
|
||||
StartTime = Time.realtimeSinceStartupAsDouble,
|
||||
Pitch = pitch,
|
||||
};
|
||||
this.m_activeSounds.Add(chainActive);
|
||||
IncrementClipCount(audioObject.Id);
|
||||
int continuousStart = audioObject.ContainerType == ContainerType.Random ? -1 : 0;
|
||||
chainActive.Coroutine =
|
||||
StartCoroutine(PlayContainerContinuous(source, audioObject, chainActive, continuousStart, pitch));
|
||||
return;
|
||||
}
|
||||
|
||||
// 单次播放(步进模式)
|
||||
int startIndex = audioObject.ContainerType switch
|
||||
{
|
||||
ContainerType.Random => this.m_containerSelector.PickShuffleIndex(audioObject),
|
||||
ContainerType.Sequence => this.m_containerSelector.GetNextSequenceIndex(audioObject),
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
ConfigureSource(source, audioObject, pitch, startIndex, registerRemove: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 连续容器播放协程(Random / Sequence 持续模式)
|
||||
/// </summary>
|
||||
IEnumerator PlayContainerContinuous(AudioSource source, AudioObject audioObject, ActiveSound chainActive, int startIndex, float pitch)
|
||||
{
|
||||
bool isRandom = audioObject.ContainerType == ContainerType.Random;
|
||||
|
||||
// 初始化 Random 模式的辅助结构
|
||||
List<int> remainingPool = isRandom ? new List<int>(Enumerable.Range(0, audioObject.Name.Count)) : null;
|
||||
Queue<int> recentPlayed = isRandom ? new Queue<int>() : null;
|
||||
int limitRepetition = isRandom ? Mathf.Clamp(audioObject.LimitRepetition, 0, audioObject.Name.Count - 1) : 0;
|
||||
|
||||
int index = startIndex;
|
||||
|
||||
while (true)
|
||||
{
|
||||
// 选择本轮播放的 index
|
||||
if (isRandom)
|
||||
index = this.m_containerSelector.PickNextRandomIndex(
|
||||
audioObject,
|
||||
remainingPool,
|
||||
recentPlayed,
|
||||
limitRepetition);
|
||||
|
||||
// 配置并播放
|
||||
if (!ConfigureSource(source, audioObject, pitch, index, registerRemove: false))
|
||||
{
|
||||
Debug.LogError($"音频文件未找到:{audioObject.Name[index]}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new WaitWhile(() => source.isPlaying);
|
||||
|
||||
// 判断本轮是否播完
|
||||
bool roundFinished = isRandom
|
||||
? remainingPool.Count == 0 || this.m_containerSelector.GetHistoryCount(audioObject.Id) == audioObject.Name.Count
|
||||
: ++index >= audioObject.Name.Count;
|
||||
|
||||
if (roundFinished)
|
||||
{
|
||||
DecrementClipCount(audioObject.Id);
|
||||
this.m_activeSounds.Remove(chainActive);
|
||||
|
||||
if (isRandom)
|
||||
this.m_containerSelector.ResetHistory(audioObject.Id);
|
||||
|
||||
this.m_pool.ReturnToPool(source.gameObject);
|
||||
|
||||
//Debug.Log($"[Container - Continuous] 协程正常结束: {audioObject.Name[0]}");
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 工具方法
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 尝试打断一个候选声音。返回 false 表示应取消当前播放(Newest 模式)。
|
||||
/// </summary>
|
||||
bool TryKill(List<ActiveSound> candidates, KillMode killMode, string logPrefix)
|
||||
{
|
||||
if (killMode == KillMode.Newest)
|
||||
{
|
||||
Debug.LogWarning($"{logPrefix} 被 KillNewest 取消播放。");
|
||||
return false;
|
||||
}
|
||||
|
||||
ActiveSound toKill = SelectOldest(candidates);
|
||||
if (toKill != null)
|
||||
{
|
||||
Debug.LogWarning($"{logPrefix} 打断了 {toKill.AudioObject.Name[0]}。");
|
||||
StopSound(toKill);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从候选列表中选出开始时间最早的声音
|
||||
/// </summary>
|
||||
ActiveSound SelectOldest(List<ActiveSound> candidates)
|
||||
{
|
||||
ActiveSound oldest = null;
|
||||
foreach (ActiveSound s in candidates)
|
||||
{
|
||||
if (oldest == null || s.StartTime < oldest.StartTime)
|
||||
{
|
||||
oldest = s;
|
||||
}
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 MixingType 获取对应的 AudioMixerGroup
|
||||
/// </summary>
|
||||
AudioMixerGroup GetMixerGroup(MixingType type) => type switch
|
||||
{
|
||||
MixingType.Music => this.m_musicGroup,
|
||||
MixingType.Voice => this.m_voiceGroup,
|
||||
_ => this.m_sfxGroup,
|
||||
};
|
||||
|
||||
|
||||
bool ConfigureSource(AudioSource source, AudioObject audioObject, float pitch, int clipIndex = 0, bool registerRemove = true)
|
||||
{
|
||||
// TODO 现用现找可能会导致主线程卡死,尤其是低端机。需要配合Decompress配置优化性能。
|
||||
AudioClip clip = Resources.Load<AudioClip>($"Audios/{audioObject.Name[clipIndex]}");
|
||||
if (!clip)
|
||||
{
|
||||
Debug.LogError($"音频文件未找到:{audioObject.Name[clipIndex]}");
|
||||
return false;
|
||||
}
|
||||
|
||||
source.clip = clip;
|
||||
source.loop = audioObject.LoopCount < 0;
|
||||
source.priority = audioObject.Priority;
|
||||
source.outputAudioMixerGroup = GetMixerGroup(audioObject.MixingType);
|
||||
source.pitch = pitch;
|
||||
source.Play();
|
||||
|
||||
if (registerRemove)
|
||||
{
|
||||
IncrementClipCount(audioObject.Id);
|
||||
|
||||
ActiveSound active = new()
|
||||
{
|
||||
Source = source,
|
||||
AudioObject = audioObject,
|
||||
StartTime = Time.realtimeSinceStartupAsDouble,
|
||||
Pitch = pitch,
|
||||
};
|
||||
this.m_activeSounds.Add(active);
|
||||
active.Coroutine = StartCoroutine(RemoveWhenFinished(active));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
IEnumerator RemoveWhenFinished(ActiveSound active)
|
||||
{
|
||||
if (active.AudioObject.LoopCount < 0)
|
||||
{
|
||||
//Debug.Log($"RemoveWhenFinished协程已结束,因为播放的是无限循环音效{active.Source.clip.name}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
yield return new WaitWhile(() => active.Source.isPlaying);
|
||||
active.CurrentLoopCount++;
|
||||
}
|
||||
while (active.AudioObject.LoopCount > 0
|
||||
&& active.CurrentLoopCount < active.AudioObject.LoopCount
|
||||
&& PlayAgain(active));
|
||||
|
||||
DecrementClipCount(active.AudioObject.Id);
|
||||
this.m_activeSounds.Remove(active);
|
||||
this.m_pool.ReturnToPool(active.Source.gameObject);
|
||||
}
|
||||
|
||||
bool PlayAgain(ActiveSound active)
|
||||
{
|
||||
active.Source.Play();
|
||||
return true;
|
||||
}
|
||||
|
||||
void StopSound(ActiveSound active)
|
||||
{
|
||||
if (active.Coroutine != null)
|
||||
{
|
||||
StopCoroutine(active.Coroutine);
|
||||
//Debug.Log($"[StopSound] 协程已终止: {active.AudioObject.Name[0]}");
|
||||
}
|
||||
|
||||
active.Source.Stop();
|
||||
DecrementClipCount(active.AudioObject.Id);
|
||||
this.m_activeSounds.Remove(active);
|
||||
this.m_pool.ReturnToPool(active.Source.gameObject);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 活跃的声音封装
|
||||
/// </summary>
|
||||
public class ActiveSound
|
||||
{
|
||||
public AudioSource Source;
|
||||
public AudioObject AudioObject;
|
||||
// 记录某个具体播放实例的开始时间,用于 SelectOldest 在多个并发实例中挑出最老的那个来打断,是"这个实例是什么时候开始的"。
|
||||
public double StartTime;
|
||||
public int CurrentLoopCount;
|
||||
public Coroutine Coroutine;
|
||||
public float Pitch;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user