diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index c1b1bc3f7..12ea4d32d 100644 --- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -92,7 +92,10 @@ public static void SkipHeader(Stream stream) short bits = BinaryPrimitives.ReadInt16LittleEndian(fmtData.Slice(14, 2)); if (format != 1 || channels != 1 || rate != VoiceChatSettings.SampleRate || bits != 16) - throw new InvalidDataException($"Invalid WAV format (format={format}, channels={channels}, rate={rate}, bits={bits}). Expected PCM16, mono and {VoiceChatSettings.SampleRate}Hz."); + { + Log.Error($"[Speaker] Invalid WAV format (format={format}, channels={channels}, rate={rate}, bits={bits}). Expected PCM16, mono and {VoiceChatSettings.SampleRate}Hz."); + throw new InvalidDataException("Unsupported WAV format."); + } if (chunkSize > 16) stream.Seek(chunkSize - 16, SeekOrigin.Current); @@ -109,7 +112,10 @@ public static void SkipHeader(Stream stream) } if (stream.Position >= stream.Length) - throw new InvalidDataException("WAV file does not contain a 'data' chunk."); + { + Log.Error("[Speaker] WAV file does not contain a 'data' chunk."); + throw new InvalidDataException("Missing 'data' chunk in WAV file."); + } } } } diff --git a/EXILED/Exiled.API/Features/Toys/Light.cs b/EXILED/Exiled.API/Features/Toys/Light.cs index 9e167d1bb..4dad774de 100644 --- a/EXILED/Exiled.API/Features/Toys/Light.cs +++ b/EXILED/Exiled.API/Features/Toys/Light.cs @@ -151,13 +151,12 @@ public static Light Create(Vector3? position /*= null*/, Vector3? rotation /*= n Position = position ?? Vector3.zero, Rotation = Quaternion.Euler(rotation ?? Vector3.zero), Scale = scale ?? Vector3.one, + Color = color ?? Color.gray, }; if (spawn) light.Spawn(); - light.Color = color ?? Color.gray; - return light; } diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 4478c1143..4b254506f 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -16,6 +16,7 @@ namespace Exiled.API.Features.Toys using Enums; using Exiled.API.Features.Audio; + using Exiled.API.Features.Pools; using Interfaces; @@ -23,12 +24,15 @@ namespace Exiled.API.Features.Toys using Mirror; + using NorthwoodLib.Pools; + using UnityEngine; using VoiceChat; using VoiceChat.Codec; using VoiceChat.Codec.Enums; using VoiceChat.Networking; + using VoiceChat.Playbacks; using Object = UnityEngine.Object; @@ -37,6 +41,12 @@ namespace Exiled.API.Features.Toys /// public class Speaker : AdminToy, IWrapper { + /// + /// A queue used for object pooling of instances. + /// Reusing idle speakers instead of constantly creating and destroying them significantly improves server performance, especially for frequent audio events. + /// + internal static readonly Queue Pool = new(); + private const int FrameSize = VoiceChatSettings.PacketSizePerChannel; private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate; @@ -280,23 +290,28 @@ public byte ControllerId set => Base.NetworkControllerId = value; } + /// + /// Gets or sets a value indicating whether the speaker should return to the pool after playback finishes. + /// + public bool ReturnToPoolAfter { get; set; } + /// /// Creates a new . /// - /// The position of the . - /// The rotation of the . - /// The scale of the . + /// The parent transform to attach the to. + /// The local position of the . + /// The specific controller ID to assign. If null, the next available ID is used. /// Whether the should be initially spawned. /// The new . - public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scale, bool spawn) + public static Speaker Create(Transform parent = null, Vector3? position = null, byte? controllerId = null, bool spawn = true) { - Speaker speaker = new(Object.Instantiate(Prefab)) + Speaker speaker = new(Object.Instantiate(Prefab, parent)) { - Position = position ?? Vector3.zero, - Rotation = Quaternion.Euler(rotation ?? Vector3.zero), - Scale = scale ?? Vector3.one, + ControllerId = controllerId ?? GetNextFreeControllerId(), }; + speaker.Transform.localPosition = position ?? Vector3.zero; + if (spawn) speaker.Spawn(); @@ -304,45 +319,120 @@ public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scal } /// - /// Creates a new . + /// Rents an available speaker from the pool or creates a new one if the pool is empty. /// - /// The transform to create this on. - /// Whether the should be initially spawned. - /// Whether the should keep the same world position. - /// The new . - public static Speaker Create(Transform transform, bool spawn, bool worldPositionStays = true) + /// The local position of the . + /// The parent transform to attach the to. + /// A clean instance ready for use. + public static Speaker Rent(Vector3 position, Transform parent = null) { - Speaker speaker = new(Object.Instantiate(Prefab, transform, worldPositionStays)) + Speaker speaker = null; + + while (Pool.Count > 0) { - Position = transform.position, - Rotation = transform.rotation, - Scale = transform.localScale.normalized, - }; + speaker = Pool.Dequeue(); - if (spawn) - speaker.Spawn(); + if (speaker != null && speaker.Base != null) + break; + + speaker = null; + } + + if (speaker == null) + { + speaker = Create(parent: parent, position: position, spawn: true); + } + else + { + if (parent != null) + speaker.Transform.SetParent(parent); + + speaker.Volume = 1f; + speaker.Transform.localPosition = position; + speaker.ControllerId = GetNextFreeControllerId(); + } return speaker; } /// - /// Plays audio through this speaker. + /// Gets the next available controller ID for a . /// - /// An instance. - /// Targets who will hear the audio. If null, audio will be sent to all players. - public static void Play(AudioMessage message, IEnumerable targets = null) + /// The next available byte ID. If all IDs are currently in use, returns a default of 0. + public static byte GetNextFreeControllerId() { - foreach (Player target in targets ?? Player.List) - target.Connection.Send(message); + byte id = 0; + HashSet usedIds = NorthwoodLib.Pools.HashSetPool.Shared.Rent(256); + + foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances) + { + usedIds.Add(playbackBase.ControllerId); + } + + if (usedIds.Count >= byte.MaxValue + 1) + { + NorthwoodLib.Pools.HashSetPool.Shared.Return(usedIds); + return 0; + } + + while (usedIds.Contains(id)) + { + id++; + } + + NorthwoodLib.Pools.HashSetPool.Shared.Return(usedIds); + return id; } /// - /// Plays audio through this speaker. + /// Rents a speaker from the pool, plays a wav file one time, and automatically returns it to the pool afterwards. (File must be 16 bit, mono and 48khz.) /// - /// Audio samples. - /// The length of the samples array. - /// Targets who will hear the audio. If null, audio will be sent to all players. - public void Play(byte[] samples, int? length = null, IEnumerable targets = null) => Play(new AudioMessage(ControllerId, samples, length ?? samples.Length), targets); + /// The path to the wav file. + /// The position of the speaker. + /// The parent transform, if any. + /// Whether the audio source is spatialized. + /// The volume level of the audio source. + /// The minimum distance at which the audio reaches full volume. + /// The maximum distance at which the audio can be heard. + /// The playback pitch level of the audio source. + /// The play mode determining how audio is sent to players. + /// Whether to stream the audio or preload it. + /// The target player if PlayMode is Player. + /// The list of target players if PlayMode is PlayerList. + /// The condition if PlayMode is Predicate. + /// The rented instance if playback started successfully; otherwise, null. + public static Speaker PlayFromPool(string path, Vector3 position, Transform parent = null, bool isSpatial = true, float? volume = null, float? minDistance = null, float? maxDistance = null, float pitch = 1f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) + { + Speaker speaker = Rent(position, parent); + + if (!isSpatial) + speaker.IsSpatial = isSpatial; + + if (volume.HasValue) + speaker.Volume = volume.Value; + + if (minDistance.HasValue) + speaker.MinDistance = minDistance.Value; + + if (maxDistance.HasValue) + speaker.MaxDistance = maxDistance.Value; + + speaker.Pitch = pitch; + speaker.PlayMode = playMode; + speaker.Predicate = predicate; + speaker.TargetPlayer = targetPlayer; + speaker.TargetPlayers = targetPlayers; + + speaker.ReturnToPoolAfter = true; + + if (!speaker.Play(path, stream)) + { + speaker.ReturnToPool(); + return null; + } + + return speaker; + } /// /// Plays a wav file through this speaker.(File must be 16 bit, mono and 48khz.) @@ -351,13 +441,20 @@ public static void Play(AudioMessage message, IEnumerable targets = null /// Whether to stream the audio or preload it. /// Whether to destroy the speaker after playback. /// Whether to loop the audio. - public void Play(string path, bool stream = false, bool destroyAfter = false, bool loop = false) + /// true if the audio file was successfully found, loaded, and playback started; otherwise, false. + public bool Play(string path, bool stream = false, bool destroyAfter = false, bool loop = false) { if (!File.Exists(path)) - throw new FileNotFoundException("The specified file does not exist.", path); + { + Log.Error($"[Speaker] The specified file does not exist, path: `{path}`."); + return false; + } if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) - throw new NotSupportedException($"The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file."); + { + Log.Error($"[Speaker] The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file."); + return false; + } TryInitializePlayBack(); Stop(); @@ -365,8 +462,19 @@ public void Play(string path, bool stream = false, bool destroyAfter = false, bo Loop = loop; LastTrack = path; DestroyAfter = destroyAfter; - source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path); + + try + { + source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to initialize audio source for file at path: '{path}'.\nException Details: {ex}"); + return false; + } + playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); + return true; } /// @@ -384,6 +492,41 @@ public void Stop() source = null; } + /// + /// Stops the current playback, resets all properties of the , and returns the instance to the object pool for future reuse. + /// + public void ReturnToPool() + { + Stop(); + + Transform.SetParent(null); + Transform.localPosition = Vector3.zero; + + Loop = false; + DestroyAfter = false; + ReturnToPoolAfter = false; + PlayMode = SpeakerPlayMode.Global; + Channel = Channels.ReliableOrdered2; + + LastTrack = null; + Predicate = null; + TargetPlayer = null; + TargetPlayers = null; + + Pitch = 1f; + Volume = 0f; + IsSpatial = true; + + MinDistance = 1f; + MaxDistance = 15f; + + resampleTime = 0.0; + resampleBufferFilled = 0; + isPitchDefault = true; + + Pool.Enqueue(this); + } + private void TryInitializePlayBack() { if (isPlayBackInitialized) @@ -445,7 +588,9 @@ private IEnumerator PlayBackCoroutine() continue; } - if (DestroyAfter) + if (ReturnToPoolAfter) + ReturnToPool(); + else if (DestroyAfter) Destroy(); else Stop(); diff --git a/EXILED/Exiled.Events/Handlers/Internal/Round.cs b/EXILED/Exiled.Events/Handlers/Internal/Round.cs index cebcffa74..886f8f05b 100644 --- a/EXILED/Exiled.Events/Handlers/Internal/Round.cs +++ b/EXILED/Exiled.Events/Handlers/Internal/Round.cs @@ -18,6 +18,7 @@ namespace Exiled.Events.Handlers.Internal using Exiled.API.Features.Items; using Exiled.API.Features.Pools; using Exiled.API.Features.Roles; + using Exiled.API.Features.Toys; using Exiled.API.Structs; using Exiled.Events.EventArgs.Player; using Exiled.Events.EventArgs.Scp049; @@ -65,6 +66,8 @@ public static void OnWaitingForPlayers() /// public static void OnRestartingRound() { + Speaker.Pool.Clear(); + Scp049Role.TurnedPlayers.Clear(); Scp173Role.TurnedPlayers.Clear(); Scp096Role.TurnedPlayers.Clear();