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..8980921bc 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -23,12 +23,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,9 +40,25 @@ 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 float DefaultVolume = 1f; + private const float DefaultMinDistance = 1f; + private const float DefaultMaxDistance = 15f; + + private const byte DefaultControllerId = 0; + + private const bool DefaultSpatial = true; + private const int FrameSize = VoiceChatSettings.PacketSizePerChannel; private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate; + private static readonly Vector3 SpeakerParkPosition = Vector3.down * 999; + private float[] frame; private byte[] encoded; private float[] resampleBuffer; @@ -117,6 +136,11 @@ internal Speaker(SpeakerToy speakerToy) /// public bool DestroyAfter { get; set; } + /// + /// Gets or sets a value indicating whether the speaker should return to the pool after playback finishes. + /// + public bool ReturnToPoolAfter { get; set; } + /// /// Gets or sets the play mode for this speaker, determining how audio is sent to players. /// @@ -139,7 +163,7 @@ internal Speaker(SpeakerToy speakerToy) public Func Predicate { get; set; } /// - /// Gets a value indicating whether gets is a sound playing on this speaker or not. + /// Gets a value indicating whether a sound is currently playing on this speaker. /// public bool IsPlaying => playBackRoutine.IsRunning && !IsPaused; @@ -177,12 +201,12 @@ public double CurrentTime get => source?.CurrentTime ?? 0.0; set { - if (source != null) - { - source.CurrentTime = value; - resampleTime = 0.0; - resampleBufferFilled = 0; - } + if (source == null) + return; + + source.CurrentTime = value; + resampleTime = 0.0; + resampleBufferFilled = 0; } } @@ -192,6 +216,12 @@ public double CurrentTime /// public double TotalDuration => source?.TotalDuration ?? 0.0; + /// + /// Gets the current playback progress as a value between 0.0 and 1.0. + /// Returns 0 if not playing. + /// + public float PlaybackProgress => TotalDuration > 0.0 ? (float)(CurrentTime / TotalDuration) : 0f; + /// /// Gets the path to the last audio file played on this speaker. /// @@ -283,20 +313,28 @@ public byte ControllerId /// /// 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 volume level of the audio source. + /// Whether the audio source is spatialized (3D sound). + /// The minimum distance at which the audio reaches full volume. + /// The maximum distance at which the audio can be heard. + /// 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, float volume = DefaultVolume, bool isSpatial = DefaultSpatial, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, 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, + Volume = volume, + IsSpatial = isSpatial, + MinDistance = minDistance, + MaxDistance = maxDistance, + ControllerId = controllerId ?? GetNextFreeControllerId(), }; + speaker.Transform.localPosition = position ?? Vector3.zero; + if (spawn) speaker.Spawn(); @@ -304,45 +342,122 @@ 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 + { + speaker.IsStatic = false; + + if (parent != null) + speaker.Transform.SetParent(parent); + + speaker.Transform.localPosition = position; + speaker.ControllerId = GetNextFreeControllerId(); + } return speaker; } /// - /// 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.) /// - /// 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 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. + /// true if the audio file was successfully found, loaded, and playback started; otherwise, false. + public static bool 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) { - foreach (Player target in targets ?? Player.List) - target.Connection.Send(message); + Speaker speaker = Rent(position, parent); + + if (isSpatial != DefaultSpatial) + speaker.IsSpatial = isSpatial; + + if (volume.HasValue && volume.Value != DefaultVolume) + speaker.Volume = volume.Value; + + if (minDistance.HasValue && minDistance.Value != DefaultMinDistance) + speaker.MinDistance = minDistance.Value; + + if (maxDistance.HasValue && maxDistance.Value != DefaultMaxDistance) + 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 false; + } + + return true; } /// - /// Plays audio through this speaker. + /// Gets the next available controller ID for a . /// - /// 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 next available byte ID. If all IDs are currently in use, returns a default of 0. + public static byte GetNextFreeControllerId() + { + byte id = 0; + HashSet usedIds = HashSetPool.Shared.Rent(byte.MaxValue + 1); + + foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances) + { + usedIds.Add(playbackBase.ControllerId); + } + + if (usedIds.Count >= byte.MaxValue + 1) + { + HashSetPool.Shared.Return(usedIds); + Log.Warn("[Speaker] All controller IDs are in use. Default Controll Id will be use, Audio may conflict!"); + return DefaultControllerId; + } + + while (usedIds.Contains(id)) + { + id++; + } + + HashSetPool.Shared.Return(usedIds); + return id; + } /// /// Plays a wav file through this speaker.(File must be 16 bit, mono and 48khz.) @@ -351,13 +466,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 +487,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; } /// @@ -376,7 +509,7 @@ public void Stop() { if (playBackRoutine.IsRunning) { - Timing.KillCoroutines(playBackRoutine); + playBackRoutine.IsRunning = false; OnPlaybackStopped?.Invoke(); } @@ -384,6 +517,57 @@ 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(); + + if (Transform.parent != null || AdminToyBase._clientParentId != 0) + { + Transform.SetParent(null); + Base.RpcChangeParent(0); + } + + Position = SpeakerParkPosition; + + if (Volume != DefaultVolume) + Volume = DefaultVolume; + + if (IsSpatial != DefaultSpatial) + IsSpatial = DefaultSpatial; + + if (MinDistance != DefaultMinDistance) + MinDistance = DefaultMinDistance; + + if (MaxDistance != DefaultMaxDistance) + MaxDistance = DefaultMaxDistance; + + if (ControllerId != DefaultControllerId) + ControllerId = DefaultControllerId; + + IsStatic = true; + + Loop = false; + DestroyAfter = false; + ReturnToPoolAfter = false; + PlayMode = SpeakerPlayMode.Global; + Channel = Channels.ReliableOrdered2; + + LastTrack = null; + Predicate = null; + TargetPlayer = null; + TargetPlayers = null; + + Pitch = 1f; + resampleTime = 0.0; + resampleBufferFilled = 0; + isPitchDefault = true; + + Pool.Enqueue(this); + } + private void TryInitializePlayBack() { if (isPlayBackInitialized) @@ -445,7 +629,9 @@ private IEnumerator PlayBackCoroutine() continue; } - if (DestroyAfter) + if (ReturnToPoolAfter) + ReturnToPool(); + else if (DestroyAfter) Destroy(); else Stop(); @@ -457,6 +643,58 @@ private IEnumerator PlayBackCoroutine() } } + private void SendPacket(int len) + { + AudioMessage msg = new(ControllerId, encoded, len); + + switch (PlayMode) + { + case SpeakerPlayMode.Global: + NetworkServer.SendToReady(msg, Channel); + break; + + case SpeakerPlayMode.Player: + TargetPlayer?.Connection.Send(msg, Channel); + break; + + case SpeakerPlayMode.PlayerList: + + if (TargetPlayers is null) + break; + + using (NetworkWriterPooled writer = NetworkWriterPool.Get()) + { + NetworkMessages.Pack(msg, writer); + ArraySegment segment = writer.ToArraySegment(); + + foreach (Player ply in TargetPlayers) + { + ply?.Connection.Send(segment, Channel); + } + } + + break; + + case SpeakerPlayMode.Predicate: + if (Predicate is null) + break; + + using (NetworkWriterPooled writer = NetworkWriterPool.Get()) + { + NetworkMessages.Pack(msg, writer); + ArraySegment segment = writer.ToArraySegment(); + + foreach (Player ply in Player.List) + { + if (Predicate(ply)) + ply.Connection.Send(segment, Channel); + } + } + + break; + } + } + private void ResampleFrame() { int requiredSize = (int)(FrameSize * Mathf.Abs(Pitch) * 2) + 10; @@ -527,51 +765,6 @@ private void ResampleFrame() } } - private void SendPacket(int len) - { - AudioMessage msg = new(ControllerId, encoded, len); - - switch (PlayMode) - { - case SpeakerPlayMode.Global: - NetworkServer.SendToReady(msg, Channel); - break; - - case SpeakerPlayMode.Player: - TargetPlayer?.Connection.Send(msg, Channel); - break; - - case SpeakerPlayMode.PlayerList: - using (NetworkWriterPooled writer = NetworkWriterPool.Get()) - { - NetworkMessages.Pack(msg, writer); - ArraySegment segment = writer.ToArraySegment(); - - foreach (Player ply in TargetPlayers) - { - ply?.Connection.Send(segment, Channel); - } - } - - break; - - case SpeakerPlayMode.Predicate: - using (NetworkWriterPooled writer = NetworkWriterPool.Get()) - { - NetworkMessages.Pack(msg, writer); - ArraySegment segment = writer.ToArraySegment(); - - foreach (Player ply in Player.List) - { - if (Predicate(ply)) - ply.Connection.Send(segment, Channel); - } - } - - break; - } - } - private void OnToyRemoved(AdminToyBase toy) { if (toy != Base) 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();