using System.Collections; using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.Audio; namespace OCES.Audio { /// /// 音频调度器 /// public class AudioScheduler : MonoBehaviour { const int k_maxGlobalConcurrent = 128; //记录某个 AudioObject 最近一次触发播放的时刻,用于 MinInterval 节流判断,是"上次什么时候播过"。 readonly Dictionary m_lastPlayTime = new(); readonly Dictionary m_clipConcurrentCount = new(); readonly List 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 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 sameGroup = this.m_activeSounds .Where(a => a.AudioObject.Group == audioObject.Group) .ToList(); if (sameGroup.Count >= groupConfig.GroupThrottleCount) { List lowerPriority = sameGroup .Where(a => a.AudioObject.Priority > audioObject.Priority) .ToList(); List 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 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); } /// /// 连续容器播放协程(Random / Sequence 持续模式) /// IEnumerator PlayContainerContinuous(AudioSource source, AudioObject audioObject, ActiveSound chainActive, int startIndex, float pitch) { bool isRandom = audioObject.ContainerType == ContainerType.Random; // 初始化 Random 模式的辅助结构 List remainingPool = isRandom ? new List(Enumerable.Range(0, audioObject.Name.Count)) : null; Queue recentPlayed = isRandom ? new Queue() : 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; } } } // ───────────────────────────────────────────── // 工具方法 // ───────────────────────────────────────────── /// /// 尝试打断一个候选声音。返回 false 表示应取消当前播放(Newest 模式)。 /// bool TryKill(List 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; } /// /// 从候选列表中选出开始时间最早的声音 /// ActiveSound SelectOldest(List candidates) { ActiveSound oldest = null; foreach (ActiveSound s in candidates) { if (oldest == null || s.StartTime < oldest.StartTime) { oldest = s; } } return oldest; } /// /// 根据 MixingType 获取对应的 AudioMixerGroup /// 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($"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); } } /// /// 活跃的声音封装 /// public class ActiveSound { public AudioSource Source; public AudioObject AudioObject; // 记录某个具体播放实例的开始时间,用于 SelectOldest 在多个并发实例中挑出最老的那个来打断,是"这个实例是什么时候开始的"。 public double StartTime; public int CurrentLoopCount; public Coroutine Coroutine; public float Pitch; } }