0fdd76022d
- 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
542 lines
20 KiB
C#
542 lines
20 KiB
C#
using System;
|
||
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using OCES.Audio.HandWritten;
|
||
using OCES.Haptic;
|
||
using UnityEngine;
|
||
using UnityEngine.Audio;
|
||
|
||
namespace OCES.Audio
|
||
{
|
||
/// <summary>
|
||
/// 音效系统
|
||
/// </summary>
|
||
public class SfxSystem : MonoBehaviour
|
||
{
|
||
public UnityEngine.UI.Text audioSystemTextBox;
|
||
|
||
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();
|
||
|
||
// 复用列表,避免 TryPlay 每帧分配临时 List
|
||
readonly List<ActiveSound> m_tempSameObject = new();
|
||
readonly List<ActiveSound> m_tempSameGroup = new();
|
||
readonly List<ActiveSound> m_tempLowerPriority = new();
|
||
|
||
AudioGroupConfig m_groupConfig;
|
||
AudioMixerGroup m_sfxGroup;
|
||
AudioMixerGroup m_voiceGroup;
|
||
AudioMixerGroup m_accentSfxGroup;
|
||
AudioSourcePool m_pool;
|
||
AudioContainerSelector m_containerSelector;
|
||
PitchStepResolver m_pitchStepResolver;
|
||
VolumeStepResolver m_volumeStepResolver;
|
||
|
||
#if UNITY_EDITOR
|
||
void Update()
|
||
{
|
||
Editor.DebugInfoCollector.Instance.ActiveSounds = this.m_activeSounds;
|
||
Editor.DebugInfoCollector.Instance.ClipConcurrentCount = this.m_clipConcurrentCount;
|
||
}
|
||
#endif
|
||
|
||
|
||
int GetClipCount(uint id)
|
||
{
|
||
return this.m_clipConcurrentCount.GetValueOrDefault(id, 0);
|
||
}
|
||
|
||
void IncrementClipCount(uint id)
|
||
{
|
||
this.m_clipConcurrentCount[id] = GetClipCount(id) + 1;
|
||
#if UNITY_EDITOR
|
||
//Debug.Log($"{id} count added to {GetClipCount(id)}");
|
||
#endif
|
||
}
|
||
|
||
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, AudioSourcePool pool)
|
||
{
|
||
this.m_groupConfig = groups;
|
||
|
||
this.m_pool = pool;
|
||
this.m_containerSelector = new AudioContainerSelector();
|
||
this.m_pitchStepResolver = new PitchStepResolver();
|
||
this.m_volumeStepResolver = new VolumeStepResolver();
|
||
|
||
AudioMixerGroup[] sfx = Find("Master/Regular/SFX");
|
||
if (sfx.Length > 0) this.m_sfxGroup = sfx[0];
|
||
AudioMixerGroup[] voice = Find("Master/Regular/Voice");
|
||
if (voice.Length > 0) this.m_voiceGroup = voice[0];
|
||
AudioMixerGroup[] accentSfx = Find("Master/SFX_Accent");
|
||
if (accentSfx.Length > 0) this.m_accentSfxGroup = accentSfx[0];
|
||
|
||
return;
|
||
|
||
AudioMixerGroup[] Find(string path) => mixer.FindMatchingGroups(path);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 节流 & 调度入口
|
||
// ─────────────────────────────────────────────
|
||
|
||
internal void TryPlay(AudioObject audioObject, Action onPlay = null)
|
||
{
|
||
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)
|
||
{
|
||
this.m_tempSameObject.Clear();
|
||
foreach (ActiveSound activeSound in this.m_activeSounds)
|
||
{
|
||
if (activeSound.AudioObject.Id == audioObject.Id)
|
||
{
|
||
this.m_tempSameObject.Add(activeSound);
|
||
}
|
||
}
|
||
|
||
if (this.m_tempSameObject.Count >= audioObject.ThrottleCount &&
|
||
!TryKill(this.m_tempSameObject, 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。");
|
||
}
|
||
this.m_tempSameGroup.Clear();
|
||
foreach (ActiveSound activeSound in this.m_activeSounds)
|
||
{
|
||
if (activeSound.AudioObject.Group == audioObject.Group)
|
||
{
|
||
this.m_tempSameGroup.Add(activeSound);
|
||
}
|
||
}
|
||
|
||
if (this.m_tempSameGroup.Count >= groupConfig.GroupThrottleCount)
|
||
{
|
||
this.m_tempLowerPriority.Clear();
|
||
foreach (ActiveSound activeSound in this.m_tempSameGroup)
|
||
{
|
||
if (activeSound.AudioObject.Priority > audioObject.Priority)
|
||
{
|
||
this.m_tempLowerPriority.Add(activeSound);
|
||
}
|
||
}
|
||
|
||
List<ActiveSound> killCandidates = this.m_tempLowerPriority.Count > 0 ? this.m_tempLowerPriority : this.m_tempSameGroup;
|
||
KillMode killMode = this.m_tempLowerPriority.Count > 0 ? groupConfig.KillMode : audioObject.KillMode;
|
||
|
||
if (!TryKill(killCandidates, killMode, $"[Group] {audioObject.Name[0]}"))
|
||
return;
|
||
}
|
||
|
||
// 第四层:全局并发控制
|
||
if (this.m_activeSounds.Count >= k_maxGlobalConcurrent)
|
||
{
|
||
this.m_tempLowerPriority.Clear();
|
||
foreach (ActiveSound activeSound in this.m_activeSounds)
|
||
{
|
||
if (activeSound.AudioObject.Priority > audioObject.Priority)
|
||
{
|
||
this.m_tempLowerPriority.Add(activeSound);
|
||
}
|
||
}
|
||
|
||
if (this.m_tempLowerPriority.Count == 0 || !TryKill(this.m_tempLowerPriority, KillMode.Oldest, "[Global]"))
|
||
return;
|
||
}
|
||
|
||
// 执行播放
|
||
float pitch = this.m_pitchStepResolver.ResolvePitch(audioObject, now); //算一下用不用变调
|
||
float volume = this.m_volumeStepResolver.ResolveVolume(audioObject, now);
|
||
PlayNewSound(audioObject, pitch, volume);
|
||
this.m_lastPlayTime[audioObject.Id] = now;
|
||
onPlay?.Invoke();
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 播放逻辑
|
||
// ─────────────────────────────────────────────
|
||
|
||
void PlayNewSound(AudioObject audioObject, float pitch, float volume)
|
||
{
|
||
ActiveSound active = new()
|
||
{
|
||
AudioObject = audioObject,
|
||
Pitch = pitch,
|
||
Volume = volume,
|
||
State = ActiveSoundState.Pending,
|
||
StartTime = Time.realtimeSinceStartupAsDouble,
|
||
};
|
||
|
||
if (audioObject.InitialDelay > 0)
|
||
{
|
||
this.m_activeSounds.Add(active);
|
||
IncrementClipCount(audioObject.Id);
|
||
|
||
active.Coroutine = StartCoroutine(PlayAfterDelay(active, audioObject));
|
||
return;
|
||
}
|
||
|
||
ExecutePlay(active);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 连续容器播放协程(Random / Sequence 持续模式)
|
||
/// </summary>
|
||
IEnumerator PlayContainerContinuous(AudioSource source, AudioObject audioObject, ActiveSound chainActive, int startIndex)
|
||
{
|
||
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 (!SetupSource(source, chainActive, index))
|
||
{
|
||
Debug.LogError($"音频文件未找到:{audioObject.Name[index]}");
|
||
yield break;
|
||
}
|
||
|
||
StartPlayback(source);
|
||
|
||
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]}");
|
||
// TryStopHaptic(audioObject.Haptic);
|
||
yield break;
|
||
}
|
||
}
|
||
}
|
||
static void StartPlayback(AudioSource source)
|
||
{
|
||
source.Play();
|
||
}
|
||
|
||
IEnumerator PlayAfterDelay(ActiveSound active, AudioObject audioObject)
|
||
{
|
||
Debug.Log($"Delaying for {audioObject.InitialDelay} second(s).");
|
||
yield return new WaitForSeconds(audioObject.InitialDelay);
|
||
|
||
if (!this.m_activeSounds.Contains(active)) yield break;
|
||
|
||
ExecutePlay(active, isRegistered: true);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 工具方法
|
||
// ─────────────────────────────────────────────
|
||
|
||
/// <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.Voice => this.m_voiceGroup,
|
||
MixingType.Accented => this.m_accentSfxGroup,
|
||
_ => GetDefaultGroup(type),
|
||
};
|
||
|
||
AudioMixerGroup GetDefaultGroup(MixingType type)
|
||
{
|
||
if (type != MixingType.Sfx)
|
||
{
|
||
Debug.LogWarning($"[SfxSystem] Unrecognized MixingType: {type}, defaulting to SFX.");
|
||
}
|
||
|
||
return this.m_sfxGroup;
|
||
}
|
||
|
||
bool SetupSource(AudioSource source, ActiveSound activeSound, int clipIndex = 0)
|
||
{
|
||
AudioObject audioObject = activeSound.AudioObject;
|
||
AudioClip clip = Resources.Load<AudioClip>($"Audios/{audioObject.Name[clipIndex]}"); // TODO 抽象同一资源加载接口
|
||
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 = activeSound.Pitch;
|
||
source.volume = activeSound.Volume;
|
||
|
||
return true;
|
||
}
|
||
|
||
void RegisterActiveSound(ActiveSound activeSound, AudioSource audioSource, bool isRegistered = false)
|
||
{
|
||
activeSound.Source = audioSource;
|
||
activeSound.State = ActiveSoundState.Playing;
|
||
activeSound.StartTime = Time.realtimeSinceStartupAsDouble;
|
||
|
||
if (!isRegistered)
|
||
{
|
||
this.m_activeSounds.Add(activeSound);
|
||
IncrementClipCount(activeSound.AudioObject.Id);
|
||
}
|
||
|
||
if (activeSound.AudioObject.ContainerType == ContainerType.Blend)
|
||
{
|
||
Debug.LogWarning($"[Haptic System] Blend container {activeSound.AudioObject.Id} should not have haptic feedback!");
|
||
}
|
||
else
|
||
{
|
||
TryStartHaptic(activeSound);
|
||
}
|
||
|
||
activeSound.Coroutine = StartCoroutine(RemoveWhenFinished(activeSound));
|
||
}
|
||
|
||
void ExecutePlay(ActiveSound active, bool isRegistered = false)
|
||
{
|
||
AudioObject audioObject = active.AudioObject;
|
||
float pitch = active.Pitch;
|
||
float volume = active.Volume;
|
||
|
||
// =======================
|
||
// Blend(每个clip一个ActiveSound)
|
||
// =======================
|
||
if (audioObject.ContainerType == ContainerType.Blend)
|
||
{
|
||
for (int i = 0; i < audioObject.Name.Count; i++)
|
||
{
|
||
AudioSource source = this.m_pool.AcquireAudioSource();
|
||
|
||
ActiveSound child = new()
|
||
{
|
||
AudioObject = audioObject,
|
||
Pitch = pitch,
|
||
Volume = volume,
|
||
State = ActiveSoundState.Playing,
|
||
};
|
||
|
||
if (!SetupSource(source, child, i))
|
||
{
|
||
this.m_pool.ReturnToPool(source.gameObject);
|
||
continue;
|
||
}
|
||
|
||
StartPlayback(source);
|
||
RegisterActiveSound(child, source);
|
||
}
|
||
|
||
// 删除 pending 占位
|
||
if (this.m_activeSounds.Contains(active))
|
||
{
|
||
DecrementClipCount(audioObject.Id);
|
||
this.m_activeSounds.Remove(active);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
AudioSource sourceSingle = this.m_pool.AcquireAudioSource();
|
||
|
||
// =======================
|
||
// Continuous
|
||
// =======================
|
||
if (audioObject.Name.Count > 1 && audioObject.ContainerPlayMode)
|
||
{
|
||
active.Source = sourceSingle;
|
||
active.State = ActiveSoundState.Playing;
|
||
|
||
this.m_activeSounds.Add(active);
|
||
IncrementClipCount(audioObject.Id);
|
||
|
||
int start = audioObject.ContainerType == ContainerType.Random ? -1 : 0;
|
||
|
||
active.Coroutine = StartCoroutine(
|
||
PlayContainerContinuous(sourceSingle, audioObject, active, start)
|
||
);
|
||
|
||
return;
|
||
}
|
||
|
||
// =======================
|
||
// 单次播放
|
||
// =======================
|
||
int index = audioObject.ContainerType switch
|
||
{
|
||
ContainerType.Random => m_containerSelector.PickShuffleIndex(audioObject),
|
||
ContainerType.Sequence => m_containerSelector.GetNextSequenceIndex(audioObject),
|
||
_ => 0,
|
||
};
|
||
|
||
if (!SetupSource(sourceSingle, active, index))
|
||
{
|
||
this.m_pool.ReturnToPool(sourceSingle.gameObject);
|
||
return;
|
||
}
|
||
|
||
StartPlayback(sourceSingle);
|
||
RegisterActiveSound(active, sourceSingle, isRegistered);
|
||
}
|
||
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)
|
||
{
|
||
StartPlayback(active.Source);
|
||
return true;
|
||
}
|
||
|
||
void StopSound(ActiveSound active)
|
||
{
|
||
if (active.Coroutine != null)
|
||
{
|
||
StopCoroutine(active.Coroutine);
|
||
//Debug.Log($"[StopSound] 协程已终止: {active.AudioObject.Name[0]}");
|
||
}
|
||
|
||
if(active.Source) active.Source.Stop();
|
||
DecrementClipCount(active.AudioObject.Id);
|
||
this.m_activeSounds.Remove(active);
|
||
this.m_pool.ReturnToPool(active.Source.gameObject);
|
||
}
|
||
|
||
static void TryStartHaptic(ActiveSound active)
|
||
{
|
||
uint hapticId = active.AudioObject.Haptic;
|
||
if (hapticId == 0) return;
|
||
HapticSystem.Instance.Play(hapticId, isDirectCall: false);
|
||
}
|
||
|
||
static void TryStopHaptic(uint hapticId)
|
||
{
|
||
HapticSystem.Instance.Stop(hapticId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 活跃的声音封装
|
||
/// </summary>
|
||
public class ActiveSound
|
||
{
|
||
public AudioSource Source;
|
||
public AudioObject AudioObject;
|
||
// 记录某个具体播放实例的开始时间,用于 SelectOldest 在多个并发实例中挑出最老的那个来打断,是"这个实例是什么时候开始的"。
|
||
public double StartTime;
|
||
public int CurrentLoopCount;
|
||
public Coroutine Coroutine;
|
||
public float Pitch;
|
||
public float Volume;
|
||
public ActiveSoundState State;
|
||
}
|
||
} |