first commit

This commit is contained in:
2026-03-20 17:55:53 +08:00
commit 41e38311f0
327 changed files with 11557 additions and 0 deletions
@@ -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;
}
}