From d2f5dbc11a671cb8a182c1dbfb1e656cdbd36df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 28 Feb 2026 01:52:14 +0300 Subject: [PATCH 001/102] Automatic id / throwing errors changed to log error --- .../Exiled.API/Features/Audio/WavUtility.cs | 7 +- EXILED/Exiled.API/Features/Toys/Speaker.cs | 71 +++++++++++++------ 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index c1b1bc3f7..f88749c39 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,7 @@ 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."); } } } diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 4478c1143..dd4b3e4a0 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; @@ -29,6 +30,7 @@ namespace Exiled.API.Features.Toys using VoiceChat.Codec; using VoiceChat.Codec.Enums; using VoiceChat.Networking; + using VoiceChat.Playbacks; using Object = UnityEngine.Object; @@ -288,20 +290,8 @@ public byte ControllerId /// The scale of the . /// Whether the should be initially spawned. /// The new . - public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scale, bool spawn) - { - Speaker speaker = new(Object.Instantiate(Prefab)) - { - Position = position ?? Vector3.zero, - Rotation = Quaternion.Euler(rotation ?? Vector3.zero), - Scale = scale ?? Vector3.one, - }; - - if (spawn) - speaker.Spawn(); - - return speaker; - } + [Obsolete("Use the Create(parent, position, scale, controllerId, spawn, worldPositonStays) methods, rotation useless.")] + public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scale, bool spawn) => Create(parent: null, position: position, scale: scale, controllerId: null, spawn: spawn, worldPositionStays: true); /// /// Creates a new . @@ -310,21 +300,60 @@ public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scal /// 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) + public static Speaker Create(Transform transform, bool spawn, bool worldPositionStays = true) => Create(parent: transform, position: Vector3.zero, scale: transform.localScale.normalized, controllerId: null, spawn: spawn, worldPositionStays: worldPositionStays); + + /// + /// Creates a new . + /// + /// The parent transform to attach the to. + /// The local position of the . + /// The scale of the . + /// The specific controller ID to assign. If null, the next available ID is used. + /// Whether the should be initially spawned. + /// Whether the should keep the same world position when parented. + /// The new . + public static Speaker Create(Transform parent = null, Vector3? position = null, Vector3? scale = null, byte? controllerId = null, bool spawn = true, bool worldPositionStays = true) { - Speaker speaker = new(Object.Instantiate(Prefab, transform, worldPositionStays)) + Speaker speaker = new(Object.Instantiate(Prefab, parent, worldPositionStays)) { - Position = transform.position, - Rotation = transform.rotation, - Scale = transform.localScale.normalized, + Scale = scale ?? Vector3.one, + ControllerId = controllerId ?? GetNextControllerId(), }; + speaker.Transform.localPosition = position ?? Vector3.zero; + if (spawn) speaker.Spawn(); return speaker; } + /// + /// Gets the next available controller ID for a . + /// + /// The next available byte ID, or 0 if all IDs are currently in use. + public static byte GetNextControllerId() + { + byte id = 0; + HashSet usedIds = HashSetPool.Pool.Get(); + + foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances) + usedIds.Add(playbackBase.ControllerId); + + while (usedIds.Contains(id)) + { + id++; + if (id == 255) + { + HashSetPool.Pool.Return(usedIds); + return 0; + } + } + + HashSetPool.Pool.Return(usedIds); + return id; + } + /// /// Plays audio through this speaker. /// @@ -354,10 +383,10 @@ public static void Play(AudioMessage message, IEnumerable targets = null public void 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.Warn($"[Speaker] The specified file does not exist, path: `{path}`."); 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."); TryInitializePlayBack(); Stop(); From bae29947c893e6eb408741772ab4ddbde8e8b50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 28 Feb 2026 01:55:18 +0300 Subject: [PATCH 002/102] f --- EXILED/Exiled.API/Features/Audio/WavUtility.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index f88749c39..12ea4d32d 100644 --- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -112,7 +112,10 @@ public static void SkipHeader(Stream stream) } if (stream.Position >= stream.Length) + { Log.Error("[Speaker] WAV file does not contain a 'data' chunk."); + throw new InvalidDataException("Missing 'data' chunk in WAV file."); + } } } } From be87e9fc818566fc89a4069ad7b04f7eb0597592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 28 Feb 2026 01:57:43 +0300 Subject: [PATCH 003/102] . --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index dd4b3e4a0..2de141c0f 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -290,7 +290,7 @@ public byte ControllerId /// The scale of the . /// Whether the should be initially spawned. /// The new . - [Obsolete("Use the Create(parent, position, scale, controllerId, spawn, worldPositonStays) methods, rotation useless.")] + [Obsolete("Use the Create(parent, position, scale, controllerId, spawn, worldPositonStays) method, rotation is useless.")] public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scale, bool spawn) => Create(parent: null, position: position, scale: scale, controllerId: null, spawn: spawn, worldPositionStays: true); /// From ee82708f61d57eb994a8ca697bc2fb18f149737e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 28 Feb 2026 02:10:45 +0300 Subject: [PATCH 004/102] s --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 2de141c0f..008b3f365 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -317,7 +317,7 @@ public static Speaker Create(Transform parent = null, Vector3? position = null, Speaker speaker = new(Object.Instantiate(Prefab, parent, worldPositionStays)) { Scale = scale ?? Vector3.one, - ControllerId = controllerId ?? GetNextControllerId(), + ControllerId = controllerId ?? GetNextFreeControllerId(), }; speaker.Transform.localPosition = position ?? Vector3.zero; @@ -331,8 +331,8 @@ public static Speaker Create(Transform parent = null, Vector3? position = null, /// /// Gets the next available controller ID for a . /// - /// The next available byte ID, or 0 if all IDs are currently in use. - public static byte GetNextControllerId() + /// 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.Pool.Get(); @@ -340,14 +340,15 @@ public static byte GetNextControllerId() foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances) usedIds.Add(playbackBase.ControllerId); + if (usedIds.Count >= byte.MaxValue + 1) + { + HashSetPool.Pool.Return(usedIds); + return 0; + } + while (usedIds.Contains(id)) { id++; - if (id == 255) - { - HashSetPool.Pool.Return(usedIds); - return 0; - } } HashSetPool.Pool.Return(usedIds); From 36428d67630ceaac66fca93d96cee8df835529dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 28 Feb 2026 02:14:44 +0300 Subject: [PATCH 005/102] gf --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 008b3f365..0bf29dbc2 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -384,10 +384,16 @@ public static void Play(AudioMessage message, IEnumerable targets = null public void Play(string path, bool stream = false, bool destroyAfter = false, bool loop = false) { if (!File.Exists(path)) + { Log.Warn($"[Speaker] The specified file does not exist, path: `{path}`."); + return; + } if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) + { Log.Error($"[Speaker] The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file."); + return; + } TryInitializePlayBack(); Stop(); From 841d20d72cec341cdeb37d3facb76b0341f7b22f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 28 Feb 2026 02:15:53 +0300 Subject: [PATCH 006/102] =?UTF-8?q?e=C4=9FH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 0bf29dbc2..eb5106e72 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -385,7 +385,7 @@ public void Play(string path, bool stream = false, bool destroyAfter = false, bo { if (!File.Exists(path)) { - Log.Warn($"[Speaker] The specified file does not exist, path: `{path}`."); + Log.Error($"[Speaker] The specified file does not exist, path: `{path}`."); return; } From a08e3ffed5e7ec3f61e218349568bfd36ffbaa38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 1 Mar 2026 22:14:10 +0300 Subject: [PATCH 007/102] =?UTF-8?q?added=20new=20=C3=B6zellik?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index eb5106e72..72f8f1474 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -366,6 +366,32 @@ public static void Play(AudioMessage message, IEnumerable targets = null target.Connection.Send(message); } + /// + /// Plays a wav file one time through a newly spawned speaker and destroys it afterwards. (File must be 16 bit, mono and 48khz.) + /// + /// The path to the wav file. + /// The position of the speaker. + /// The parent transform, if any. + /// 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 created Speaker instance. + public static Speaker PlayOneShot(string path, Vector3 position, Transform parent = null, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) + { + Speaker speaker = Create(parent: parent, position: position, spawn: true); + + speaker.PlayMode = playMode; + speaker.TargetPlayer = targetPlayer; + speaker.TargetPlayers = targetPlayers; + speaker.Predicate = predicate; + + speaker.Play(path, stream: stream, destroyAfter: true, loop: false); + + return speaker; + } + /// /// Plays audio through this speaker. /// From c6fe071a3b434fed4bea0cc503616b61a43e51a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 1 Mar 2026 22:31:54 +0300 Subject: [PATCH 008/102] update --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 36 ++++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 72f8f1474..9da2ab49d 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -335,14 +335,16 @@ public static Speaker Create(Transform parent = null, Vector3? position = null, public static byte GetNextFreeControllerId() { byte id = 0; - HashSet usedIds = HashSetPool.Pool.Get(); + HashSet usedIds = NorthwoodLib.Pools.HashSetPool.Shared.Rent(256); foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances) + { usedIds.Add(playbackBase.ControllerId); + } if (usedIds.Count >= byte.MaxValue + 1) { - HashSetPool.Pool.Return(usedIds); + NorthwoodLib.Pools.HashSetPool.Shared.Return(usedIds); return 0; } @@ -351,7 +353,7 @@ public static byte GetNextFreeControllerId() id++; } - HashSetPool.Pool.Return(usedIds); + NorthwoodLib.Pools.HashSetPool.Shared.Return(usedIds); return id; } @@ -377,7 +379,7 @@ public static void Play(AudioMessage message, IEnumerable targets = null /// The target player if PlayMode is Player. /// The list of target players if PlayMode is PlayerList. /// The condition if PlayMode is Predicate. - /// The created Speaker instance. + /// The created instance if playback started successfully; otherwise, null. public static Speaker PlayOneShot(string path, Vector3 position, Transform parent = null, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) { Speaker speaker = Create(parent: parent, position: position, spawn: true); @@ -387,7 +389,11 @@ public static Speaker PlayOneShot(string path, Vector3 position, Transform paren speaker.TargetPlayers = targetPlayers; speaker.Predicate = predicate; - speaker.Play(path, stream: stream, destroyAfter: true, loop: false); + if (!speaker.Play(path, stream: stream, destroyAfter: true, loop: false)) + { + speaker.Destroy(); + return null; + } return speaker; } @@ -407,18 +413,19 @@ public static Speaker PlayOneShot(string path, Vector3 position, Transform paren /// 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)) { Log.Error($"[Speaker] The specified file does not exist, path: `{path}`."); - return; + return false; } if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) { Log.Error($"[Speaker] The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file."); - return; + return false; } TryInitializePlayBack(); @@ -427,8 +434,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(ex); + return false; + } + playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); + return true; } /// From 2d9feea2014b63dbd5adde889cb80c1268d934d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 1 Mar 2026 22:43:30 +0300 Subject: [PATCH 009/102] update log --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 9da2ab49d..9c5abc889 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -441,7 +441,8 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo } catch (Exception ex) { - Log.Error(ex); + string loadMode = stream ? "Stream" : "Preload"; + Log.Error($"[Speaker] Failed to initialize audio source ({loadMode}) for file at path: '{path}'.\nException Details: {ex}"); return false; } From 8ec3cde5cf5f2cfbaf6df0ebed7eb7730b7daadf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 1 Mar 2026 23:21:42 +0300 Subject: [PATCH 010/102] color on wrong order --- EXILED/Exiled.API/Features/Toys/Light.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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; } From 670dc1a64109260f3be92b46ae72214488dd85b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 2 Mar 2026 01:12:17 +0300 Subject: [PATCH 011/102] wwait i will fix --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 87 +++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 9c5abc889..d39c3e7c3 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -24,6 +24,8 @@ namespace Exiled.API.Features.Toys using Mirror; + using NorthwoodLib.Pools; + using UnityEngine; using VoiceChat; @@ -42,6 +44,8 @@ public class Speaker : AdminToy, IWrapper private const int FrameSize = VoiceChatSettings.PacketSizePerChannel; private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate; + private static readonly Queue Pool = new(); + private float[] frame; private byte[] encoded; private float[] resampleBuffer; @@ -282,6 +286,11 @@ 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 . /// @@ -328,6 +337,45 @@ public static Speaker Create(Transform parent = null, Vector3? position = null, return speaker; } + /// + /// Rents an available speaker from the pool or creates a new one if the pool is empty. + /// + /// 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 = null; + + while (Pool.Count > 0) + { + speaker = Pool.Dequeue(); + + if (speaker != null && speaker.Base != null) + break; + + speaker = null; + } + + if (speaker == null) + { + speaker = Create(parent: parent, position: position, spawn: true); + } + else + { + speaker.Transform.SetParent(parent); + speaker.Transform.localPosition = position; + speaker.ControllerId = GetNextFreeControllerId(); + } + + speaker.Volume = 1f; + + speaker.ReturnToPoolAfter = true; + speaker.DestroyAfter = false; + + return speaker; + } + /// /// Gets the next available controller ID for a . /// @@ -465,6 +513,41 @@ public void Stop() source = null; } + /// + /// blabalbla. + /// + public void ReturnToPool() + { + Stop(); + + Transform.SetParent(null); + Transform.localPosition = Vector3.down * 999f; + + Loop = false; + PlayMode = default; + DestroyAfter = false; + ReturnToPoolAfter = false; + 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) @@ -526,7 +609,9 @@ private IEnumerator PlayBackCoroutine() continue; } - if (DestroyAfter) + if (ReturnToPoolAfter) + ReturnToPool(); + else if (DestroyAfter) Destroy(); else Stop(); From 527613c3a62d36d70f67874f8284f20494eb0620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 2 Mar 2026 01:35:36 +0300 Subject: [PATCH 012/102] finished --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 25 +++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index d39c3e7c3..38d16bc67 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -369,9 +369,8 @@ public static Speaker Rent(Vector3 position, Transform parent = null) } speaker.Volume = 1f; - - speaker.ReturnToPoolAfter = true; - speaker.DestroyAfter = false; + speaker.MinDistance = 1f; + speaker.MaxDistance = 15f; return speaker; } @@ -417,7 +416,7 @@ public static void Play(AudioMessage message, IEnumerable targets = null } /// - /// Plays a wav file one time through a newly spawned speaker and destroys it afterwards. (File must be 16 bit, mono and 48khz.) + /// 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.) /// /// The path to the wav file. /// The position of the speaker. @@ -427,19 +426,21 @@ public static void Play(AudioMessage message, IEnumerable targets = null /// The target player if PlayMode is Player. /// The list of target players if PlayMode is PlayerList. /// The condition if PlayMode is Predicate. - /// The created instance if playback started successfully; otherwise, null. - public static Speaker PlayOneShot(string path, Vector3 position, Transform parent = null, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) + /// The rented instance if playback started successfully; otherwise, null. + public static Speaker PlayFromPool(string path, Vector3 position, Transform parent = null, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) { - Speaker speaker = Create(parent: parent, position: position, spawn: true); + Speaker speaker = Rent(position, parent); speaker.PlayMode = playMode; speaker.TargetPlayer = targetPlayer; speaker.TargetPlayers = targetPlayers; speaker.Predicate = predicate; - if (!speaker.Play(path, stream: stream, destroyAfter: true, loop: false)) + speaker.ReturnToPoolAfter = true; + + if (!speaker.Play(path, stream: stream)) { - speaker.Destroy(); + speaker.ReturnToPool(); return null; } @@ -521,7 +522,7 @@ public void ReturnToPool() Stop(); Transform.SetParent(null); - Transform.localPosition = Vector3.down * 999f; + Transform.localPosition = Vector3.down * 9999; Loop = false; PlayMode = default; @@ -538,8 +539,8 @@ public void ReturnToPool() Volume = 0f; IsSpatial = true; - MinDistance = 1f; - MaxDistance = 15f; + MinDistance = 0; + MaxDistance = 0; resampleTime = 0.0; resampleBufferFilled = 0; From 26909f645940e20b14a52b2573cbf6964164bfe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 2 Mar 2026 02:15:35 +0300 Subject: [PATCH 013/102] fix --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 34 +++++++++++++++------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 38d16bc67..fff07ffeb 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -363,15 +363,14 @@ public static Speaker Rent(Vector3 position, Transform parent = null) } else { - speaker.Transform.SetParent(parent); + if (parent != null) + speaker.Transform.SetParent(parent); + + speaker.Volume = 1f; speaker.Transform.localPosition = position; speaker.ControllerId = GetNextFreeControllerId(); } - speaker.Volume = 1f; - speaker.MinDistance = 1f; - speaker.MaxDistance = 15f; - return speaker; } @@ -421,20 +420,32 @@ 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 minimum distance at which the audio reaches full volume. + /// The maximum distance at which the audio can be heard. /// 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, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) + public static Speaker PlayFromPool(string path, Vector3 position, Transform parent = null, bool isSpatial = true, float? minDistance = null, float? maxDistance = null, 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 (minDistance != null) + speaker.MinDistance = (float)minDistance; + + if (maxDistance != null) + speaker.MaxDistance = (float)maxDistance; + speaker.PlayMode = playMode; + speaker.Predicate = predicate; speaker.TargetPlayer = targetPlayer; speaker.TargetPlayers = targetPlayers; - speaker.Predicate = predicate; speaker.ReturnToPoolAfter = true; @@ -444,6 +455,7 @@ public static Speaker PlayFromPool(string path, Vector3 position, Transform pare return null; } + Log.Warn("pool size " + Pool.Count); return speaker; } @@ -522,12 +534,12 @@ public void ReturnToPool() Stop(); Transform.SetParent(null); - Transform.localPosition = Vector3.down * 9999; + Transform.localPosition = Vector3.zero; Loop = false; - PlayMode = default; DestroyAfter = false; ReturnToPoolAfter = false; + PlayMode = SpeakerPlayMode.Global; Channel = Channels.ReliableOrdered2; LastTrack = null; @@ -539,8 +551,8 @@ public void ReturnToPool() Volume = 0f; IsSpatial = true; - MinDistance = 0; - MaxDistance = 0; + MinDistance = 1; + MaxDistance = 15; resampleTime = 0.0; resampleBufferFilled = 0; From 6da8cd226aa4e92076bf42c0cdfbbf0f65598992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 2 Mar 2026 02:32:11 +0300 Subject: [PATCH 014/102] pool clear --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 4 ++-- EXILED/Exiled.Events/Handlers/Internal/Round.cs | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index fff07ffeb..f82a01bcd 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -44,8 +44,6 @@ public class Speaker : AdminToy, IWrapper private const int FrameSize = VoiceChatSettings.PacketSizePerChannel; private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate; - private static readonly Queue Pool = new(); - private float[] frame; private byte[] encoded; private float[] resampleBuffer; @@ -60,6 +58,8 @@ public class Speaker : AdminToy, IWrapper private bool isPitchDefault = true; private bool isPlayBackInitialized = false; + internal static readonly Queue Pool = new(); + /// /// Initializes a new instance of the class. /// 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(); From 952200f0d18395e215d04ab0d91ed5631dbdb9a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 2 Mar 2026 02:32:47 +0300 Subject: [PATCH 015/102] fAHH! --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index f82a01bcd..984f83f2a 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -455,7 +455,6 @@ public static Speaker PlayFromPool(string path, Vector3 position, Transform pare return null; } - Log.Warn("pool size " + Pool.Count); return speaker; } From 06e8ffdfd0755bfa98ac3936bec77a171ced3e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 2 Mar 2026 02:34:25 +0300 Subject: [PATCH 016/102] Fahh --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 984f83f2a..c9cc9957a 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -41,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; @@ -58,8 +64,6 @@ public class Speaker : AdminToy, IWrapper private bool isPitchDefault = true; private bool isPlayBackInitialized = false; - internal static readonly Queue Pool = new(); - /// /// Initializes a new instance of the class. /// From 8cab48f2d1bd6f7b066735f3c5d82695878d2138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 2 Mar 2026 03:51:05 +0300 Subject: [PATCH 017/102] better and more functionally --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index c9cc9957a..1b6c0d047 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -425,27 +425,33 @@ public static void Play(AudioMessage message, IEnumerable targets = null /// 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? minDistance = null, float? maxDistance = null, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = 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 (minDistance != null) - speaker.MinDistance = (float)minDistance; + if (volume.HasValue) + speaker.Volume = volume.Value; - if (maxDistance != null) - speaker.MaxDistance = (float)maxDistance; + 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; @@ -554,8 +560,8 @@ public void ReturnToPool() Volume = 0f; IsSpatial = true; - MinDistance = 1; - MaxDistance = 15; + MinDistance = 1f; + MaxDistance = 15f; resampleTime = 0.0; resampleBufferFilled = 0; From 699eb564ac60ea734551cca898bdd449cdefdff7 Mon Sep 17 00:00:00 2001 From: MS-crew <100300664+MS-crew@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:39:41 +0300 Subject: [PATCH 018/102] Return to pool doc --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 1b6c0d047..b6accaf78 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -536,8 +536,8 @@ public void Stop() } /// - /// blabalbla. - /// + /// Stops the current playback, resets all properties of the , and returns the instance to the object pool for future reuse. + /// public void ReturnToPool() { Stop(); From ec11415ee9b93ae2d727c4060f844fccb169592c Mon Sep 17 00:00:00 2001 From: MS-crew <100300664+MS-crew@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:44:57 +0300 Subject: [PATCH 019/102] Cleanup & Breaking Changes for exiled 10 Fck this old useless things --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 45 +--------------------- 1 file changed, 2 insertions(+), 43 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index b6accaf78..d653240ac 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -295,39 +295,17 @@ public byte ControllerId /// public bool ReturnToPoolAfter { get; set; } - /// - /// Creates a new . - /// - /// The position of the . - /// The rotation of the . - /// The scale of the . - /// Whether the should be initially spawned. - /// The new . - [Obsolete("Use the Create(parent, position, scale, controllerId, spawn, worldPositonStays) method, rotation is useless.")] - public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scale, bool spawn) => Create(parent: null, position: position, scale: scale, controllerId: null, spawn: spawn, worldPositionStays: true); - - /// - /// Creates a new . - /// - /// 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) => Create(parent: transform, position: Vector3.zero, scale: transform.localScale.normalized, controllerId: null, spawn: spawn, worldPositionStays: worldPositionStays); - /// /// Creates a new . /// /// The parent transform to attach the to. /// The local position of the . - /// The scale of the . /// The specific controller ID to assign. If null, the next available ID is used. /// Whether the should be initially spawned. - /// Whether the should keep the same world position when parented. /// The new . - public static Speaker Create(Transform parent = null, Vector3? position = null, Vector3? scale = null, byte? controllerId = null, bool spawn = true, bool worldPositionStays = true) + public static Speaker Create(Transform parent = null, Vector3? position = null, byte? controllerId = null, bool spawn = true) { - Speaker speaker = new(Object.Instantiate(Prefab, parent, worldPositionStays)) + Speaker speaker = new(Object.Instantiate(Prefab, parent)) { Scale = scale ?? Vector3.one, ControllerId = controllerId ?? GetNextFreeControllerId(), @@ -407,17 +385,6 @@ public static byte GetNextFreeControllerId() return id; } - /// - /// Plays audio through this speaker. - /// - /// 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) - { - foreach (Player target in targets ?? Player.List) - target.Connection.Send(message); - } - /// /// 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.) /// @@ -468,14 +435,6 @@ public static Speaker PlayFromPool(string path, Vector3 position, Transform pare return speaker; } - /// - /// Plays audio through this speaker. - /// - /// 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); - /// /// Plays a wav file through this speaker.(File must be 16 bit, mono and 48khz.) /// From 0c4371fe130881bea890431f6813c0bd87e29a29 Mon Sep 17 00:00:00 2001 From: MS-crew <100300664+MS-crew@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:52:38 +0300 Subject: [PATCH 020/102] Update Speaker.cs --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index d653240ac..7ad9bd5c6 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -307,7 +307,6 @@ public static Speaker Create(Transform parent = null, Vector3? position = null, { Speaker speaker = new(Object.Instantiate(Prefab, parent)) { - Scale = scale ?? Vector3.one, ControllerId = controllerId ?? GetNextFreeControllerId(), }; @@ -496,7 +495,7 @@ public void Stop() /// /// Stops the current playback, resets all properties of the , and returns the instance to the object pool for future reuse. - /// + /// public void ReturnToPool() { Stop(); From b5edd7a4bdc465899153f415733cc4e150a7452c Mon Sep 17 00:00:00 2001 From: MS-crew <100300664+MS-crew@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:00:00 +0300 Subject: [PATCH 021/102] Update Speaker.cs --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 7ad9bd5c6..639bd1ada 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -425,7 +425,7 @@ public static Speaker PlayFromPool(string path, Vector3 position, Transform pare speaker.ReturnToPoolAfter = true; - if (!speaker.Play(path, stream: stream)) + if (!speaker.Play(path, stream)) { speaker.ReturnToPool(); return null; From 03e9a4ed2e47b5cc38ea8908eb9ed5d3daeee8de Mon Sep 17 00:00:00 2001 From: MS-crew <100300664+MS-crew@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:04:01 +0300 Subject: [PATCH 022/102] Update Speaker.cs --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 639bd1ada..4b254506f 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -469,8 +469,7 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo } catch (Exception ex) { - string loadMode = stream ? "Stream" : "Preload"; - Log.Error($"[Speaker] Failed to initialize audio source ({loadMode}) for file at path: '{path}'.\nException Details: {ex}"); + Log.Error($"[Speaker] Failed to initialize audio source for file at path: '{path}'.\nException Details: {ex}"); return false; } From 44e41b85adb1089d95a3f2ae696fb66da29fab37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 2 Mar 2026 15:21:13 +0300 Subject: [PATCH 023/102] little refactor --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 171 ++++++++++----------- 1 file changed, 85 insertions(+), 86 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 4b254506f..45cc0749c 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -16,7 +16,6 @@ namespace Exiled.API.Features.Toys using Enums; using Exiled.API.Features.Audio; - using Exiled.API.Features.Pools; using Interfaces; @@ -127,6 +126,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. /// @@ -187,12 +191,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; } } @@ -290,11 +294,6 @@ 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 . /// @@ -355,35 +354,6 @@ public static Speaker Rent(Vector3 position, Transform parent = null) return speaker; } - /// - /// Gets the next available controller ID for a . - /// - /// 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 = 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; - } - /// /// 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.) /// @@ -434,6 +404,35 @@ public static Speaker PlayFromPool(string path, Vector3 position, Transform pare return speaker; } + /// + /// Gets the next available controller ID for a . + /// + /// 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(256); + + foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances) + { + usedIds.Add(playbackBase.ControllerId); + } + + if (usedIds.Count >= byte.MaxValue + 1) + { + HashSetPool.Shared.Return(usedIds); + return 0; + } + + 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.) /// @@ -602,6 +601,51 @@ 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: + 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 ResampleFrame() { int requiredSize = (int)(FrameSize * Mathf.Abs(Pitch) * 2) + 10; @@ -672,51 +716,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) From 6a1bfc3a0a813c3c99a5c23d109372f1fd5b2547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 2 Mar 2026 16:13:51 +0300 Subject: [PATCH 024/102] Performance improvement & fix Architectural problems in pooling --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 45cc0749c..59142b665 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -343,10 +343,11 @@ public static Speaker Rent(Vector3 position, Transform parent = null) } else { + speaker.IsStatic = false; + if (parent != null) speaker.Transform.SetParent(parent); - speaker.Volume = 1f; speaker.Transform.localPosition = position; speaker.ControllerId = GetNextFreeControllerId(); } @@ -370,8 +371,8 @@ public static Speaker Rent(Vector3 position, Transform parent = null) /// 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) + /// 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) { Speaker speaker = Rent(position, parent); @@ -398,10 +399,10 @@ public static Speaker PlayFromPool(string path, Vector3 position, Transform pare if (!speaker.Play(path, stream)) { speaker.ReturnToPool(); - return null; + return false; } - return speaker; + return true; } /// @@ -498,8 +499,10 @@ public void ReturnToPool() { Stop(); + IsStatic = true; + Transform.SetParent(null); - Transform.localPosition = Vector3.zero; + Position = Vector3.one * 999; Loop = false; DestroyAfter = false; @@ -513,7 +516,7 @@ public void ReturnToPool() TargetPlayers = null; Pitch = 1f; - Volume = 0f; + Volume = 1f; IsSpatial = true; MinDistance = 1f; From d80362bfa4c3f3742787dfcff9dd360b3ad0624a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 2 Mar 2026 16:14:02 +0300 Subject: [PATCH 025/102] wth --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 59142b665..e71adf785 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -502,7 +502,7 @@ public void ReturnToPool() IsStatic = true; Transform.SetParent(null); - Position = Vector3.one * 999; + Position = Vector3.down * 9999; Loop = false; DestroyAfter = false; From f38df4eecee3ee32f9bebbc592dd792c108991cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Tue, 3 Mar 2026 15:15:40 +0300 Subject: [PATCH 026/102] . --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index e71adf785..f3ff7c2be 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -299,14 +299,22 @@ public byte ControllerId /// /// 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(Transform parent = null, Vector3? position = null, byte? controllerId = null, bool spawn = true) + public static Speaker Create(Transform parent = null, Vector3? position = null, float volume = 1f, bool isSpatial = true, float minDistance = 1f, float maxDistance = 15f, byte? controllerId = null, bool spawn = true) { Speaker speaker = new(Object.Instantiate(Prefab, parent)) { - ControllerId = controllerId ?? GetNextFreeControllerId(), + Volume = volume, + IsSpatial = isSpatial, + MinDistance = minDistance, + MaxDistance = maxDistance, + ControllerId = controllerId ?? GetNextFreeControllerId(), }; speaker.Transform.localPosition = position ?? Vector3.zero; From 8ff218091720b59788e138124c93b10aa5660645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Thu, 5 Mar 2026 15:11:23 +0300 Subject: [PATCH 027/102] fix return pool --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 25 ++++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index f3ff7c2be..6079db294 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -509,9 +509,26 @@ public void ReturnToPool() IsStatic = true; - Transform.SetParent(null); + if (Transform.parent != null) + { + Transform.SetParent(null); + Base.RpcChangeParent(0); + } + Position = Vector3.down * 9999; + if (Volume != 1f) + Volume = 1f; + + if (!IsSpatial) + IsSpatial = true; + + if (MinDistance != 1f) + MinDistance = 1f; + + if (MaxDistance != 15f) + MaxDistance = 15f; + Loop = false; DestroyAfter = false; ReturnToPoolAfter = false; @@ -524,12 +541,6 @@ public void ReturnToPool() TargetPlayers = null; Pitch = 1f; - Volume = 1f; - IsSpatial = true; - - MinDistance = 1f; - MaxDistance = 15f; - resampleTime = 0.0; resampleBufferFilled = 0; isPitchDefault = true; From e926b585fc3f2f9f5db0d4e40272e55b96cd0bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 7 Mar 2026 00:04:31 +0300 Subject: [PATCH 028/102] . --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 6079db294..2e9e7ce6b 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -509,7 +509,7 @@ public void ReturnToPool() IsStatic = true; - if (Transform.parent != null) + if (Transform.parent != null || AdminToyBase._clientParentId != 0) { Transform.SetParent(null); Base.RpcChangeParent(0); From 575ec405de3edb45948f5b24dfa98dd9563f18bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 7 Mar 2026 16:40:49 +0300 Subject: [PATCH 029/102] d --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 2e9e7ce6b..55fc87dd5 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -507,8 +507,6 @@ public void ReturnToPool() { Stop(); - IsStatic = true; - if (Transform.parent != null || AdminToyBase._clientParentId != 0) { Transform.SetParent(null); @@ -529,6 +527,8 @@ public void ReturnToPool() if (MaxDistance != 15f) MaxDistance = 15f; + IsStatic = true; + Loop = false; DestroyAfter = false; ReturnToPoolAfter = false; From b2bdac1068181129aeec8372ae86021b2b8fae4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 8 Mar 2026 02:00:55 +0300 Subject: [PATCH 030/102] removed Hard Coded values --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 32 ++++++++++++++-------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 55fc87dd5..3cf10a217 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -46,9 +46,17 @@ public class Speaker : AdminToy, IWrapper /// internal static readonly Queue Pool = new(); + private const float DefaultVolume = 1f; + private const float DefaultMinDistance = 1f; + private const float DefaultMaxDistance = 15f; + + 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; @@ -387,13 +395,13 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent if (!isSpatial) speaker.IsSpatial = isSpatial; - if (volume.HasValue) + if (volume.HasValue && volume.Value != DefaultVolume) speaker.Volume = volume.Value; - if (minDistance.HasValue) + if (minDistance.HasValue && minDistance.Value != DefaultMinDistance) speaker.MinDistance = minDistance.Value; - if (maxDistance.HasValue) + if (maxDistance.HasValue && maxDistance.Value != DefaultMaxDistance) speaker.MaxDistance = maxDistance.Value; speaker.Pitch = pitch; @@ -513,19 +521,19 @@ public void ReturnToPool() Base.RpcChangeParent(0); } - Position = Vector3.down * 9999; + Position = SpeakerParkPosition; - if (Volume != 1f) - Volume = 1f; + if (Volume != DefaultVolume) + Volume = DefaultVolume; - if (!IsSpatial) - IsSpatial = true; + if (IsSpatial != DefaultSpatial) + IsSpatial = DefaultSpatial; - if (MinDistance != 1f) - MinDistance = 1f; + if (MinDistance != DefaultMinDistance) + MinDistance = DefaultMinDistance; - if (MaxDistance != 15f) - MaxDistance = 15f; + if (MaxDistance != DefaultMaxDistance) + MaxDistance = DefaultMaxDistance; IsStatic = true; From d79555bf839d9adaf3827d527072ea950484d556 Mon Sep 17 00:00:00 2001 From: MS-crew <100300664+MS-crew@users.noreply.github.com> Date: Sun, 8 Mar 2026 06:20:29 +0300 Subject: [PATCH 031/102] Update Speaker.cs --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 3cf10a217..44633f0aa 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -392,7 +392,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent { Speaker speaker = Rent(position, parent); - if (!isSpatial) + if (isSpatial != DefaultSpatial) speaker.IsSpatial = isSpatial; if (volume.HasValue && volume.Value != DefaultVolume) From 0ea7bd0a682765d28993f66f48148ee303c47518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 8 Mar 2026 16:09:52 +0300 Subject: [PATCH 032/102] fAHHHHH --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 23 ++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 3cf10a217..db4178815 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -163,7 +163,16 @@ internal Speaker(SpeakerToy speakerToy) /// /// Gets a value indicating whether gets is a sound playing on this speaker or not. /// - public bool IsPlaying => playBackRoutine.IsRunning && !IsPaused; + public bool IsPlaying + { + get + { + if (playBackRoutine == null) + return false; + + return playBackRoutine.IsRunning && !IsPaused; + } + } /// /// Gets or sets a value indicating whether the playback is paused. @@ -176,6 +185,9 @@ public bool IsPaused get => playBackRoutine.IsAliveAndPaused; set { + if (playBackRoutine == null) + return; + if (!playBackRoutine.IsRunning) return; @@ -214,6 +226,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. /// @@ -428,7 +446,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent public static byte GetNextFreeControllerId() { byte id = 0; - HashSet usedIds = HashSetPool.Shared.Rent(256); + HashSet usedIds = HashSetPool.Shared.Rent(byte.MaxValue + 1); foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances) { @@ -438,6 +456,7 @@ public static byte GetNextFreeControllerId() if (usedIds.Count >= byte.MaxValue + 1) { HashSetPool.Shared.Return(usedIds); + Log.Warn("[Speaker] All controller IDs are in use. Audio may conflict!"); return 0; } From ad14a1240f532547c5fa58472658d7187dde1cd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 8 Mar 2026 17:07:34 +0300 Subject: [PATCH 033/102] fix --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 25 +++++++++------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 2c2c4c5a7..6b672e186 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -161,18 +161,9 @@ 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 - { - get - { - if (playBackRoutine == null) - return false; - - return playBackRoutine.IsRunning && !IsPaused; - } - } + public bool IsPlaying => playBackRoutine.IsRunning && !IsPaused; /// /// Gets or sets a value indicating whether the playback is paused. @@ -185,9 +176,6 @@ public bool IsPaused get => playBackRoutine.IsAliveAndPaused; set { - if (playBackRoutine == null) - return; - if (!playBackRoutine.IsRunning) return; @@ -519,7 +507,7 @@ public void Stop() { if (playBackRoutine.IsRunning) { - Timing.KillCoroutines(playBackRoutine); + playBackRoutine.IsRunning = false; OnPlaybackStopped?.Invoke(); } @@ -665,6 +653,10 @@ private void SendPacket(int len) break; case SpeakerPlayMode.PlayerList: + + if (TargetPlayers is null) + break; + using (NetworkWriterPooled writer = NetworkWriterPool.Get()) { NetworkMessages.Pack(msg, writer); @@ -679,6 +671,9 @@ private void SendPacket(int len) break; case SpeakerPlayMode.Predicate: + if (Predicate is null) + break; + using (NetworkWriterPooled writer = NetworkWriterPool.Get()) { NetworkMessages.Pack(msg, writer); From 817b77807a00faae8da36c72f05b1b91ce4b8536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 8 Mar 2026 17:11:56 +0300 Subject: [PATCH 034/102] f --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 6b672e186..65da247f7 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -50,6 +50,8 @@ public class Speaker : AdminToy, IWrapper 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; @@ -444,8 +446,8 @@ public static byte GetNextFreeControllerId() if (usedIds.Count >= byte.MaxValue + 1) { HashSetPool.Shared.Return(usedIds); - Log.Warn("[Speaker] All controller IDs are in use. Audio may conflict!"); - return 0; + Log.Warn("[Speaker] All controller IDs are in use. Default Controll Id will be use, Audio may conflict!"); + return DefaultControllerId; } while (usedIds.Contains(id)) From 55a8a838e9f3740fe1fe35ed8c74656eaf8cfd7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 8 Mar 2026 20:19:16 +0300 Subject: [PATCH 035/102] izabel --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 65da247f7..a21d44e09 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -322,7 +322,7 @@ public byte ControllerId /// 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(Transform parent = null, Vector3? position = null, float volume = 1f, bool isSpatial = true, float minDistance = 1f, float maxDistance = 15f, byte? controllerId = null, bool spawn = true) + 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, parent)) { From a476bb898d2a766aa013325655e13c7411094ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 9 Mar 2026 01:08:40 +0300 Subject: [PATCH 036/102] Release controller ID on pool return to prevent ID exhaustion --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index a21d44e09..8980921bc 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -544,6 +544,9 @@ public void ReturnToPool() if (MaxDistance != DefaultMaxDistance) MaxDistance = DefaultMaxDistance; + if (ControllerId != DefaultControllerId) + ControllerId = DefaultControllerId; + IsStatic = true; Loop = false; From c4f2290c31f76362959c892b186ab44bf9ce1a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 15 Mar 2026 18:47:46 +0300 Subject: [PATCH 037/102] remove network chechks --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 35 +++++++--------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 8980921bc..0243bf7f9 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -396,21 +396,14 @@ public static Speaker Rent(Vector3 position, Transform parent = null) /// 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) + public static bool PlayFromPool(string path, Vector3 position, Transform parent = null, bool isSpatial = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, 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 != 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.Volume = volume; + speaker.IsSpatial = isSpatial; + speaker.MinDistance = minDistance; + speaker.MaxDistance = maxDistance; speaker.Pitch = pitch; speaker.PlayMode = playMode; @@ -532,20 +525,12 @@ public void ReturnToPool() Position = SpeakerParkPosition; - if (Volume != DefaultVolume) - Volume = DefaultVolume; - - if (IsSpatial != DefaultSpatial) - IsSpatial = DefaultSpatial; - - if (MinDistance != DefaultMinDistance) - MinDistance = DefaultMinDistance; - - if (MaxDistance != DefaultMaxDistance) - MaxDistance = DefaultMaxDistance; + Volume = DefaultVolume; - if (ControllerId != DefaultControllerId) - ControllerId = DefaultControllerId; + IsSpatial = DefaultSpatial; + MinDistance = DefaultMinDistance; + MaxDistance = DefaultMaxDistance; + ControllerId = DefaultControllerId; IsStatic = true; From 0c4f76cbb7db3e2e3ebe43c90caf874d85a37394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 16 Mar 2026 23:33:45 +0300 Subject: [PATCH 038/102] for another pr --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 0243bf7f9..e0689b9a4 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -331,10 +331,9 @@ public static Speaker Create(Transform parent = null, Vector3? position = null, MinDistance = minDistance, MaxDistance = maxDistance, ControllerId = controllerId ?? GetNextFreeControllerId(), + Position = position ?? Vector3.zero, }; - speaker.Transform.localPosition = position ?? Vector3.zero; - if (spawn) speaker.Spawn(); @@ -372,7 +371,7 @@ public static Speaker Rent(Vector3 position, Transform parent = null) if (parent != null) speaker.Transform.SetParent(parent); - speaker.Transform.localPosition = position; + speaker.Position = position; speaker.ControllerId = GetNextFreeControllerId(); } From 1bfe67f32f5b488ab6727853e6d0732459d05f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Tue, 17 Mar 2026 00:44:53 +0300 Subject: [PATCH 039/102] locals --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index e0689b9a4..913cbb9b7 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -331,7 +331,7 @@ public static Speaker Create(Transform parent = null, Vector3? position = null, MinDistance = minDistance, MaxDistance = maxDistance, ControllerId = controllerId ?? GetNextFreeControllerId(), - Position = position ?? Vector3.zero, + LocalPosition = position ?? Vector3.zero, }; if (spawn) @@ -362,7 +362,7 @@ public static Speaker Rent(Vector3 position, Transform parent = null) if (speaker == null) { - speaker = Create(parent: parent, position: position, spawn: true); + speaker = Create(parent, position, spawn: true); } else { @@ -371,7 +371,7 @@ public static Speaker Rent(Vector3 position, Transform parent = null) if (parent != null) speaker.Transform.SetParent(parent); - speaker.Position = position; + speaker.LocalPosition = position; speaker.ControllerId = GetNextFreeControllerId(); } @@ -382,7 +382,7 @@ public static Speaker Rent(Vector3 position, Transform parent = null) /// 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.) /// /// The path to the wav file. - /// The position of the speaker. + /// The local position of the speaker. /// The parent transform, if any. /// Whether the audio source is spatialized. /// The volume level of the audio source. @@ -522,7 +522,7 @@ public void ReturnToPool() Base.RpcChangeParent(0); } - Position = SpeakerParkPosition; + LocalPosition = SpeakerParkPosition; Volume = DefaultVolume; From 370ec83f0a4a08a9e2801d282ac88d61fe119f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Thu, 19 Mar 2026 00:25:50 +0300 Subject: [PATCH 040/102] fix: prevent controller ID conflicts by removing pooled speakers from AllInstances --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 913cbb9b7..517a04202 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -362,17 +362,18 @@ public static Speaker Rent(Vector3 position, Transform parent = null) if (speaker == null) { - speaker = Create(parent, position, spawn: true); + speaker = Create(parent, position); } else { speaker.IsStatic = false; if (parent != null) - speaker.Transform.SetParent(parent); + speaker.Transform.parent = parent; speaker.LocalPosition = position; speaker.ControllerId = GetNextFreeControllerId(); + SpeakerToyPlaybackBase.AllInstances.Add(speaker.Base.Playback); } return speaker; @@ -529,7 +530,6 @@ public void ReturnToPool() IsSpatial = DefaultSpatial; MinDistance = DefaultMinDistance; MaxDistance = DefaultMaxDistance; - ControllerId = DefaultControllerId; IsStatic = true; @@ -549,6 +549,8 @@ public void ReturnToPool() resampleBufferFilled = 0; isPitchDefault = true; + SpeakerToyPlaybackBase.AllInstances.Remove(Base.Playback); + Pool.Enqueue(this); } From a7c7bc7a4957c07f1b350b5430cd1fa82a1cfd30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Thu, 19 Mar 2026 00:37:22 +0300 Subject: [PATCH 041/102] s --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 517a04202..ff128e44a 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -519,7 +519,7 @@ public void ReturnToPool() if (Transform.parent != null || AdminToyBase._clientParentId != 0) { - Transform.SetParent(null); + Transform.parent = null; Base.RpcChangeParent(0); } From 47f9a41724f58684931a9fa5dd9f1eff6e37252f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Thu, 19 Mar 2026 16:54:20 +0300 Subject: [PATCH 042/102] base check for return pool --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index ff128e44a..28a7933cb 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -515,6 +515,9 @@ public void Stop() /// public void ReturnToPool() { + if (Base == null) + return; + Stop(); if (Transform.parent != null || AdminToyBase._clientParentId != 0) From 040d690780de32b2bc5450d8ec69ff7cd7c7fea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Fri, 20 Mar 2026 18:53:40 +0300 Subject: [PATCH 043/102] ffff --- .../Features/Audio/PreloadedPcmSource.cs | 9 +---- EXILED/Exiled.API/Features/Toys/AdminToy.cs | 28 ++++++++++++- EXILED/Exiled.API/Features/Toys/Speaker.cs | 39 ++++++++++++++++++- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs index 7be9d09a3..160f59b5c 100644 --- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs @@ -88,14 +88,7 @@ public int Read(float[] buffer, int offset, int count) public void Seek(double seconds) { long targetIndex = (long)(seconds * VoiceChatSettings.SampleRate); - - if (targetIndex < 0) - targetIndex = 0; - - if (targetIndex > data.Length) - targetIndex = data.Length; - - pos = (int)targetIndex; + pos = (int)Math.Max(0, Math.Min(targetIndex, data.Length)); } /// diff --git a/EXILED/Exiled.API/Features/Toys/AdminToy.cs b/EXILED/Exiled.API/Features/Toys/AdminToy.cs index c93cee110..c0dbf9e3a 100644 --- a/EXILED/Exiled.API/Features/Toys/AdminToy.cs +++ b/EXILED/Exiled.API/Features/Toys/AdminToy.cs @@ -101,7 +101,33 @@ public Quaternion Rotation } /// - /// Gets or sets the scale of the toy. + /// Gets or sets the local position of the toy relative to its parent. + /// + public Vector3 LocalPosition + { + get => Transform.localPosition; + set + { + Transform.localPosition = value; + AdminToyBase.NetworkPosition = value; + } + } + + /// + /// Gets or sets the local rotation of the toy relative to its parent. + /// + public Quaternion LocalRotation + { + get => Transform.localRotation; + set + { + Transform.localRotation = value; + AdminToyBase.NetworkRotation = value; + } + } + + /// + /// Gets or sets the local scale of the toy. /// public Vector3 Scale { diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 28a7933cb..406769c5d 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -70,6 +70,9 @@ public class Speaker : AdminToy, IWrapper private OpusEncoder encoder; private CoroutineHandle playBackRoutine; + private int idChangeFrame; + private bool needsSyncWait = false; + private bool isPitchDefault = true; private bool isPlayBackInitialized = false; @@ -307,7 +310,15 @@ public float MinDistance public byte ControllerId { get => Base.NetworkControllerId; - set => Base.NetworkControllerId = value; + set + { + if (Base.NetworkControllerId != value) + { + Base.NetworkControllerId = value; + needsSyncWait = true; + idChangeFrame = Time.frameCount; + } + } } /// @@ -475,6 +486,7 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo } TryInitializePlayBack(); + ResetEncoder(); Stop(); Loop = loop; @@ -569,11 +581,27 @@ private void TryInitializePlayBack() encoder = new(OpusApplicationType.Audio); encoded = new byte[VoiceChatSettings.MaxEncodedSize]; + // 3002 => OPUS_SIGNAL_MUSIC (https://github.com/xiph/opus/blob/2d862ea14b233e5a3f3afaf74d96050691af3cd5/include/opus_defines.h#L229) + OpusWrapper.SetEncoderSetting(encoder._handle, OpusCtlSetRequest.Signal, 3002); + AdminToyBase.OnRemoved += OnToyRemoved; } private IEnumerator PlayBackCoroutine() { + if (needsSyncWait) + { + int framesPassed = Time.frameCount - idChangeFrame; + + while (framesPassed < 2) + { + yield return Timing.WaitForOneFrame; + framesPassed = Time.frameCount - idChangeFrame; + } + + needsSyncWait = false; + } + OnPlaybackStarted?.Invoke(); resampleTime = 0.0; @@ -754,6 +782,15 @@ private void ResampleFrame() } } + private void ResetEncoder() + { + if (encoder != null && encoder._handle != IntPtr.Zero) + { + // 4028 => OPUS_RESET_STATE (https://github.com/xiph/opus/blob/2d862ea14b233e5a3f3afaf74d96050691af3cd5/include/opus_defines.h#L710) + OpusWrapper.SetEncoderSetting(encoder._handle, (OpusCtlSetRequest)4028, 0); + } + } + private void OnToyRemoved(AdminToyBase toy) { if (toy != Base) From db12dc5188da7333ab65a42910ff19b264d8c0f5 Mon Sep 17 00:00:00 2001 From: MS-crew <100300664+MS-crew@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:57:02 +0300 Subject: [PATCH 044/102] NOT MY CHANGES --- EXILED/Exiled.API/Features/Warhead.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Warhead.cs b/EXILED/Exiled.API/Features/Warhead.cs index 3f1e12c51..d26cf33e8 100644 --- a/EXILED/Exiled.API/Features/Warhead.cs +++ b/EXILED/Exiled.API/Features/Warhead.cs @@ -127,6 +127,11 @@ public static WarheadStatus Status /// public static bool IsInProgress => Controller.Info.InProgress; + /// + /// Gets a value indicating whether the warhead detonation is on cooldown. + /// + public static bool IsOnCooldown => Controller.CooldownEndTime > NetworkTime.time; + /// /// Gets or sets the warhead detonation timer. /// @@ -162,7 +167,7 @@ public static int Kills /// /// Gets a value indicating whether the warhead can be started. /// - public static bool CanBeStarted => !IsInProgress && !IsDetonated && Controller.CooldownEndTime <= NetworkTime.time; + public static bool CanBeStarted => !IsInProgress && !IsDetonated && !IsOnCooldown; /// /// Closes the surface blast doors. From 0db8c88050c967c89b1987311c60fde5d8d2b418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 21 Mar 2026 13:19:51 +0300 Subject: [PATCH 045/102] Added Fade Volume Method, Time left property & setter for PlaybackProgress --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 53 +++++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 406769c5d..5c6d9c967 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -68,6 +68,7 @@ public class Speaker : AdminToy, IWrapper private IPcmSource source; private OpusEncoder encoder; + private CoroutineHandle fadeRoutine; private CoroutineHandle playBackRoutine; private int idChangeFrame; @@ -220,10 +221,23 @@ public double CurrentTime public double TotalDuration => source?.TotalDuration ?? 0.0; /// - /// Gets the current playback progress as a value between 0.0 and 1.0. + /// Gets the remaining playback time in seconds. + /// + public double TimeLeft => Math.Max(0.0, TotalDuration - CurrentTime); + + /// + /// Gets or sets 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; + public float PlaybackProgress + { + get => TotalDuration > 0.0 ? (float)(CurrentTime / TotalDuration) : 0f; + set + { + if (TotalDuration > 0.0) + CurrentTime = TotalDuration * Mathf.Clamp01(value); + } + } /// /// Gets the path to the last audio file played on this speaker. @@ -507,6 +521,21 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo return true; } + /// + /// Fades the volume to a specific target over a given duration. + /// + /// The initial volume level when the fade begins. + /// The final volume level to reach at the end of the fade. + /// The time in seconds the fading process should take to complete. + /// If set to true, the playback will automatically once the is reached. Perfect for fade-outs. + public void FadeVolume(float startVolume, float targetVolume, float duration = 3, bool stopAfterFade = false) + { + if (fadeRoutine.IsRunning) + fadeRoutine.IsRunning = false; + + fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, stopAfterFade).CancelWith(GameObject)); + } + /// /// Stops playback. /// @@ -518,6 +547,9 @@ public void Stop() OnPlaybackStopped?.Invoke(); } + if (fadeRoutine.IsRunning) + fadeRoutine.IsRunning = false; + source?.Dispose(); source = null; } @@ -660,6 +692,23 @@ private IEnumerator PlayBackCoroutine() } } + private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, bool stopAfterFade) + { + float timePassed = 0f; + + while (timePassed < duration) + { + timePassed += Time.deltaTime; + Volume = Mathf.Lerp(startVolume, targetVolume, timePassed / duration); + yield return Timing.WaitForOneFrame; + } + + Volume = targetVolume; + + if (stopAfterFade) + Stop(); + } + private void SendPacket(int len) { AudioMessage msg = new(ControllerId, encoded, len); From 28c072ac50b6905ed867eb17b0550495511f37c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 21 Mar 2026 13:53:29 +0300 Subject: [PATCH 046/102] fade in for play --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 5c6d9c967..63140d15f 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -484,8 +484,9 @@ public static byte GetNextFreeControllerId() /// Whether to stream the audio or preload it. /// Whether to destroy the speaker after playback. /// Whether to loop the audio. + /// The duration in seconds over which the volume should smoothly increase from 0 to speaker volume. 0 means no fade in. /// 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) + public bool Play(string path, bool stream = false, bool destroyAfter = false, bool loop = false, float fadeInDuration = 0f) { if (!File.Exists(path)) { @@ -517,6 +518,13 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo return false; } + if (fadeInDuration > 0f) + { + float targetVol = Volume; + Volume = 0f; + FadeVolume(0f, targetVol, fadeInDuration); + } + playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); return true; } From b512bf1790e3219f6e9f8bb8852feafc60deaa4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 21 Mar 2026 14:38:33 +0300 Subject: [PATCH 047/102] TrackData & fade in for Play from pool --- .../Features/Audio/PreloadedPcmSource.cs | 10 ++- EXILED/Exiled.API/Features/Audio/TrackData.cs | 65 ++++++++++++++++ .../Features/Audio/WavStreamSource.cs | 7 +- .../Exiled.API/Features/Audio/WavUtility.cs | 75 ++++++++++++++++--- EXILED/Exiled.API/Features/Toys/Speaker.cs | 16 +++- EXILED/Exiled.API/Interfaces/IPcmSource.cs | 7 ++ 6 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 EXILED/Exiled.API/Features/Audio/TrackData.cs diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs index 160f59b5c..3ee7a9992 100644 --- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs @@ -34,7 +34,9 @@ public sealed class PreloadedPcmSource : IPcmSource /// The path to the audio file. public PreloadedPcmSource(string path) { - data = WavUtility.WavToPcm(path); + (float[] PcmData, TrackData TrackInfo) result = WavUtility.WavToPcm(path); + data = result.PcmData; + TrackInfo = result.TrackInfo; } /// @@ -44,8 +46,14 @@ public PreloadedPcmSource(string path) public PreloadedPcmSource(float[] pcmData) { data = pcmData; + TrackInfo = new TrackData { Duration = TotalDuration }; } + /// + /// Gets the metadata of the loaded track. + /// + public TrackData TrackInfo { get; } + /// /// Gets a value indicating whether the end of the PCM data buffer has been reached. /// diff --git a/EXILED/Exiled.API/Features/Audio/TrackData.cs b/EXILED/Exiled.API/Features/Audio/TrackData.cs new file mode 100644 index 000000000..8711d509f --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/TrackData.cs @@ -0,0 +1,65 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + + /// + /// Contains metadata about a audio track. + /// + public struct TrackData + { + /// + /// Gets the title of the track, if available in the metadata. + /// + public string Title { get; internal set; } + + /// + /// Gets the artist of the track, if available in the metadata. + /// + public string Artist { get; internal set; } + + /// + /// Gets the total duration of the track in seconds. + /// + public double Duration { get; internal set; } + + /// + /// Gets a value indicating whether the track data is completely empty. + /// + public readonly bool IsEmpty => string.IsNullOrEmpty(Title) && string.IsNullOrEmpty(Artist) && Duration <= 0; + + /// + /// Gets a formatted display name for the track. + /// + public string DisplayName + { + get + { + if (!string.IsNullOrEmpty(Artist) && !string.IsNullOrEmpty(Title)) + return $"{Artist} - {Title}"; + if (!string.IsNullOrEmpty(Title)) + return Title; + + return "Unknown Track"; + } + } + + /// + /// Gets the duration formatted as a digital clock string. + /// + public readonly string FormattedDuration + { + get + { + TimeSpan t = TimeSpan.FromSeconds(Duration); + return t.Hours > 0 ? t.ToString(@"hh\:mm\:ss") : t.ToString(@"mm\:ss"); + } + } + } +} diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs index d910093f1..47a77772a 100644 --- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs +++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs @@ -36,12 +36,17 @@ public sealed class WavStreamSource : IPcmSource public WavStreamSource(string path) { stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.SequentialScan); - WavUtility.SkipHeader(stream); + TrackInfo = WavUtility.SkipHeader(stream); startPosition = stream.Position; endPosition = stream.Length; internalBuffer = ArrayPool.Shared.Rent(VoiceChatSettings.PacketSizePerChannel * 2); } + /// + /// Gets the metadata of the streaming track. + /// + public TrackData TrackInfo { get; } + /// /// Gets the total duration of the audio in seconds. /// diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index 12ea4d32d..3b9db3c91 100644 --- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -26,8 +26,8 @@ public static class WavUtility /// Converts a WAV file at the specified path to a PCM float array. /// /// The file path of the WAV file to convert. - /// An array of floats representing the PCM data. - public static float[] WavToPcm(string path) + /// A tuple containing an array of floats representing the PCM data and its TrackData. + public static (float[] PcmData, TrackData TrackInfo) WavToPcm(string path) { using FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.Read); int length = (int)fs.Length; @@ -40,7 +40,7 @@ public static float[] WavToPcm(string path) using MemoryStream ms = new(rentedBuffer, 0, bytesRead); - SkipHeader(ms); + TrackData metaData = SkipHeader(ms); int headerOffset = (int)ms.Position; int dataLength = bytesRead - headerOffset; @@ -53,7 +53,7 @@ public static float[] WavToPcm(string path) for (int i = 0; i < samples.Length; i++) pcm[i] = samples[i] * Divide; - return pcm; + return (pcm, metaData); } finally { @@ -65,11 +65,18 @@ public static float[] WavToPcm(string path) /// Skips the WAV file header and validates that the format is PCM16 mono with the specified sample rate. /// /// The to read from. - public static void SkipHeader(Stream stream) + /// A struct containing the parsed file information. + public static TrackData SkipHeader(Stream stream) { + TrackData trackData = new(); + Span headerBuffer = stackalloc byte[12]; stream.Read(headerBuffer); + int rate = 0; + int bits = 0; + int channels = 0; + Span chunkHeader = stackalloc byte[8]; while (true) { @@ -87,9 +94,9 @@ public static void SkipHeader(Stream stream) stream.Read(fmtData); short format = BinaryPrimitives.ReadInt16LittleEndian(fmtData[..2]); - short channels = BinaryPrimitives.ReadInt16LittleEndian(fmtData.Slice(2, 2)); - int rate = BinaryPrimitives.ReadInt32LittleEndian(fmtData.Slice(4, 4)); - short bits = BinaryPrimitives.ReadInt16LittleEndian(fmtData.Slice(14, 2)); + channels = BinaryPrimitives.ReadInt16LittleEndian(fmtData.Slice(2, 2)); + rate = BinaryPrimitives.ReadInt32LittleEndian(fmtData.Slice(4, 4)); + bits = BinaryPrimitives.ReadInt16LittleEndian(fmtData.Slice(14, 2)); if (format != 1 || channels != 1 || rate != VoiceChatSettings.SampleRate || bits != 16) { @@ -101,10 +108,58 @@ public static void SkipHeader(Stream stream) stream.Seek(chunkSize - 16, SeekOrigin.Current); } + // 'LIST' chunk + else if (chunkId == 0x5453494C) + { + Span listType = stackalloc byte[4]; + stream.Read(listType); + uint type = BinaryPrimitives.ReadUInt32LittleEndian(listType); + + // 'INFO' chunk + if (type == 0x4F464E49) + { + int bytesToRead = chunkSize - 4; + byte[] infoBytes = ArrayPool.Shared.Rent(bytesToRead); + stream.Read(infoBytes, 0, bytesToRead); + + int offset = 0; + while (offset < bytesToRead - 8) + { + uint infoId = BinaryPrimitives.ReadUInt32LittleEndian(infoBytes.AsSpan(offset, 4)); + int infoSize = BinaryPrimitives.ReadInt32LittleEndian(infoBytes.AsSpan(offset + 4, 4)); + offset += 8; + + if (infoSize > 0 && offset + infoSize <= bytesToRead) + { + string value = System.Text.Encoding.UTF8.GetString(infoBytes, offset, infoSize).TrimEnd('\0'); + + if (infoId == 0x4D414E49) + trackData.Title = value; + else if (infoId == 0x54524149) + trackData.Artist = value; + } + + offset += infoSize; + if (infoSize % 2 != 0) + offset++; + } + + ArrayPool.Shared.Return(infoBytes); + } + else + { + stream.Seek(chunkSize - 4, SeekOrigin.Current); + } + } + // 'data' chunk else if (chunkId == 0x61746164) { - return; + int bytesPerSample = bits / 8; + if (bytesPerSample > 0 && channels > 0 && rate > 0) + trackData.Duration = (double)chunkSize / (rate * channels * bytesPerSample); + + return trackData; } else { @@ -117,6 +172,8 @@ public static void SkipHeader(Stream stream) throw new InvalidDataException("Missing 'data' chunk in WAV file."); } } + + return trackData; } } } \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 63140d15f..ec8e212f4 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -244,6 +244,11 @@ public float PlaybackProgress /// public string LastTrack { get; private set; } + /// + /// Gets the metadata information (Title, Artist, Duration) of the last played audio track. + /// + public TrackData LastTrackInfo { get; private set; } + /// /// Gets or sets the playback pitch. /// @@ -415,13 +420,14 @@ public static Speaker Rent(Vector3 position, Transform parent = null) /// 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 duration in seconds over which the volume should smoothly increase from 0 to speaker volume. 0 means no fade in. /// 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 = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) + public static bool PlayFromPool(string path, Vector3 position, Transform parent = null, bool isSpatial = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, float fadeInDuration = 0f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) { Speaker speaker = Rent(position, parent); @@ -438,7 +444,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent speaker.ReturnToPoolAfter = true; - if (!speaker.Play(path, stream)) + if (!speaker.Play(path, stream, fadeInDuration: fadeInDuration)) { speaker.ReturnToPool(); return false; @@ -505,7 +511,6 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo Stop(); Loop = loop; - LastTrack = path; DestroyAfter = destroyAfter; try @@ -518,6 +523,9 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo return false; } + LastTrack = path; + LastTrackInfo = source.TrackInfo; + if (fadeInDuration > 0f) { float targetVol = Volume; @@ -595,6 +603,8 @@ public void ReturnToPool() Channel = Channels.ReliableOrdered2; LastTrack = null; + LastTrackInfo = default; + Predicate = null; TargetPlayer = null; TargetPlayers = null; diff --git a/EXILED/Exiled.API/Interfaces/IPcmSource.cs b/EXILED/Exiled.API/Interfaces/IPcmSource.cs index 680f56841..5e86f1c60 100644 --- a/EXILED/Exiled.API/Interfaces/IPcmSource.cs +++ b/EXILED/Exiled.API/Interfaces/IPcmSource.cs @@ -9,6 +9,8 @@ namespace Exiled.API.Interfaces { using System; + using Exiled.API.Features.Audio; + /// /// Represents a source of PCM audio data. /// @@ -29,6 +31,11 @@ public interface IPcmSource : IDisposable /// double CurrentTime { get; set; } + /// + /// Gets the metadata of the streaming track. + /// + TrackData TrackInfo { get; } + /// /// Reads a sequence of PCM samples into the specified buffer. /// From f69c9731ed0c4a0dfe09dc8801ae000268e66ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 21 Mar 2026 15:07:54 +0300 Subject: [PATCH 048/102] encoder clean for current time setter --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index ec8e212f4..e0eb3a9c9 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -211,6 +211,8 @@ public double CurrentTime source.CurrentTime = value; resampleTime = 0.0; resampleBufferFilled = 0; + + ResetEncoder(); } } From 464ec652a7eae878fc804028258fc6c38e1b8803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 21 Mar 2026 19:04:14 +0300 Subject: [PATCH 049/102] Queue Track --- EXILED/Exiled.API/Features/Audio/TrackData.cs | 9 ++ .../Features/Audio/WavStreamSource.cs | 12 +- .../Exiled.API/Features/Audio/WavUtility.cs | 1 + EXILED/Exiled.API/Features/Toys/Speaker.cs | 122 ++++++++++++++++-- 4 files changed, 123 insertions(+), 21 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/TrackData.cs b/EXILED/Exiled.API/Features/Audio/TrackData.cs index 8711d509f..d169f944f 100644 --- a/EXILED/Exiled.API/Features/Audio/TrackData.cs +++ b/EXILED/Exiled.API/Features/Audio/TrackData.cs @@ -29,6 +29,11 @@ public struct TrackData /// public double Duration { get; internal set; } + /// + /// Gets the file path of the track. + /// + public string Path { get; internal set; } + /// /// Gets a value indicating whether the track data is completely empty. /// @@ -43,9 +48,13 @@ public string DisplayName { if (!string.IsNullOrEmpty(Artist) && !string.IsNullOrEmpty(Title)) return $"{Artist} - {Title}"; + if (!string.IsNullOrEmpty(Title)) return Title; + if (!string.IsNullOrEmpty(Path)) + return System.IO.Path.GetFileNameWithoutExtension(Path); + return "Unknown Track"; } } diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs index 47a77772a..f113664e7 100644 --- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs +++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs @@ -12,6 +12,8 @@ namespace Exiled.API.Features.Audio using System.IO; using System.Runtime.InteropServices; + using Christmas.Scp2536.Gifts; + using Exiled.API.Interfaces; using VoiceChat; @@ -109,15 +111,7 @@ public int Read(float[] buffer, int offset, int count) /// The position in seconds to seek to. public void Seek(double seconds) { - long targetSample = (long)(seconds * VoiceChatSettings.SampleRate); - long targetByte = targetSample * 2; - - long newPos = startPosition + targetByte; - if (newPos > endPosition) - newPos = endPosition; - - if (newPos < startPosition) - newPos = startPosition; + long newPos = Math.Clamp(startPosition + ((long)(seconds * VoiceChatSettings.SampleRate) * 2), startPosition, endPosition); if (newPos % 2 != 0) newPos--; diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index 3b9db3c91..617b08e56 100644 --- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -53,6 +53,7 @@ public static (float[] PcmData, TrackData TrackInfo) WavToPcm(string path) for (int i = 0; i < samples.Length; i++) pcm[i] = samples[i] * Divide; + metaData.Path = path; return (pcm, metaData); } finally diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index e0eb3a9c9..5a1b0d84d 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -108,7 +108,7 @@ internal Speaker(SpeakerToy speakerToy) /// Invoked when the audio track finishes playing. /// If looping is enabled, this triggers every time the track finished. /// - public event Action OnPlaybackFinished; + public event Action OnPlaybackFinished; /// /// Invoked when the audio playback stops completely (either manually or end of file). @@ -242,14 +242,14 @@ public float PlaybackProgress } /// - /// Gets the path to the last audio file played on this speaker. + /// Gets the metadata information (Title, Artist, Duration) of the last played audio track. /// - public string LastTrack { get; private set; } + public TrackData LastTrackInfo { get; private set; } /// - /// Gets the metadata information (Title, Artist, Duration) of the last played audio track. + /// Gets the queue of audio file paths to be played sequentially after the current track finishes. /// - public TrackData LastTrackInfo { get; private set; } + public List TrackQueue { get; } = new(); /// /// Gets or sets the playback pitch. @@ -509,8 +509,7 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo } TryInitializePlayBack(); - ResetEncoder(); - Stop(); + Stop(clearQueue: false); Loop = loop; DestroyAfter = destroyAfter; @@ -525,7 +524,6 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo return false; } - LastTrack = path; LastTrackInfo = source.TrackInfo; if (fadeInDuration > 0f) @@ -554,11 +552,64 @@ public void FadeVolume(float startVolume, float targetVolume, float duration = 3 fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, stopAfterFade).CancelWith(GameObject)); } + /// + /// Adds an audio file to the playback queue. If nothing is currently playing, playback starts immediately. + /// + /// The path to the wav file to enqueue. + /// true if the file was successfully queued or playback started; otherwise, false. + public bool QueueTrack(string path) + { + if (!playBackRoutine.IsRunning && !IsPaused) + return Play(path); + + TrackQueue.Add(path); + return true; + } + + /// + /// Skips the currently playing track and starts playing the next one in the queue. + /// + public void SkipTrack() + { + if (TrySwitchToNextTrack()) + { + IsPaused = false; + + if (!playBackRoutine.IsRunning) + playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); + } + else + { + Stop(); + } + } + + /// + /// Removes a specific track from the queue by its file path. + /// + /// The exact file path of the track to remove. + /// If true, removes the first occurrence; if false, removes the last occurrence. + /// true if the track was successfully found and removed; otherwise, false. + public bool RemoveFromQueue(string path, bool findFirst = true) + { + int index = findFirst ? TrackQueue.IndexOf(path) : TrackQueue.LastIndexOf(path); + + if (index == -1) + return false; + + TrackQueue.RemoveAt(index); + return true; + } + /// /// Stops playback. /// - public void Stop() + /// If true, clears the upcoming tracks in the playlist. + public void Stop(bool clearQueue = true) { + if (!isPlayBackInitialized) + return; + if (playBackRoutine.IsRunning) { playBackRoutine.IsRunning = false; @@ -568,6 +619,10 @@ public void Stop() if (fadeRoutine.IsRunning) fadeRoutine.IsRunning = false; + if (clearQueue) + TrackQueue.Clear(); + + ResetEncoder(); source?.Dispose(); source = null; } @@ -604,7 +659,6 @@ public void ReturnToPool() PlayMode = SpeakerPlayMode.Global; Channel = Channels.ReliableOrdered2; - LastTrack = null; LastTrackInfo = default; Predicate = null; @@ -615,6 +669,7 @@ public void ReturnToPool() resampleTime = 0.0; resampleBufferFilled = 0; isPitchDefault = true; + needsSyncWait = false; SpeakerToyPlaybackBase.AllInstances.Remove(Base.Playback); @@ -639,6 +694,40 @@ private void TryInitializePlayBack() AdminToyBase.OnRemoved += OnToyRemoved; } + private bool TrySwitchToNextTrack() + { + while (TrackQueue.Count > 0) + { + string nextTrack = TrackQueue[0]; + TrackQueue.RemoveAt(0); + + IPcmSource newSource; + try + { + bool useStream = source is WavStreamSource; + newSource = useStream ? new WavStreamSource(nextTrack) : new PreloadedPcmSource(nextTrack); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Playlist next track failed: '{nextTrack}'.\n{ex}"); + continue; + } + + source?.Dispose(); + source = newSource; + LastTrackInfo = source.TrackInfo; + + ResetEncoder(); + resampleTime = 0.0; + resampleBufferFilled = 0; + + OnPlaybackStarted?.Invoke(); + return true; + } + + return false; + } + private IEnumerator PlayBackCoroutine() { if (needsSyncWait) @@ -688,13 +777,22 @@ private IEnumerator PlayBackCoroutine() if (!source.Ended) continue; - OnPlaybackFinished?.Invoke(LastTrack); + OnPlaybackFinished?.Invoke(); if (Loop) { + ResetEncoder(); source.Reset(); - OnPlaybackLooped?.Invoke(); + timeAccumulator = 0; resampleTime = resampleBufferFilled = 0; + + OnPlaybackLooped?.Invoke(); + continue; + } + + if (TrySwitchToNextTrack()) + { + timeAccumulator = 0f; continue; } From 9f42fb33906d40655597c2cd00c51da024beee68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 21 Mar 2026 19:33:25 +0300 Subject: [PATCH 050/102] added Static Events --- .../Features/Audio/SpeakerEvents.cs | 85 +++++++++++++++++++ EXILED/Exiled.API/Features/Toys/Speaker.cs | 27 ++++-- 2 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs diff --git a/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs new file mode 100644 index 000000000..7b9496ff5 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs @@ -0,0 +1,85 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + + using Toys; + + /// + /// Contains global event handlers related to the audio system. + /// + public static class SpeakerEvents + { + /// + /// Invoked when a speaker starts playing an audio track. + /// + public static event Action PlaybackStarted; + + /// + /// Invoked when the audio playback of a speaker is paused. + /// + public static event Action PlaybackPaused; + + /// + /// Invoked when the audio playback of a speaker is resumed from a paused state. + /// + public static event Action PlaybackResumed; + + /// + /// Invoked when the audio playback of a speaker loops back to the beginning. + /// + public static event Action PlaybackLooped; + + /// + /// Invoked when a speaker finishes playing its current audio track. + /// + public static event Action PlaybackFinished; + + /// + /// Invoked when a speaker's audio playback is completely stopped. + /// + public static event Action PlaybackStopped; + + /// + /// Called when a speaker starts playing an audio track. + /// + /// The instance. + internal static void OnPlaybackStarted(Speaker speaker) => PlaybackStarted?.Invoke(speaker); + + /// + /// Called when the audio playback of a speaker is paused. + /// + /// The instance. + internal static void OnPlaybackPaused(Speaker speaker) => PlaybackPaused?.Invoke(speaker); + + /// + /// Called when the audio playback of a speaker is resumed from a paused state. + /// + /// The instance. + internal static void OnPlaybackResumed(Speaker speaker) => PlaybackResumed?.Invoke(speaker); + + /// + /// Called when the audio playback of a speaker loops back to the beginning. + /// + /// The instance. + internal static void OnPlaybackLooped(Speaker speaker) => PlaybackLooped?.Invoke(speaker); + + /// + /// Called when a speaker finishes playing its current audio track. + /// + /// The instance. + internal static void OnPlaybackFinished(Speaker speaker) => PlaybackFinished?.Invoke(speaker); + + /// + /// Called when a speaker's audio playback is completely stopped. + /// + /// The instance. + internal static void OnPlaybackStopped(Speaker speaker) => PlaybackStopped?.Invoke(speaker); + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 5a1b0d84d..261be4a76 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -190,9 +190,15 @@ public bool IsPaused playBackRoutine.IsAliveAndPaused = value; if (value) + { OnPlaybackPaused?.Invoke(); + SpeakerEvents.OnPlaybackPaused(this); + } else + { OnPlaybackResumed?.Invoke(); + SpeakerEvents.OnPlaybackResumed(this); + } } } @@ -486,15 +492,16 @@ public static byte GetNextFreeControllerId() } /// - /// Plays a wav file through this speaker.(File must be 16 bit, mono and 48khz.) + /// Plays a wav file through this speaker. (File must be 16-bit, mono, and 48kHz.) /// /// The path to the wav file. - /// Whether to stream the audio or preload it. - /// Whether to destroy the speaker after playback. - /// Whether to loop the audio. - /// The duration in seconds over which the volume should smoothly increase from 0 to speaker volume. 0 means no fade in. + /// Whether to stream the audio directly from the disk (true) or preload it entirely into RAM (false). + /// Whether to destroy the speaker object automatically after playback finishes. + /// Whether to loop the audio continuously. + /// The duration in seconds over which the volume should smoothly increase from 0 to the speaker's volume. 0 means no fade-in. + /// If true, clears any upcoming tracks in the playlist before playing the new track. /// 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, float fadeInDuration = 0f) + public bool Play(string path, bool stream = false, bool destroyAfter = false, bool loop = false, float fadeInDuration = 0f, bool clearQueue = false) { if (!File.Exists(path)) { @@ -509,7 +516,7 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo } TryInitializePlayBack(); - Stop(clearQueue: false); + Stop(clearQueue); Loop = loop; DestroyAfter = destroyAfter; @@ -613,7 +620,9 @@ public void Stop(bool clearQueue = true) if (playBackRoutine.IsRunning) { playBackRoutine.IsRunning = false; + OnPlaybackStopped?.Invoke(); + SpeakerEvents.OnPlaybackStopped(this); } if (fadeRoutine.IsRunning) @@ -722,6 +731,7 @@ private bool TrySwitchToNextTrack() resampleBufferFilled = 0; OnPlaybackStarted?.Invoke(); + SpeakerEvents.OnPlaybackStarted(this); return true; } @@ -744,6 +754,7 @@ private IEnumerator PlayBackCoroutine() } OnPlaybackStarted?.Invoke(); + SpeakerEvents.OnPlaybackStarted(this); resampleTime = 0.0; resampleBufferFilled = 0; @@ -778,6 +789,7 @@ private IEnumerator PlayBackCoroutine() continue; OnPlaybackFinished?.Invoke(); + SpeakerEvents.OnPlaybackFinished(this); if (Loop) { @@ -787,6 +799,7 @@ private IEnumerator PlayBackCoroutine() resampleTime = resampleBufferFilled = 0; OnPlaybackLooped?.Invoke(); + SpeakerEvents.OnPlaybackLooped(this); continue; } From d476c4a0dfbf6f6b807d43aedd0203050ed63cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 21 Mar 2026 21:43:32 +0300 Subject: [PATCH 051/102] Audio Time Events --- .../Features/Audio/AudioTimeEvent.cs | 45 ++++++++++++++++ EXILED/Exiled.API/Features/Toys/Speaker.cs | 51 ++++++++++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 EXILED/Exiled.API/Features/Audio/AudioTimeEvent.cs diff --git a/EXILED/Exiled.API/Features/Audio/AudioTimeEvent.cs b/EXILED/Exiled.API/Features/Audio/AudioTimeEvent.cs new file mode 100644 index 000000000..10aa8ed8c --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/AudioTimeEvent.cs @@ -0,0 +1,45 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + + /// + /// Represents a time-based event for audio playback. + /// + public readonly struct AudioTimeEvent : IComparable + { + /// + /// Initializes a new instance of the struct. + /// + /// The exact time in seconds to trigger the action. + /// The action to execute. + public AudioTimeEvent(double time, Action action) + { + Time = time; + Action = action; + } + + /// + /// Gets the specific time in seconds at which the event should trigger. + /// + public double Time { get; } + + /// + /// Gets the action to be invoked when the specified time is reached. + /// + public Action Action { get; } + + /// + /// Compares this instance to another based on their trigger times. + /// + /// The other to compare to. + /// A value that indicates the relative order of the events being compared. + public readonly int CompareTo(AudioTimeEvent other) => Time.CompareTo(other.Time); + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 261be4a76..6f483a09e 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -72,8 +72,8 @@ public class Speaker : AdminToy, IWrapper private CoroutineHandle playBackRoutine; private int idChangeFrame; + private int nextTimeEventIndex = 0; private bool needsSyncWait = false; - private bool isPitchDefault = true; private bool isPlayBackInitialized = false; @@ -219,6 +219,7 @@ public double CurrentTime resampleBufferFilled = 0; ResetEncoder(); + UpdateNextTimeEventIndex(); } } @@ -257,6 +258,11 @@ public float PlaybackProgress /// public List TrackQueue { get; } = new(); + /// + /// Gets the list of time-based events for the current audio track. + /// + public List TimeEvents { get; } = new(); + /// /// Gets or sets the playback pitch. /// @@ -608,6 +614,28 @@ public bool RemoveFromQueue(string path, bool findFirst = true) return true; } + /// + /// Adds an action to be executed at a specific time in seconds during the current playback. + /// + /// The exact time in seconds to trigger the action. + /// The action to invoke when the specified time is reached. + public void AddTimeEvent(double timeInSeconds, Action action) + { + TimeEvents.Add(new AudioTimeEvent(timeInSeconds, action)); + TimeEvents.Sort(); + + UpdateNextTimeEventIndex(); + } + + /// + /// Clears all time-based events for the current playback. + /// + public void ClearTimeEvents() + { + TimeEvents.Clear(); + nextTimeEventIndex = 0; + } + /// /// Stops playback. /// @@ -632,6 +660,7 @@ public void Stop(bool clearQueue = true) TrackQueue.Clear(); ResetEncoder(); + ClearTimeEvents(); source?.Dispose(); source = null; } @@ -727,6 +756,7 @@ private bool TrySwitchToNextTrack() LastTrackInfo = source.TrackInfo; ResetEncoder(); + ClearTimeEvents(); resampleTime = 0.0; resampleBufferFilled = 0; @@ -738,6 +768,17 @@ private bool TrySwitchToNextTrack() return false; } + private void UpdateNextTimeEventIndex() + { + nextTimeEventIndex = 0; + double current = CurrentTime; + + while (nextTimeEventIndex < TimeEvents.Count && TimeEvents[nextTimeEventIndex].Time <= current) + { + nextTimeEventIndex++; + } + } + private IEnumerator PlayBackCoroutine() { if (needsSyncWait) @@ -798,6 +839,8 @@ private IEnumerator PlayBackCoroutine() timeAccumulator = 0; resampleTime = resampleBufferFilled = 0; + nextTimeEventIndex = 0; + OnPlaybackLooped?.Invoke(); SpeakerEvents.OnPlaybackLooped(this); continue; @@ -819,6 +862,12 @@ private IEnumerator PlayBackCoroutine() yield break; } + while (nextTimeEventIndex < TimeEvents.Count && CurrentTime >= TimeEvents[nextTimeEventIndex].Time) + { + TimeEvents[nextTimeEventIndex].Action?.Invoke(); + nextTimeEventIndex++; + } + yield return Timing.WaitForOneFrame; } } From 2443a57ac3cd4c8b4fdc2f89fe91b3a5218d6c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 21 Mar 2026 21:51:55 +0300 Subject: [PATCH 052/102] fix Action errors break corrutine --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 6f483a09e..828096c6d 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -864,7 +864,15 @@ private IEnumerator PlayBackCoroutine() while (nextTimeEventIndex < TimeEvents.Count && CurrentTime >= TimeEvents[nextTimeEventIndex].Time) { - TimeEvents[nextTimeEventIndex].Action?.Invoke(); + try + { + TimeEvents[nextTimeEventIndex].Action?.Invoke(); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to execute scheduled time event at {TimeEvents[nextTimeEventIndex].Time:F2}s.\nException Details: {ex}"); + } + nextTimeEventIndex++; } From b0a91d317d6d75ee1a34fa6f0a4a0fe07c6365d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 21 Mar 2026 22:12:35 +0300 Subject: [PATCH 053/102] Action id --- .../Features/Audio/AudioTimeEvent.cs | 9 ++++- EXILED/Exiled.API/Features/Toys/Speaker.cs | 35 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/AudioTimeEvent.cs b/EXILED/Exiled.API/Features/Audio/AudioTimeEvent.cs index 10aa8ed8c..25cf3aebf 100644 --- a/EXILED/Exiled.API/Features/Audio/AudioTimeEvent.cs +++ b/EXILED/Exiled.API/Features/Audio/AudioTimeEvent.cs @@ -19,10 +19,12 @@ namespace Exiled.API.Features.Audio /// /// The exact time in seconds to trigger the action. /// The action to execute. - public AudioTimeEvent(double time, Action action) + /// /// The optional unique identifier for the event. If null, a random GUID will be generated automatically. + public AudioTimeEvent(double time, Action action, string id = null) { Time = time; Action = action; + Id = id ?? Guid.NewGuid().ToString(); } /// @@ -35,6 +37,11 @@ public AudioTimeEvent(double time, Action action) /// public Action Action { get; } + /// + /// Gets the unique identifier for this time event. + /// + public string Id { get; } + /// /// Compares this instance to another based on their trigger times. /// diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 828096c6d..dc25a26d3 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -616,15 +616,33 @@ public bool RemoveFromQueue(string path, bool findFirst = true) /// /// Adds an action to be executed at a specific time in seconds during the current playback. + /// WARNING: Heavy operations can cause audio interruptions. If you need to perform heavy operations, start a new Coroutine inside the action. /// /// The exact time in seconds to trigger the action. /// The action to invoke when the specified time is reached. - public void AddTimeEvent(double timeInSeconds, Action action) + /// An optional unique string identifier for this event. If not provided, a random GUID will be assigned. + /// The unique string ID of the created time event, which can be used to remove it later via . + public string AddTimeEvent(double timeInSeconds, Action action, string id = null) { - TimeEvents.Add(new AudioTimeEvent(timeInSeconds, action)); - TimeEvents.Sort(); + AudioTimeEvent timeEvent = new(timeInSeconds, action, id); + TimeEvents.Add(timeEvent); + TimeEvents.Sort(); UpdateNextTimeEventIndex(); + + return timeEvent.Id; + } + + /// + /// Removes a specific time-based event using its ID. + /// + /// The unique string identifier of the event to remove. + public void RemoveTimeEvent(string id) + { + int removed = TimeEvents.RemoveAll(e => e.Id == id); + + if (removed > 0) + UpdateNextTimeEventIndex(); } /// @@ -636,6 +654,17 @@ public void ClearTimeEvents() nextTimeEventIndex = 0; } + /// + /// Restarts the currently playing track from the beginning. + /// + public void RestartTrack() + { + if (!playBackRoutine.IsRunning || source == null) + return; + + CurrentTime = 0.0; + } + /// /// Stops playback. /// From 0053b13239d47184ab325c8d21f1a1e0281d0f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 21 Mar 2026 23:29:19 +0300 Subject: [PATCH 054/102] more more C#18 YOLOOOOOOO --- .../Features/Audio/PreloadedPcmSource.cs | 1 + .../Features/Audio/WavStreamSource.cs | 3 +- .../Exiled.API/Features/Audio/WavUtility.cs | 2 + EXILED/Exiled.API/Features/Toys/Speaker.cs | 94 +++++++++++++------ EXILED/Exiled.API/Interfaces/IPcmSource.cs | 2 +- .../Structs/AudioPlaybackOptions.cs | 58 ++++++++++++ .../{Features/Audio => Structs}/TrackData.cs | 2 +- 7 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs rename EXILED/Exiled.API/{Features/Audio => Structs}/TrackData.cs (98%) diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs index 3ee7a9992..a78399bf3 100644 --- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs @@ -10,6 +10,7 @@ namespace Exiled.API.Features.Audio using System; using Exiled.API.Interfaces; + using Exiled.API.Structs; using VoiceChat; diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs index f113664e7..a20add8e7 100644 --- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs +++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs @@ -12,9 +12,8 @@ namespace Exiled.API.Features.Audio using System.IO; using System.Runtime.InteropServices; - using Christmas.Scp2536.Gifts; - using Exiled.API.Interfaces; + using Exiled.API.Structs; using VoiceChat; diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index 617b08e56..c130f6122 100644 --- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -13,6 +13,8 @@ namespace Exiled.API.Features.Audio using System.IO; using System.Runtime.InteropServices; + using Exiled.API.Structs; + using VoiceChat; /// diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index dc25a26d3..3625b62b4 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.Structs; using Interfaces; @@ -390,7 +391,7 @@ public static Speaker Create(Transform parent = null, Vector3? position = null, /// 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) + public static Speaker Rent(Vector3? position = null, Transform parent = null) { Speaker speaker = null; @@ -415,8 +416,8 @@ public static Speaker Rent(Vector3 position, Transform parent = null) if (parent != null) speaker.Transform.parent = parent; - speaker.LocalPosition = position; - speaker.ControllerId = GetNextFreeControllerId(); + speaker.LocalPosition = position ?? Vector3.zero; + speaker.ControllerId = GetNextFreeControllerId(speaker.ControllerId); SpeakerToyPlaybackBase.AllInstances.Add(speaker.Base.Playback); } @@ -435,13 +436,14 @@ public static Speaker Rent(Vector3 position, Transform parent = null) /// The maximum distance at which the audio can be heard. /// The playback pitch level of the audio source. /// The duration in seconds over which the volume should smoothly increase from 0 to speaker volume. 0 means no fade in. + /// The duration in seconds over which the volume should smoothly decrease to 0 before the track ends. 0 means no fade out. /// 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 = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, float fadeInDuration = 0f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) + public static bool PlayFromPool(string path, Vector3 position, Transform parent = null, bool isSpatial = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, float fadeInDuration = 0f, float fadeOutDuration = 0f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) { Speaker speaker = Rent(position, parent); @@ -458,7 +460,14 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent speaker.ReturnToPoolAfter = true; - if (!speaker.Play(path, stream, fadeInDuration: fadeInDuration)) + AudioPlaybackOptions options = new() + { + Stream = stream, + FadeInDuration = fadeInDuration, + FadeOutDuration = fadeOutDuration, + }; + + if (!speaker.Play(path, options)) { speaker.ReturnToPool(); return false; @@ -470,10 +479,10 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent /// /// Gets the next available controller ID for a . /// + /// An optional ID to check first. /// The next available byte ID. If all IDs are currently in use, returns a default of 0. - public static byte GetNextFreeControllerId() + public static byte GetNextFreeControllerId(byte? preferredId = null) { - byte id = 0; HashSet usedIds = HashSetPool.Shared.Rent(byte.MaxValue + 1); foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances) @@ -488,6 +497,13 @@ public static byte GetNextFreeControllerId() return DefaultControllerId; } + if (preferredId.HasValue && !usedIds.Contains(preferredId.Value)) + { + HashSetPool.Shared.Return(usedIds); + return preferredId.Value; + } + + byte id = 0; while (usedIds.Contains(id)) { id++; @@ -501,13 +517,9 @@ public static byte GetNextFreeControllerId() /// Plays a wav file through this speaker. (File must be 16-bit, mono, and 48kHz.) /// /// The path to the wav file. - /// Whether to stream the audio directly from the disk (true) or preload it entirely into RAM (false). - /// Whether to destroy the speaker object automatically after playback finishes. - /// Whether to loop the audio continuously. - /// The duration in seconds over which the volume should smoothly increase from 0 to the speaker's volume. 0 means no fade-in. - /// If true, clears any upcoming tracks in the playlist before playing the new track. + /// The configuration options for playback. /// 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, float fadeInDuration = 0f, bool clearQueue = false) + public bool Play(string path, AudioPlaybackOptions options = default) { if (!File.Exists(path)) { @@ -522,14 +534,11 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo } TryInitializePlayBack(); - Stop(clearQueue); - - Loop = loop; - DestroyAfter = destroyAfter; + Stop(options.ClearQueue); try { - source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path); + source = options.Stream ? new WavStreamSource(path) : new PreloadedPcmSource(path); } catch (Exception ex) { @@ -539,11 +548,17 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo LastTrackInfo = source.TrackInfo; - if (fadeInDuration > 0f) + if (options.FadeInDuration > 0f) { float targetVol = Volume; Volume = 0f; - FadeVolume(0f, targetVol, fadeInDuration); + FadeVolume(0f, targetVol, options.FadeInDuration); + } + + if (options.FadeOutDuration > 0f && TotalDuration > options.FadeOutDuration) + { + double triggerTime = TotalDuration - options.FadeOutDuration; + AddTimeEvent(triggerTime, () => FadeVolume(Volume, 0f, options.FadeOutDuration), id: "AutoFadeOut"); } playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); @@ -556,13 +571,13 @@ public bool Play(string path, bool stream = false, bool destroyAfter = false, bo /// The initial volume level when the fade begins. /// The final volume level to reach at the end of the fade. /// The time in seconds the fading process should take to complete. - /// If set to true, the playback will automatically once the is reached. Perfect for fade-outs. - public void FadeVolume(float startVolume, float targetVolume, float duration = 3, bool stopAfterFade = false) + /// An optional action to invoke when the fade process is fully finished. + public void FadeVolume(float startVolume, float targetVolume, float duration = 3, Action onComplete = null) { if (fadeRoutine.IsRunning) fadeRoutine.IsRunning = false; - fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, stopAfterFade).CancelWith(GameObject)); + fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, onComplete).CancelWith(GameObject)); } /// @@ -694,6 +709,25 @@ public void Stop(bool clearQueue = true) source = null; } + /// + /// Smoothly fades out the volume over the specified duration and then stops the playback completely. + /// + /// The duration in seconds to fade out the audio. + /// If true, clears the upcoming tracks in the playlist. + public void Stop(float fadeOutDuration, bool clearQueue = true) + { + if (fadeOutDuration <= 0f || !IsPlaying) + { + Stop(clearQueue); + return; + } + + if (clearQueue) + TrackQueue.Clear(); + + FadeVolume(Volume, 0f, fadeOutDuration, onComplete: () => Stop(clearQueue)); + } + /// /// Stops the current playback, resets all properties of the , and returns the instance to the object pool for future reuse. /// @@ -713,13 +747,11 @@ public void ReturnToPool() LocalPosition = SpeakerParkPosition; Volume = DefaultVolume; - IsSpatial = DefaultSpatial; MinDistance = DefaultMinDistance; MaxDistance = DefaultMaxDistance; IsStatic = true; - Loop = false; DestroyAfter = false; ReturnToPoolAfter = false; @@ -738,6 +770,13 @@ public void ReturnToPool() isPitchDefault = true; needsSyncWait = false; + OnPlaybackStarted = null; + OnPlaybackPaused = null; + OnPlaybackResumed = null; + OnPlaybackLooped = null; + OnPlaybackFinished = null; + OnPlaybackStopped = null; + SpeakerToyPlaybackBase.AllInstances.Remove(Base.Playback); Pool.Enqueue(this); @@ -909,7 +948,7 @@ private IEnumerator PlayBackCoroutine() } } - private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, bool stopAfterFade) + private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, Action onComplete) { float timePassed = 0f; @@ -922,8 +961,7 @@ private IEnumerator FadeCoroutine(float startVolume, float targetVolume, Volume = targetVolume; - if (stopAfterFade) - Stop(); + onComplete?.Invoke(); } private void SendPacket(int len) diff --git a/EXILED/Exiled.API/Interfaces/IPcmSource.cs b/EXILED/Exiled.API/Interfaces/IPcmSource.cs index 5e86f1c60..c9f932c8b 100644 --- a/EXILED/Exiled.API/Interfaces/IPcmSource.cs +++ b/EXILED/Exiled.API/Interfaces/IPcmSource.cs @@ -9,7 +9,7 @@ namespace Exiled.API.Interfaces { using System; - using Exiled.API.Features.Audio; + using Exiled.API.Structs; /// /// Represents a source of PCM audio data. diff --git a/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs b/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs new file mode 100644 index 000000000..745631f9b --- /dev/null +++ b/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs @@ -0,0 +1,58 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Structs +{ + /// + /// Represents the configuration options for audio playback. + /// + public struct AudioPlaybackOptions + { + /// + /// Initializes a new instance of the struct with default values. + /// + public AudioPlaybackOptions() + { + Stream = false; + ClearQueue = false; + FadeInDuration = 0f; + FadeOutDuration = 0f; + } + + /// + /// Gets or sets a value indicating whether to stream the audio directly from the disk (true) or preload it entirely into RAM (false). + /// + public bool Stream { get; set; } + + /// + /// Gets or sets a value indicating whether the speaker object should be automatically destroyed after the playback finishes. + /// + public bool DestroyAfter { get; set; } + + /// + /// Gets or sets a value indicating whether the audio should loop continuously. + /// + public bool Loop { get; set; } + + /// + /// Gets or sets the duration in seconds over which the volume should smoothly increase from 0 to the target volume at the start of playback. + /// 0 means no fade-in. + /// + public float FadeInDuration { get; set; } + + /// + /// Gets or sets the duration in seconds over which the volume should smoothly decrease to 0 before the track ends automatically. + /// 0 means no fade-out. + /// + public float FadeOutDuration { get; set; } + + /// + /// Gets or sets a value indicating whether to clear any upcoming tracks in the playlist before playing the new track. + /// + public bool ClearQueue { get; set; } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/TrackData.cs b/EXILED/Exiled.API/Structs/TrackData.cs similarity index 98% rename from EXILED/Exiled.API/Features/Audio/TrackData.cs rename to EXILED/Exiled.API/Structs/TrackData.cs index d169f944f..fba0aca28 100644 --- a/EXILED/Exiled.API/Features/Audio/TrackData.cs +++ b/EXILED/Exiled.API/Structs/TrackData.cs @@ -5,7 +5,7 @@ // // ----------------------------------------------------------------------- -namespace Exiled.API.Features.Audio +namespace Exiled.API.Structs { using System; From 35dfb119b54fe904648033c03c9ccbbc43b6299a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 21 Mar 2026 23:45:31 +0300 Subject: [PATCH 055/102] reorder --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 403 ++++++++++----------- 1 file changed, 201 insertions(+), 202 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 3625b62b4..03972d01a 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -50,9 +50,7 @@ public class Speaker : AdminToy, IWrapper 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; @@ -60,21 +58,22 @@ public class Speaker : AdminToy, IWrapper private static readonly Vector3 SpeakerParkPosition = Vector3.down * 999; + private IPcmSource source; + private OpusEncoder encoder; + private float[] frame; private byte[] encoded; private float[] resampleBuffer; - private double resampleTime; - private int resampleBufferFilled; - - private IPcmSource source; - private OpusEncoder encoder; private CoroutineHandle fadeRoutine; private CoroutineHandle playBackRoutine; + private double resampleTime; + private int resampleBufferFilled; private int idChangeFrame; - private int nextTimeEventIndex = 0; private bool needsSyncWait = false; + + private int nextTimeEventIndex = 0; private bool isPitchDefault = true; private bool isPlayBackInitialized = false; @@ -565,6 +564,54 @@ public bool Play(string path, AudioPlaybackOptions options = default) return true; } + /// + /// Stops playback. + /// + /// If true, clears the upcoming tracks in the playlist. + public void Stop(bool clearQueue = true) + { + if (!isPlayBackInitialized) + return; + + if (playBackRoutine.IsRunning) + { + playBackRoutine.IsRunning = false; + + OnPlaybackStopped?.Invoke(); + SpeakerEvents.OnPlaybackStopped(this); + } + + if (fadeRoutine.IsRunning) + fadeRoutine.IsRunning = false; + + if (clearQueue) + TrackQueue.Clear(); + + ResetEncoder(); + ClearTimeEvents(); + source?.Dispose(); + source = null; + } + + /// + /// Smoothly fades out the volume over the specified duration and then stops the playback completely. + /// + /// The duration in seconds to fade out the audio. + /// If true, clears the upcoming tracks in the playlist. + public void Stop(float fadeOutDuration, bool clearQueue = true) + { + if (fadeOutDuration <= 0f || !IsPlaying) + { + Stop(clearQueue); + return; + } + + if (clearQueue) + TrackQueue.Clear(); + + FadeVolume(Volume, 0f, fadeOutDuration, onComplete: () => Stop(clearQueue)); + } + /// /// Fades the volume to a specific target over a given duration. /// @@ -580,6 +627,17 @@ public void FadeVolume(float startVolume, float targetVolume, float duration = 3 fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, onComplete).CancelWith(GameObject)); } + /// + /// Restarts the currently playing track from the beginning. + /// + public void RestartTrack() + { + if (!playBackRoutine.IsRunning || source == null) + return; + + CurrentTime = 0.0; + } + /// /// Adds an audio file to the playback queue. If nothing is currently playing, playback starts immediately. /// @@ -612,23 +670,6 @@ public void SkipTrack() } } - /// - /// Removes a specific track from the queue by its file path. - /// - /// The exact file path of the track to remove. - /// If true, removes the first occurrence; if false, removes the last occurrence. - /// true if the track was successfully found and removed; otherwise, false. - public bool RemoveFromQueue(string path, bool findFirst = true) - { - int index = findFirst ? TrackQueue.IndexOf(path) : TrackQueue.LastIndexOf(path); - - if (index == -1) - return false; - - TrackQueue.RemoveAt(index); - return true; - } - /// /// Adds an action to be executed at a specific time in seconds during the current playback. /// WARNING: Heavy operations can cause audio interruptions. If you need to perform heavy operations, start a new Coroutine inside the action. @@ -661,71 +702,29 @@ public void RemoveTimeEvent(string id) } /// - /// Clears all time-based events for the current playback. - /// - public void ClearTimeEvents() - { - TimeEvents.Clear(); - nextTimeEventIndex = 0; - } - - /// - /// Restarts the currently playing track from the beginning. - /// - public void RestartTrack() - { - if (!playBackRoutine.IsRunning || source == null) - return; - - CurrentTime = 0.0; - } - - /// - /// Stops playback. + /// Removes a specific track from the queue by its file path. /// - /// If true, clears the upcoming tracks in the playlist. - public void Stop(bool clearQueue = true) + /// The exact file path of the track to remove. + /// If true, removes the first occurrence; if false, removes the last occurrence. + /// true if the track was successfully found and removed; otherwise, false. + public bool RemoveFromQueue(string path, bool findFirst = true) { - if (!isPlayBackInitialized) - return; - - if (playBackRoutine.IsRunning) - { - playBackRoutine.IsRunning = false; - - OnPlaybackStopped?.Invoke(); - SpeakerEvents.OnPlaybackStopped(this); - } - - if (fadeRoutine.IsRunning) - fadeRoutine.IsRunning = false; + int index = findFirst ? TrackQueue.IndexOf(path) : TrackQueue.LastIndexOf(path); - if (clearQueue) - TrackQueue.Clear(); + if (index == -1) + return false; - ResetEncoder(); - ClearTimeEvents(); - source?.Dispose(); - source = null; + TrackQueue.RemoveAt(index); + return true; } /// - /// Smoothly fades out the volume over the specified duration and then stops the playback completely. + /// Clears all time-based events for the current playback. /// - /// The duration in seconds to fade out the audio. - /// If true, clears the upcoming tracks in the playlist. - public void Stop(float fadeOutDuration, bool clearQueue = true) + public void ClearTimeEvents() { - if (fadeOutDuration <= 0f || !IsPlaying) - { - Stop(clearQueue); - return; - } - - if (clearQueue) - TrackQueue.Clear(); - - FadeVolume(Volume, 0f, fadeOutDuration, onComplete: () => Stop(clearQueue)); + TimeEvents.Clear(); + nextTimeEventIndex = 0; } /// @@ -847,121 +846,13 @@ private void UpdateNextTimeEventIndex() } } - private IEnumerator PlayBackCoroutine() - { - if (needsSyncWait) - { - int framesPassed = Time.frameCount - idChangeFrame; - - while (framesPassed < 2) - { - yield return Timing.WaitForOneFrame; - framesPassed = Time.frameCount - idChangeFrame; - } - - needsSyncWait = false; - } - - OnPlaybackStarted?.Invoke(); - SpeakerEvents.OnPlaybackStarted(this); - - resampleTime = 0.0; - resampleBufferFilled = 0; - - float timeAccumulator = 0f; - - while (true) - { - timeAccumulator += Time.deltaTime; - - while (timeAccumulator >= FrameTime) - { - timeAccumulator -= FrameTime; - - if (isPitchDefault) - { - int read = source.Read(frame, 0, FrameSize); - if (read < FrameSize) - Array.Clear(frame, read, FrameSize - read); - } - else - { - ResampleFrame(); - } - - int len = encoder.Encode(frame, encoded); - - if (len > 2) - SendPacket(len); - - if (!source.Ended) - continue; - - OnPlaybackFinished?.Invoke(); - SpeakerEvents.OnPlaybackFinished(this); - - if (Loop) - { - ResetEncoder(); - source.Reset(); - timeAccumulator = 0; - resampleTime = resampleBufferFilled = 0; - - nextTimeEventIndex = 0; - - OnPlaybackLooped?.Invoke(); - SpeakerEvents.OnPlaybackLooped(this); - continue; - } - - if (TrySwitchToNextTrack()) - { - timeAccumulator = 0f; - continue; - } - - if (ReturnToPoolAfter) - ReturnToPool(); - else if (DestroyAfter) - Destroy(); - else - Stop(); - - yield break; - } - - while (nextTimeEventIndex < TimeEvents.Count && CurrentTime >= TimeEvents[nextTimeEventIndex].Time) - { - try - { - TimeEvents[nextTimeEventIndex].Action?.Invoke(); - } - catch (Exception ex) - { - Log.Error($"[Speaker] Failed to execute scheduled time event at {TimeEvents[nextTimeEventIndex].Time:F2}s.\nException Details: {ex}"); - } - - nextTimeEventIndex++; - } - - yield return Timing.WaitForOneFrame; - } - } - - private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, Action onComplete) + private void ResetEncoder() { - float timePassed = 0f; - - while (timePassed < duration) + if (encoder != null && encoder._handle != IntPtr.Zero) { - timePassed += Time.deltaTime; - Volume = Mathf.Lerp(startVolume, targetVolume, timePassed / duration); - yield return Timing.WaitForOneFrame; + // 4028 => OPUS_RESET_STATE (https://github.com/xiph/opus/blob/2d862ea14b233e5a3f3afaf74d96050691af3cd5/include/opus_defines.h#L710) + OpusWrapper.SetEncoderSetting(encoder._handle, (OpusCtlSetRequest)4028, 0); } - - Volume = targetVolume; - - onComplete?.Invoke(); } private void SendPacket(int len) @@ -1086,15 +977,6 @@ private void ResampleFrame() } } - private void ResetEncoder() - { - if (encoder != null && encoder._handle != IntPtr.Zero) - { - // 4028 => OPUS_RESET_STATE (https://github.com/xiph/opus/blob/2d862ea14b233e5a3f3afaf74d96050691af3cd5/include/opus_defines.h#L710) - OpusWrapper.SetEncoderSetting(encoder._handle, (OpusCtlSetRequest)4028, 0); - } - } - private void OnToyRemoved(AdminToyBase toy) { if (toy != Base) @@ -1106,5 +988,122 @@ private void OnToyRemoved(AdminToyBase toy) encoder?.Dispose(); } + + private IEnumerator PlayBackCoroutine() + { + if (needsSyncWait) + { + int framesPassed = Time.frameCount - idChangeFrame; + + while (framesPassed < 2) + { + yield return Timing.WaitForOneFrame; + framesPassed = Time.frameCount - idChangeFrame; + } + + needsSyncWait = false; + } + + OnPlaybackStarted?.Invoke(); + SpeakerEvents.OnPlaybackStarted(this); + + resampleTime = 0.0; + resampleBufferFilled = 0; + + float timeAccumulator = 0f; + + while (true) + { + timeAccumulator += Time.deltaTime; + + while (timeAccumulator >= FrameTime) + { + timeAccumulator -= FrameTime; + + if (isPitchDefault) + { + int read = source.Read(frame, 0, FrameSize); + if (read < FrameSize) + Array.Clear(frame, read, FrameSize - read); + } + else + { + ResampleFrame(); + } + + int len = encoder.Encode(frame, encoded); + + if (len > 2) + SendPacket(len); + + if (!source.Ended) + continue; + + OnPlaybackFinished?.Invoke(); + SpeakerEvents.OnPlaybackFinished(this); + + if (Loop) + { + ResetEncoder(); + source.Reset(); + timeAccumulator = 0; + resampleTime = resampleBufferFilled = 0; + + nextTimeEventIndex = 0; + + OnPlaybackLooped?.Invoke(); + SpeakerEvents.OnPlaybackLooped(this); + continue; + } + + if (TrySwitchToNextTrack()) + { + timeAccumulator = 0f; + continue; + } + + if (ReturnToPoolAfter) + ReturnToPool(); + else if (DestroyAfter) + Destroy(); + else + Stop(); + + yield break; + } + + while (nextTimeEventIndex < TimeEvents.Count && CurrentTime >= TimeEvents[nextTimeEventIndex].Time) + { + try + { + TimeEvents[nextTimeEventIndex].Action?.Invoke(); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to execute scheduled time event at {TimeEvents[nextTimeEventIndex].Time:F2}s.\nException Details: {ex}"); + } + + nextTimeEventIndex++; + } + + yield return Timing.WaitForOneFrame; + } + } + + private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, Action onComplete) + { + float timePassed = 0f; + + while (timePassed < duration) + { + timePassed += Time.deltaTime; + Volume = Mathf.Lerp(startVolume, targetVolume, timePassed / duration); + yield return Timing.WaitForOneFrame; + } + + Volume = targetVolume; + + onComplete?.Invoke(); + } } } \ No newline at end of file From c770cee639323636382192150c1ca7e4e3a6f71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 00:07:05 +0300 Subject: [PATCH 056/102] lazy instance for lists --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 03972d01a..65e591598 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -256,12 +256,12 @@ public float PlaybackProgress /// /// Gets the queue of audio file paths to be played sequentially after the current track finishes. /// - public List TrackQueue { get; } = new(); + public List TrackQueue => field ??= new(); /// /// Gets the list of time-based events for the current audio track. /// - public List TimeEvents { get; } = new(); + public List TimeEvents => field ??= new(); /// /// Gets or sets the playback pitch. @@ -387,10 +387,10 @@ public static Speaker Create(Transform parent = null, Vector3? position = null, /// /// Rents an available speaker from the pool or creates a new one if the pool is empty. /// - /// The local position of the . /// The parent transform to attach the to. + /// The local position of the . /// A clean instance ready for use. - public static Speaker Rent(Vector3? position = null, Transform parent = null) + public static Speaker Rent(Transform parent = null, Vector3? position = null) { Speaker speaker = null; @@ -444,7 +444,7 @@ public static Speaker Rent(Vector3? position = null, Transform parent = null) /// 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 = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, float fadeInDuration = 0f, float fadeOutDuration = 0f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) { - Speaker speaker = Rent(position, parent); + Speaker speaker = Rent(parent, position); speaker.Volume = volume; speaker.IsSpatial = isSpatial; From 355317d8c8f53eec2b4384a8cd803ca95b2aa218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 00:32:03 +0300 Subject: [PATCH 057/102] shuffle --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 65e591598..79d1cfcca 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -35,6 +35,7 @@ namespace Exiled.API.Features.Toys using VoiceChat.Playbacks; using Object = UnityEngine.Object; + using Random = UnityEngine.Random; /// /// A wrapper class for . @@ -670,6 +671,21 @@ public void SkipTrack() } } + /// + /// Shuffles the tracks in the into a random order. + /// + public void ShuffleTracks() + { + if (TrackQueue.Count <= 1) + return; + + for (int i = TrackQueue.Count - 1; i > 0; i--) + { + int j = Random.Range(0, i + 1); + (TrackQueue[i], TrackQueue[j]) = (TrackQueue[j], TrackQueue[i]); + } + } + /// /// Adds an action to be executed at a specific time in seconds during the current playback. /// WARNING: Heavy operations can cause audio interruptions. If you need to perform heavy operations, start a new Coroutine inside the action. From f73eeba54a8cdc73b10a38cd12e6e2af994bbb85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 00:33:57 +0300 Subject: [PATCH 058/102] give credit to Fisher-Yates --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 79d1cfcca..d5e2b0d46 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -672,7 +672,7 @@ public void SkipTrack() } /// - /// Shuffles the tracks in the into a random order. + /// Shuffles the tracks in the into a random order with Fisher-Yates algorithm. /// public void ShuffleTracks() { From d8851b917fcf9f8df1a124ac37d9d06b4de7529a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 00:37:49 +0300 Subject: [PATCH 059/102] i forgot to delete this --- EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs b/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs index 745631f9b..3f4eefee9 100644 --- a/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs +++ b/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs @@ -28,16 +28,6 @@ public AudioPlaybackOptions() /// public bool Stream { get; set; } - /// - /// Gets or sets a value indicating whether the speaker object should be automatically destroyed after the playback finishes. - /// - public bool DestroyAfter { get; set; } - - /// - /// Gets or sets a value indicating whether the audio should loop continuously. - /// - public bool Loop { get; set; } - /// /// Gets or sets the duration in seconds over which the volume should smoothly increase from 0 to the target volume at the start of playback. /// 0 means no fade-in. From bd5e2463a2c9c3d50d50c7d091c095cb348aa117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 01:19:17 +0300 Subject: [PATCH 060/102] natural Fade --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index d5e2b0d46..1a28eeedd 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -1109,16 +1109,18 @@ private IEnumerator PlayBackCoroutine() private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, Action onComplete) { float timePassed = 0f; + bool isFadeOut = startVolume > targetVolume; while (timePassed < duration) { timePassed += Time.deltaTime; - Volume = Mathf.Lerp(startVolume, targetVolume, timePassed / duration); + float t = timePassed / duration; + t = isFadeOut ? 1f - ((1f - t) * (1f - t)) : t * t; + Volume = Mathf.Lerp(startVolume, targetVolume, t); yield return Timing.WaitForOneFrame; } Volume = targetVolume; - onComplete?.Invoke(); } } From 3a5edd6ffcda3f1613a55390d7b5701af2156f5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 01:30:52 +0300 Subject: [PATCH 061/102] add linear fade option --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 1a28eeedd..75c798563 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -619,13 +619,14 @@ public void Stop(float fadeOutDuration, bool clearQueue = true) /// The initial volume level when the fade begins. /// The final volume level to reach at the end of the fade. /// The time in seconds the fading process should take to complete. + /// If true, uses linear interpolation; if false, uses natural easing (ease-in for fade-in, ease-out for fade-out). /// An optional action to invoke when the fade process is fully finished. - public void FadeVolume(float startVolume, float targetVolume, float duration = 3, Action onComplete = null) + public void FadeVolume(float startVolume, float targetVolume, float duration = 3, bool linear = false, Action onComplete = null) { if (fadeRoutine.IsRunning) fadeRoutine.IsRunning = false; - fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, onComplete).CancelWith(GameObject)); + fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, linear, onComplete).CancelWith(GameObject)); } /// @@ -1106,7 +1107,7 @@ private IEnumerator PlayBackCoroutine() } } - private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, Action onComplete) + private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, bool linear, Action onComplete) { float timePassed = 0f; bool isFadeOut = startVolume > targetVolume; @@ -1115,7 +1116,10 @@ private IEnumerator FadeCoroutine(float startVolume, float targetVolume, { timePassed += Time.deltaTime; float t = timePassed / duration; - t = isFadeOut ? 1f - ((1f - t) * (1f - t)) : t * t; + + if (!linear) + t = isFadeOut ? 1f - ((1f - t) * (1f - t)) : t * t; + Volume = Mathf.Lerp(startVolume, targetVolume, t); yield return Timing.WaitForOneFrame; } From 32510add089a8eaf4ff23b641ff1bd327213bb94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 01:47:56 +0300 Subject: [PATCH 062/102] Fix after fade volume stuck 0 --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 75c798563..c78f3905c 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -557,8 +557,15 @@ public bool Play(string path, AudioPlaybackOptions options = default) if (options.FadeOutDuration > 0f && TotalDuration > options.FadeOutDuration) { + float oldVolume = Volume; double triggerTime = TotalDuration - options.FadeOutDuration; - AddTimeEvent(triggerTime, () => FadeVolume(Volume, 0f, options.FadeOutDuration), id: "AutoFadeOut"); + AddTimeEvent( + triggerTime, + () => + { + FadeVolume(Volume, 0f, options.FadeOutDuration, onComplete: () => Volume = oldVolume); + }, + id: "AutoFadeOut"); } playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); From d287513ac041763b2597057a5ab831e47045ebf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 02:06:16 +0300 Subject: [PATCH 063/102] fixes --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 71 +++++++++++++--------- EXILED/Exiled.API/Structs/QueuedTrack.cs | 36 +++++++++++ 2 files changed, 77 insertions(+), 30 deletions(-) create mode 100644 EXILED/Exiled.API/Structs/QueuedTrack.cs diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index c78f3905c..1798c4a9c 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -255,9 +255,9 @@ public float PlaybackProgress public TrackData LastTrackInfo { get; private set; } /// - /// Gets the queue of audio file paths to be played sequentially after the current track finishes. + /// Gets the queue of audio tracks to be played sequentially. /// - public List TrackQueue => field ??= new(); + public List TrackQueue => field ??= new(); /// /// Gets the list of time-based events for the current audio track. @@ -548,25 +548,7 @@ public bool Play(string path, AudioPlaybackOptions options = default) LastTrackInfo = source.TrackInfo; - if (options.FadeInDuration > 0f) - { - float targetVol = Volume; - Volume = 0f; - FadeVolume(0f, targetVol, options.FadeInDuration); - } - - if (options.FadeOutDuration > 0f && TotalDuration > options.FadeOutDuration) - { - float oldVolume = Volume; - double triggerTime = TotalDuration - options.FadeOutDuration; - AddTimeEvent( - triggerTime, - () => - { - FadeVolume(Volume, 0f, options.FadeOutDuration, onComplete: () => Volume = oldVolume); - }, - id: "AutoFadeOut"); - } + ApplyPlaybackOptions(options); playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); return true; @@ -617,7 +599,12 @@ public void Stop(float fadeOutDuration, bool clearQueue = true) if (clearQueue) TrackQueue.Clear(); - FadeVolume(Volume, 0f, fadeOutDuration, onComplete: () => Stop(clearQueue)); + float oldVolume = Volume; + FadeVolume(Volume, 0f, fadeOutDuration, onComplete: () => + { + Stop(clearQueue); + Volume = oldVolume; + }); } /// @@ -648,16 +635,17 @@ public void RestartTrack() } /// - /// Adds an audio file to the playback queue. If nothing is currently playing, playback starts immediately. + /// Adds an audio file to the playback queue with specific options. If nothing is playing, playback starts immediately. /// /// The path to the wav file to enqueue. - /// true if the file was successfully queued or playback started; otherwise, false. - public bool QueueTrack(string path) + /// The specific playback configuration for this track. + /// true if successfully queued or started. + public bool QueueTrack(string path, AudioPlaybackOptions options = default) { if (!playBackRoutine.IsRunning && !IsPaused) - return Play(path); + return Play(path, options); - TrackQueue.Add(path); + TrackQueue.Add(new QueuedTrack(path, options)); return true; } @@ -827,14 +815,14 @@ private bool TrySwitchToNextTrack() { while (TrackQueue.Count > 0) { - string nextTrack = TrackQueue[0]; + QueuedTrack nextTrack = TrackQueue[0]; TrackQueue.RemoveAt(0); IPcmSource newSource; try { - bool useStream = source is WavStreamSource; - newSource = useStream ? new WavStreamSource(nextTrack) : new PreloadedPcmSource(nextTrack); + bool useStream = nextTrack.Options.Stream; + newSource = useStream ? new WavStreamSource(nextTrack.Path) : new PreloadedPcmSource(nextTrack.Path); } catch (Exception ex) { @@ -851,6 +839,8 @@ private bool TrySwitchToNextTrack() resampleTime = 0.0; resampleBufferFilled = 0; + ApplyPlaybackOptions(nextTrack.Options); + OnPlaybackStarted?.Invoke(); SpeakerEvents.OnPlaybackStarted(this); return true; @@ -859,6 +849,27 @@ private bool TrySwitchToNextTrack() return false; } + private void ApplyPlaybackOptions(AudioPlaybackOptions options) + { + if (options.FadeInDuration > 0f) + { + float targetVol = Volume; + Volume = 0f; + FadeVolume(0f, targetVol, options.FadeInDuration); + } + + if (options.FadeOutDuration > 0f && TotalDuration > options.FadeOutDuration) + { + float oldVolume = Volume; + double triggerTime = TotalDuration - options.FadeOutDuration; + + AddTimeEvent( + triggerTime, + () => FadeVolume(Volume, 0f, options.FadeOutDuration, onComplete: () => Volume = oldVolume), + id: "AutoFadeOut"); + } + } + private void UpdateNextTimeEventIndex() { nextTimeEventIndex = 0; diff --git a/EXILED/Exiled.API/Structs/QueuedTrack.cs b/EXILED/Exiled.API/Structs/QueuedTrack.cs new file mode 100644 index 000000000..ea226aa57 --- /dev/null +++ b/EXILED/Exiled.API/Structs/QueuedTrack.cs @@ -0,0 +1,36 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Structs +{ + /// + /// Represents a track waiting in the queue, along with its specific playback options. + /// + public readonly struct QueuedTrack + { + /// + /// Initializes a new instance of the struct. + /// + /// The path to the .wav file. + /// The specific playback configuration for this track. + public QueuedTrack(string path, AudioPlaybackOptions options = default) + { + Path = path; + Options = options; + } + + /// + /// Gets the absolute path to the .wav file. + /// + public string Path { get; } + + /// + /// Gets the playback options configured for this specific track. + /// + public AudioPlaybackOptions Options { get; } + } +} From 5da1136e13d5e234c71efbedae7ebd850f821e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 02:14:03 +0300 Subject: [PATCH 064/102] squash fix --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 14 +++++--------- EXILED/Exiled.API/Structs/QueuedTrack.cs | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 1798c4a9c..c91f1c5bb 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -721,7 +721,7 @@ public void RemoveTimeEvent(string id) /// true if the track was successfully found and removed; otherwise, false. public bool RemoveFromQueue(string path, bool findFirst = true) { - int index = findFirst ? TrackQueue.IndexOf(path) : TrackQueue.LastIndexOf(path); + int index = findFirst ? TrackQueue.FindIndex(t => t.Path == path) : TrackQueue.FindLastIndex(t => t.Path == path); if (index == -1) return false; @@ -851,22 +851,18 @@ private bool TrySwitchToNextTrack() private void ApplyPlaybackOptions(AudioPlaybackOptions options) { + float originalVolume = Volume; + if (options.FadeInDuration > 0f) { - float targetVol = Volume; Volume = 0f; - FadeVolume(0f, targetVol, options.FadeInDuration); + FadeVolume(0f, originalVolume, options.FadeInDuration); } if (options.FadeOutDuration > 0f && TotalDuration > options.FadeOutDuration) { - float oldVolume = Volume; double triggerTime = TotalDuration - options.FadeOutDuration; - - AddTimeEvent( - triggerTime, - () => FadeVolume(Volume, 0f, options.FadeOutDuration, onComplete: () => Volume = oldVolume), - id: "AutoFadeOut"); + AddTimeEvent(triggerTime, () => FadeVolume(Volume, 0f, options.FadeOutDuration, onComplete: () => Volume = originalVolume), id: "AutoFadeOut"); } } diff --git a/EXILED/Exiled.API/Structs/QueuedTrack.cs b/EXILED/Exiled.API/Structs/QueuedTrack.cs index ea226aa57..6fd35411e 100644 --- a/EXILED/Exiled.API/Structs/QueuedTrack.cs +++ b/EXILED/Exiled.API/Structs/QueuedTrack.cs @@ -33,4 +33,4 @@ public QueuedTrack(string path, AudioPlaybackOptions options = default) /// public AudioPlaybackOptions Options { get; } } -} +} \ No newline at end of file From 8d5d7b39cc5cc7d6316e3f8b9249c2a570f4939d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 03:10:08 +0300 Subject: [PATCH 065/102] fade corrutine fix maybe, im drowing help me is it OverApi idk? --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 28 ++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index c91f1c5bb..1fb9c31bc 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -264,6 +264,11 @@ public float PlaybackProgress /// public List TimeEvents => field ??= new(); + /// + /// Gets the playback options of the currently playing track. + /// + public AudioPlaybackOptions CurrentOptions { get; private set; } + /// /// Gets or sets the playback pitch. /// @@ -296,7 +301,15 @@ public float Pitch public float Volume { get => Base.NetworkVolume; - set => Base.NetworkVolume = value; + set + { + if (fadeRoutine.IsRunning) + fadeRoutine.IsRunning = false; + + RemoveTimeEvent("AutoFadeOut"); + + Base.NetworkVolume = value; + } } /// @@ -538,6 +551,7 @@ public bool Play(string path, AudioPlaybackOptions options = default) try { + CurrentOptions = options; source = options.Stream ? new WavStreamSource(path) : new PreloadedPcmSource(path); } catch (Exception ex) @@ -581,6 +595,7 @@ public void Stop(bool clearQueue = true) ClearTimeEvents(); source?.Dispose(); source = null; + RemoveTimeEvent("ManualStop"); } /// @@ -832,7 +847,9 @@ private bool TrySwitchToNextTrack() source?.Dispose(); source = newSource; + LastTrackInfo = source.TrackInfo; + CurrentOptions = nextTrack.Options; ResetEncoder(); ClearTimeEvents(); @@ -1070,6 +1087,8 @@ private IEnumerator PlayBackCoroutine() if (!source.Ended) continue; + yield return Timing.WaitForOneFrame; + OnPlaybackFinished?.Invoke(); SpeakerEvents.OnPlaybackFinished(this); @@ -1079,9 +1098,10 @@ private IEnumerator PlayBackCoroutine() source.Reset(); timeAccumulator = 0; resampleTime = resampleBufferFilled = 0; - nextTimeEventIndex = 0; + ApplyPlaybackOptions(CurrentOptions); + OnPlaybackLooped?.Invoke(); SpeakerEvents.OnPlaybackLooped(this); continue; @@ -1134,11 +1154,11 @@ private IEnumerator FadeCoroutine(float startVolume, float targetVolume, if (!linear) t = isFadeOut ? 1f - ((1f - t) * (1f - t)) : t * t; - Volume = Mathf.Lerp(startVolume, targetVolume, t); + Base.NetworkVolume = Mathf.Lerp(startVolume, targetVolume, t); yield return Timing.WaitForOneFrame; } - Volume = targetVolume; + Base.NetworkVolume = targetVolume; onComplete?.Invoke(); } } From 56699bb5cdec1b7e4553919c3208075d36b3b04a Mon Sep 17 00:00:00 2001 From: MS-crew <100300664+MS-crew@users.noreply.github.com> Date: Sun, 22 Mar 2026 03:34:03 +0300 Subject: [PATCH 066/102] Update Speaker.cs --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 1fb9c31bc..370f5313c 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -600,6 +600,7 @@ public void Stop(bool clearQueue = true) /// /// Smoothly fades out the volume over the specified duration and then stops the playback completely. + /// Note: Manually changing the during fade-out will cancel the stop process. /// /// The duration in seconds to fade out the audio. /// If true, clears the upcoming tracks in the playlist. From 0954f8c1c00800851a4591cf103f146e7a45b2ee Mon Sep 17 00:00:00 2001 From: MS-crew <100300664+MS-crew@users.noreply.github.com> Date: Sun, 22 Mar 2026 03:36:06 +0300 Subject: [PATCH 067/102] Update Speaker.cs --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 370f5313c..511104f38 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -600,7 +600,7 @@ public void Stop(bool clearQueue = true) /// /// Smoothly fades out the volume over the specified duration and then stops the playback completely. - /// Note: Manually changing the during fade-out will cancel the stop process. + /// Note: Manually changing the during fade-out will cancel the stop process. /// /// The duration in seconds to fade out the audio. /// If true, clears the upcoming tracks in the playlist. From 09dfcc11674dc41fa6f6cfe01dfbf94f8cf1b780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 16:57:41 +0300 Subject: [PATCH 068/102] simplify api & null safety & fix fade & add stop fade function --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 104 ++++++++++----------- 1 file changed, 47 insertions(+), 57 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 1fb9c31bc..70674000c 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -42,6 +42,11 @@ namespace Exiled.API.Features.Toys /// public class Speaker : AdminToy, IWrapper { + /// + /// The unique identifier used for the automatically scheduled fade-out time event. + /// + public const string AutoFadeOutId = "AutoFadeOut"; + /// /// 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. @@ -303,11 +308,7 @@ public float Volume get => Base.NetworkVolume; set { - if (fadeRoutine.IsRunning) - fadeRoutine.IsRunning = false; - - RemoveTimeEvent("AutoFadeOut"); - + StopFade(); Base.NetworkVolume = value; } } @@ -585,45 +586,19 @@ public void Stop(bool clearQueue = true) SpeakerEvents.OnPlaybackStopped(this); } - if (fadeRoutine.IsRunning) - fadeRoutine.IsRunning = false; - if (clearQueue) TrackQueue.Clear(); + StopFade(); ResetEncoder(); ClearTimeEvents(); source?.Dispose(); source = null; - RemoveTimeEvent("ManualStop"); - } - - /// - /// Smoothly fades out the volume over the specified duration and then stops the playback completely. - /// - /// The duration in seconds to fade out the audio. - /// If true, clears the upcoming tracks in the playlist. - public void Stop(float fadeOutDuration, bool clearQueue = true) - { - if (fadeOutDuration <= 0f || !IsPlaying) - { - Stop(clearQueue); - return; - } - - if (clearQueue) - TrackQueue.Clear(); - - float oldVolume = Volume; - FadeVolume(Volume, 0f, fadeOutDuration, onComplete: () => - { - Stop(clearQueue); - Volume = oldVolume; - }); } /// /// Fades the volume to a specific target over a given duration. + /// IMPORTANT: If the property is manually changed while a fade is in progress, the fade operation will be immediately aborted. /// /// The initial volume level when the fade begins. /// The final volume level to reach at the end of the fade. @@ -638,12 +613,21 @@ public void FadeVolume(float startVolume, float targetVolume, float duration = 3 fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, linear, onComplete).CancelWith(GameObject)); } + /// + /// Stops currently active volume fading process, leaving the volume at its exact current level. + /// + public void StopFade() + { + if (fadeRoutine.IsRunning) + fadeRoutine.IsRunning = false; + } + /// /// Restarts the currently playing track from the beginning. /// public void RestartTrack() { - if (!playBackRoutine.IsRunning || source == null) + if (!playBackRoutine.IsRunning) return; CurrentTime = 0.0; @@ -682,6 +666,23 @@ public void SkipTrack() } } + /// + /// Removes a specific track from the playback queue by its file path. + /// + /// The exact file path of the track to remove. + /// If true, removes the first occurrence; if false, removes the last occurrence. + /// true if the track was successfully found and removed; otherwise, false. + public bool RemoveTrack(string path, bool findFirst = true) + { + int index = findFirst ? TrackQueue.FindIndex(t => t.Path == path) : TrackQueue.FindLastIndex(t => t.Path == path); + + if (index == -1) + return false; + + TrackQueue.RemoveAt(index); + return true; + } + /// /// Shuffles the tracks in the into a random order with Fisher-Yates algorithm. /// @@ -728,23 +729,6 @@ public void RemoveTimeEvent(string id) UpdateNextTimeEventIndex(); } - /// - /// Removes a specific track from the queue by its file path. - /// - /// The exact file path of the track to remove. - /// If true, removes the first occurrence; if false, removes the last occurrence. - /// true if the track was successfully found and removed; otherwise, false. - public bool RemoveFromQueue(string path, bool findFirst = true) - { - int index = findFirst ? TrackQueue.FindIndex(t => t.Path == path) : TrackQueue.FindLastIndex(t => t.Path == path); - - if (index == -1) - return false; - - TrackQueue.RemoveAt(index); - return true; - } - /// /// Clears all time-based events for the current playback. /// @@ -868,10 +852,9 @@ private bool TrySwitchToNextTrack() private void ApplyPlaybackOptions(AudioPlaybackOptions options) { - float originalVolume = Volume; - if (options.FadeInDuration > 0f) { + float originalVolume = Volume; Volume = 0f; FadeVolume(0f, originalVolume, options.FadeInDuration); } @@ -879,7 +862,14 @@ private void ApplyPlaybackOptions(AudioPlaybackOptions options) if (options.FadeOutDuration > 0f && TotalDuration > options.FadeOutDuration) { double triggerTime = TotalDuration - options.FadeOutDuration; - AddTimeEvent(triggerTime, () => FadeVolume(Volume, 0f, options.FadeOutDuration, onComplete: () => Volume = originalVolume), id: "AutoFadeOut"); + AddTimeEvent( + triggerTime, + () => + { + float currentVol = Volume; + FadeVolume(currentVol, 0f, options.FadeOutDuration, onComplete: () => Volume = currentVol); + }, + id: AutoFadeOutId); } } @@ -914,7 +904,7 @@ private void SendPacket(int len) break; case SpeakerPlayMode.Player: - TargetPlayer?.Connection.Send(msg, Channel); + TargetPlayer?.Connection?.Send(msg, Channel); break; case SpeakerPlayMode.PlayerList: @@ -929,7 +919,7 @@ private void SendPacket(int len) foreach (Player ply in TargetPlayers) { - ply?.Connection.Send(segment, Channel); + ply?.Connection?.Send(segment, Channel); } } @@ -947,7 +937,7 @@ private void SendPacket(int len) foreach (Player ply in Player.List) { if (Predicate(ply)) - ply.Connection.Send(segment, Channel); + ply.Connection?.Send(segment, Channel); } } From faaef765bf0a0b4d119d9912b9c583e319a582f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 18:31:01 +0300 Subject: [PATCH 069/102] Remove Fade out because its doing api dirty and complicated --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 41 +++++-------------- .../Structs/AudioPlaybackOptions.cs | 7 ---- .../Audio => Structs}/AudioTimeEvent.cs | 3 +- 3 files changed, 12 insertions(+), 39 deletions(-) rename EXILED/Exiled.API/{Features/Audio => Structs}/AudioTimeEvent.cs (94%) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 70674000c..c931a7a67 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -42,11 +42,6 @@ namespace Exiled.API.Features.Toys /// public class Speaker : AdminToy, IWrapper { - /// - /// The unique identifier used for the automatically scheduled fade-out time event. - /// - public const string AutoFadeOutId = "AutoFadeOut"; - /// /// 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. @@ -71,17 +66,17 @@ public class Speaker : AdminToy, IWrapper private byte[] encoded; private float[] resampleBuffer; - private CoroutineHandle fadeRoutine; private CoroutineHandle playBackRoutine; + private CoroutineHandle fadeRoutine; private double resampleTime; private int resampleBufferFilled; + private int nextTimeEventIndex = 0; private int idChangeFrame; - private bool needsSyncWait = false; - private int nextTimeEventIndex = 0; - private bool isPitchDefault = true; private bool isPlayBackInitialized = false; + private bool isPitchDefault = true; + private bool needsSyncWait = false; /// /// Initializes a new instance of the class. @@ -450,14 +445,13 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null) /// The maximum distance at which the audio can be heard. /// The playback pitch level of the audio source. /// The duration in seconds over which the volume should smoothly increase from 0 to speaker volume. 0 means no fade in. - /// The duration in seconds over which the volume should smoothly decrease to 0 before the track ends. 0 means no fade out. /// 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 = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, float fadeInDuration = 0f, float fadeOutDuration = 0f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) + public static bool PlayFromPool(string path, Vector3 position, Transform parent = null, bool isSpatial = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, float fadeInDuration = 0f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) { Speaker speaker = Rent(parent, position); @@ -478,7 +472,6 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent { Stream = stream, FadeInDuration = fadeInDuration, - FadeOutDuration = fadeOutDuration, }; if (!speaker.Play(path, options)) @@ -835,6 +828,7 @@ private bool TrySwitchToNextTrack() LastTrackInfo = source.TrackInfo; CurrentOptions = nextTrack.Options; + StopFade(); ResetEncoder(); ClearTimeEvents(); resampleTime = 0.0; @@ -852,25 +846,12 @@ private bool TrySwitchToNextTrack() private void ApplyPlaybackOptions(AudioPlaybackOptions options) { - if (options.FadeInDuration > 0f) - { - float originalVolume = Volume; - Volume = 0f; - FadeVolume(0f, originalVolume, options.FadeInDuration); - } + if (options.FadeInDuration <= 0f) + return; - if (options.FadeOutDuration > 0f && TotalDuration > options.FadeOutDuration) - { - double triggerTime = TotalDuration - options.FadeOutDuration; - AddTimeEvent( - triggerTime, - () => - { - float currentVol = Volume; - FadeVolume(currentVol, 0f, options.FadeOutDuration, onComplete: () => Volume = currentVol); - }, - id: AutoFadeOutId); - } + float targetVolume = Volume; + Base.NetworkVolume = 0f; + FadeVolume(0f, targetVolume, options.FadeInDuration); } private void UpdateNextTimeEventIndex() diff --git a/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs b/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs index 3f4eefee9..c7cd896c8 100644 --- a/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs +++ b/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs @@ -20,7 +20,6 @@ public AudioPlaybackOptions() Stream = false; ClearQueue = false; FadeInDuration = 0f; - FadeOutDuration = 0f; } /// @@ -34,12 +33,6 @@ public AudioPlaybackOptions() /// public float FadeInDuration { get; set; } - /// - /// Gets or sets the duration in seconds over which the volume should smoothly decrease to 0 before the track ends automatically. - /// 0 means no fade-out. - /// - public float FadeOutDuration { get; set; } - /// /// Gets or sets a value indicating whether to clear any upcoming tracks in the playlist before playing the new track. /// diff --git a/EXILED/Exiled.API/Features/Audio/AudioTimeEvent.cs b/EXILED/Exiled.API/Structs/AudioTimeEvent.cs similarity index 94% rename from EXILED/Exiled.API/Features/Audio/AudioTimeEvent.cs rename to EXILED/Exiled.API/Structs/AudioTimeEvent.cs index 25cf3aebf..becca0cda 100644 --- a/EXILED/Exiled.API/Features/Audio/AudioTimeEvent.cs +++ b/EXILED/Exiled.API/Structs/AudioTimeEvent.cs @@ -1,11 +1,10 @@ -// ----------------------------------------------------------------------- // // Copyright (c) ExMod Team. All rights reserved. // Licensed under the CC BY-SA 3.0 license. // // ----------------------------------------------------------------------- -namespace Exiled.API.Features.Audio +namespace Exiled.API.Structs { using System; From d720c88c4fe088e1ed3c42a817d4c0540f2e0979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 19:10:29 +0300 Subject: [PATCH 070/102] Clean Api & new Event --- .../Features/Audio/SpeakerEvents.cs | 14 ++++++++ EXILED/Exiled.API/Features/Toys/Speaker.cs | 34 ++++++------------- .../Structs/AudioPlaybackOptions.cs | 7 ---- 3 files changed, 24 insertions(+), 31 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs index 7b9496ff5..4f4dbe5ca 100644 --- a/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs +++ b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs @@ -9,6 +9,8 @@ namespace Exiled.API.Features.Audio { using System; + using Exiled.API.Structs; + using Toys; /// @@ -36,6 +38,11 @@ public static class SpeakerEvents /// public static event Action PlaybackLooped; + /// + /// Invoked just before the speaker switches to the next track in the queue. + /// + public static event Action TrackSwitching; + /// /// Invoked when a speaker finishes playing its current audio track. /// @@ -70,6 +77,13 @@ public static class SpeakerEvents /// The instance. internal static void OnPlaybackLooped(Speaker speaker) => PlaybackLooped?.Invoke(speaker); + /// + /// Called just before the speaker switches to the next track in the queue. + /// + /// The instance. + /// The upcoming to be played. + internal static void OnTrackSwitching(Speaker speaker, QueuedTrack nextTrack) => TrackSwitching?.Invoke(speaker, nextTrack); + /// /// Called when a speaker finishes playing its current audio track. /// diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index c931a7a67..cab259745 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -116,6 +116,12 @@ internal Speaker(SpeakerToy speakerToy) /// public event Action OnPlaybackStopped; + /// + /// Invoked just before the speaker switches to the next track in the queue. + /// Passes the upcoming as an argument. + /// + public event Action OnTrackSwitching; + /// /// Gets the prefab. /// @@ -468,13 +474,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent speaker.ReturnToPoolAfter = true; - AudioPlaybackOptions options = new() - { - Stream = stream, - FadeInDuration = fadeInDuration, - }; - - if (!speaker.Play(path, options)) + if (!speaker.Play(path, new() { Stream = stream })) { speaker.ReturnToPool(); return false; @@ -556,8 +556,6 @@ public bool Play(string path, AudioPlaybackOptions options = default) LastTrackInfo = source.TrackInfo; - ApplyPlaybackOptions(options); - playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); return true; } @@ -808,6 +806,9 @@ private bool TrySwitchToNextTrack() while (TrackQueue.Count > 0) { QueuedTrack nextTrack = TrackQueue[0]; + OnTrackSwitching?.Invoke(nextTrack); + SpeakerEvents.OnTrackSwitching(this, nextTrack); + TrackQueue.RemoveAt(0); IPcmSource newSource; @@ -828,14 +829,11 @@ private bool TrySwitchToNextTrack() LastTrackInfo = source.TrackInfo; CurrentOptions = nextTrack.Options; - StopFade(); ResetEncoder(); ClearTimeEvents(); resampleTime = 0.0; resampleBufferFilled = 0; - ApplyPlaybackOptions(nextTrack.Options); - OnPlaybackStarted?.Invoke(); SpeakerEvents.OnPlaybackStarted(this); return true; @@ -844,16 +842,6 @@ private bool TrySwitchToNextTrack() return false; } - private void ApplyPlaybackOptions(AudioPlaybackOptions options) - { - if (options.FadeInDuration <= 0f) - return; - - float targetVolume = Volume; - Base.NetworkVolume = 0f; - FadeVolume(0f, targetVolume, options.FadeInDuration); - } - private void UpdateNextTimeEventIndex() { nextTimeEventIndex = 0; @@ -1071,8 +1059,6 @@ private IEnumerator PlayBackCoroutine() resampleTime = resampleBufferFilled = 0; nextTimeEventIndex = 0; - ApplyPlaybackOptions(CurrentOptions); - OnPlaybackLooped?.Invoke(); SpeakerEvents.OnPlaybackLooped(this); continue; diff --git a/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs b/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs index c7cd896c8..b16def6b8 100644 --- a/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs +++ b/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs @@ -19,7 +19,6 @@ public AudioPlaybackOptions() { Stream = false; ClearQueue = false; - FadeInDuration = 0f; } /// @@ -27,12 +26,6 @@ public AudioPlaybackOptions() /// public bool Stream { get; set; } - /// - /// Gets or sets the duration in seconds over which the volume should smoothly increase from 0 to the target volume at the start of playback. - /// 0 means no fade-in. - /// - public float FadeInDuration { get; set; } - /// /// Gets or sets a value indicating whether to clear any upcoming tracks in the playlist before playing the new track. /// From 749f01def8f49c538fab8f620680d2e2d11829f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 19:17:00 +0300 Subject: [PATCH 071/102] remove thing which im added --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index cab259745..f2bfa50b8 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -270,11 +270,6 @@ public float PlaybackProgress /// public List TimeEvents => field ??= new(); - /// - /// Gets the playback options of the currently playing track. - /// - public AudioPlaybackOptions CurrentOptions { get; private set; } - /// /// Gets or sets the playback pitch. /// @@ -545,7 +540,6 @@ public bool Play(string path, AudioPlaybackOptions options = default) try { - CurrentOptions = options; source = options.Stream ? new WavStreamSource(path) : new PreloadedPcmSource(path); } catch (Exception ex) @@ -827,7 +821,6 @@ private bool TrySwitchToNextTrack() source = newSource; LastTrackInfo = source.TrackInfo; - CurrentOptions = nextTrack.Options; ResetEncoder(); ClearTimeEvents(); From 786091e38824788caddc3f4997ef4c45d044106e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 19:24:11 +0300 Subject: [PATCH 072/102] remeove old arg --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index f2bfa50b8..ce4ed80a3 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -445,14 +445,13 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null) /// 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 duration in seconds over which the volume should smoothly increase from 0 to speaker volume. 0 means no fade in. /// 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 = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, float fadeInDuration = 0f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) + public static bool PlayFromPool(string path, Vector3 position, Transform parent = null, bool isSpatial = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) { Speaker speaker = Rent(parent, position); From 5cca4922b0ed2a14e2f5ed2e162fdafeac43196e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 20:34:02 +0300 Subject: [PATCH 073/102] doc --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index ce4ed80a3..3b8f1f2f7 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -684,7 +684,7 @@ public void ShuffleTracks() /// /// Adds an action to be executed at a specific time in seconds during the current playback. - /// WARNING: Heavy operations can cause audio interruptions. If you need to perform heavy operations, start a new Coroutine inside the action. + /// WARNING: Heavy operations can cause audio interruptions. If you need to perform heavy operations, start a MEC Coroutine inside the action. /// /// The exact time in seconds to trigger the action. /// The action to invoke when the specified time is reached. @@ -768,6 +768,7 @@ public void ReturnToPool() OnPlaybackPaused = null; OnPlaybackResumed = null; OnPlaybackLooped = null; + OnTrackSwitching = null; OnPlaybackFinished = null; OnPlaybackStopped = null; From 306f1f11fd88fa516d484d6f59e97979d400b9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 20:56:16 +0300 Subject: [PATCH 074/102] f --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 3b8f1f2f7..41eedea97 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -985,8 +985,15 @@ private void OnToyRemoved(AdminToyBase toy) AdminToyBase.OnRemoved -= OnToyRemoved; Stop(); - encoder?.Dispose(); + + OnPlaybackStarted = null; + OnPlaybackPaused = null; + OnPlaybackResumed = null; + OnPlaybackLooped = null; + OnTrackSwitching = null; + OnPlaybackFinished = null; + OnPlaybackStopped = null; } private IEnumerator PlayBackCoroutine() From 45d5a52636f8dba2afc6e6078920ba850db8dd5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 22 Mar 2026 21:42:38 +0300 Subject: [PATCH 075/102] renameing --- .../Audio/ScheduledEvent.cs} | 21 +++--- EXILED/Exiled.API/Features/Toys/Speaker.cs | 64 +++++++++---------- 2 files changed, 43 insertions(+), 42 deletions(-) rename EXILED/Exiled.API/{Structs/AudioTimeEvent.cs => Features/Audio/ScheduledEvent.cs} (59%) diff --git a/EXILED/Exiled.API/Structs/AudioTimeEvent.cs b/EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs similarity index 59% rename from EXILED/Exiled.API/Structs/AudioTimeEvent.cs rename to EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs index becca0cda..a87768f3c 100644 --- a/EXILED/Exiled.API/Structs/AudioTimeEvent.cs +++ b/EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs @@ -1,25 +1,26 @@ -// +// ----------------------------------------------------------------------- +// // Copyright (c) ExMod Team. All rights reserved. // Licensed under the CC BY-SA 3.0 license. // // ----------------------------------------------------------------------- -namespace Exiled.API.Structs +namespace Exiled.API.Features.Audio { using System; /// - /// Represents a time-based event for audio playback. + /// Represents a time-based action for audio playback. /// - public readonly struct AudioTimeEvent : IComparable + public class ScheduledEvent : IComparable { /// - /// Initializes a new instance of the struct. + /// Initializes a new instance of the class. /// /// The exact time in seconds to trigger the action. /// The action to execute. - /// /// The optional unique identifier for the event. If null, a random GUID will be generated automatically. - public AudioTimeEvent(double time, Action action, string id = null) + /// The optional unique identifier for the event. If null, a random GUID will be generated automatically. + public ScheduledEvent(double time, Action action, string id = null) { Time = time; Action = action; @@ -42,10 +43,10 @@ public AudioTimeEvent(double time, Action action, string id = null) public string Id { get; } /// - /// Compares this instance to another based on their trigger times. + /// Compares this instance to another based on their trigger times. /// - /// The other to compare to. + /// The other to compare to. /// A value that indicates the relative order of the events being compared. - public readonly int CompareTo(AudioTimeEvent other) => Time.CompareTo(other.Time); + public int CompareTo(ScheduledEvent other) => Time.CompareTo(other.Time); } } \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 41eedea97..481c5138f 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -71,7 +71,7 @@ public class Speaker : AdminToy, IWrapper private double resampleTime; private int resampleBufferFilled; - private int nextTimeEventIndex = 0; + private int nextScheduledEventIndex = 0; private int idChangeFrame; private bool isPlayBackInitialized = false; @@ -226,7 +226,7 @@ public double CurrentTime resampleBufferFilled = 0; ResetEncoder(); - UpdateNextTimeEventIndex(); + UpdateNextScheduledEventIndex(); } } @@ -268,7 +268,7 @@ public float PlaybackProgress /// /// Gets the list of time-based events for the current audio track. /// - public List TimeEvents => field ??= new(); + public List ScheduledEvents => field ??= new(); /// /// Gets or sets the playback pitch. @@ -356,12 +356,12 @@ public byte ControllerId get => Base.NetworkControllerId; set { - if (Base.NetworkControllerId != value) - { - Base.NetworkControllerId = value; - needsSyncWait = true; - idChangeFrame = Time.frameCount; - } + if (Base.NetworkControllerId == value) + return; + + Base.NetworkControllerId = value; + needsSyncWait = true; + idChangeFrame = Time.frameCount; } } @@ -575,7 +575,7 @@ public void Stop(bool clearQueue = true) StopFade(); ResetEncoder(); - ClearTimeEvents(); + ClearScheduledEvents(); source?.Dispose(); source = null; } @@ -689,14 +689,14 @@ public void ShuffleTracks() /// The exact time in seconds to trigger the action. /// The action to invoke when the specified time is reached. /// An optional unique string identifier for this event. If not provided, a random GUID will be assigned. - /// The unique string ID of the created time event, which can be used to remove it later via . - public string AddTimeEvent(double timeInSeconds, Action action, string id = null) + /// The unique string ID of the created time event, which can be used to remove it later via . + public string AddScheduledEvent(double timeInSeconds, Action action, string id = null) { - AudioTimeEvent timeEvent = new(timeInSeconds, action, id); + ScheduledEvent timeEvent = new(timeInSeconds, action, id); - TimeEvents.Add(timeEvent); - TimeEvents.Sort(); - UpdateNextTimeEventIndex(); + ScheduledEvents.Add(timeEvent); + ScheduledEvents.Sort(); + UpdateNextScheduledEventIndex(); return timeEvent.Id; } @@ -705,21 +705,21 @@ public string AddTimeEvent(double timeInSeconds, Action action, string id = null /// Removes a specific time-based event using its ID. /// /// The unique string identifier of the event to remove. - public void RemoveTimeEvent(string id) + public void RemoveScheduledEvent(string id) { - int removed = TimeEvents.RemoveAll(e => e.Id == id); + int removed = ScheduledEvents.RemoveAll(e => e.Id == id); if (removed > 0) - UpdateNextTimeEventIndex(); + UpdateNextScheduledEventIndex(); } /// /// Clears all time-based events for the current playback. /// - public void ClearTimeEvents() + public void ClearScheduledEvents() { - TimeEvents.Clear(); - nextTimeEventIndex = 0; + ScheduledEvents.Clear(); + nextScheduledEventIndex = 0; } /// @@ -823,7 +823,7 @@ private bool TrySwitchToNextTrack() LastTrackInfo = source.TrackInfo; ResetEncoder(); - ClearTimeEvents(); + ClearScheduledEvents(); resampleTime = 0.0; resampleBufferFilled = 0; @@ -835,14 +835,14 @@ private bool TrySwitchToNextTrack() return false; } - private void UpdateNextTimeEventIndex() + private void UpdateNextScheduledEventIndex() { - nextTimeEventIndex = 0; + nextScheduledEventIndex = 0; double current = CurrentTime; - while (nextTimeEventIndex < TimeEvents.Count && TimeEvents[nextTimeEventIndex].Time <= current) + while (nextScheduledEventIndex < ScheduledEvents.Count && ScheduledEvents[nextScheduledEventIndex].Time <= current) { - nextTimeEventIndex++; + nextScheduledEventIndex++; } } @@ -1057,7 +1057,7 @@ private IEnumerator PlayBackCoroutine() source.Reset(); timeAccumulator = 0; resampleTime = resampleBufferFilled = 0; - nextTimeEventIndex = 0; + nextScheduledEventIndex = 0; OnPlaybackLooped?.Invoke(); SpeakerEvents.OnPlaybackLooped(this); @@ -1080,18 +1080,18 @@ private IEnumerator PlayBackCoroutine() yield break; } - while (nextTimeEventIndex < TimeEvents.Count && CurrentTime >= TimeEvents[nextTimeEventIndex].Time) + while (nextScheduledEventIndex < ScheduledEvents.Count && CurrentTime >= ScheduledEvents[nextScheduledEventIndex].Time) { try { - TimeEvents[nextTimeEventIndex].Action?.Invoke(); + ScheduledEvents[nextScheduledEventIndex].Action?.Invoke(); } catch (Exception ex) { - Log.Error($"[Speaker] Failed to execute scheduled time event at {TimeEvents[nextTimeEventIndex].Time:F2}s.\nException Details: {ex}"); + Log.Error($"[Speaker] Failed to execute scheduled time event at {ScheduledEvents[nextScheduledEventIndex].Time:F2}s.\nException Details: {ex}"); } - nextTimeEventIndex++; + nextScheduledEventIndex++; } yield return Timing.WaitForOneFrame; From 8f227dd18ca26cab4dd41e06b89d06c86f3c3585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 23 Mar 2026 00:43:14 +0300 Subject: [PATCH 076/102] Standalone System --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 4 ++++ EXILED/Exiled.Events/Handlers/Internal/Round.cs | 2 -- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 481c5138f..924176efa 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -26,6 +26,8 @@ namespace Exiled.API.Features.Toys using NorthwoodLib.Pools; + using RoundRestarting; + using UnityEngine; using VoiceChat; @@ -78,6 +80,8 @@ public class Speaker : AdminToy, IWrapper private bool isPitchDefault = true; private bool needsSyncWait = false; + static Speaker() => RoundRestart.OnRestartTriggered += Pool.Clear; + /// /// Initializes a new instance of the class. /// diff --git a/EXILED/Exiled.Events/Handlers/Internal/Round.cs b/EXILED/Exiled.Events/Handlers/Internal/Round.cs index 886f8f05b..9aded5ccf 100644 --- a/EXILED/Exiled.Events/Handlers/Internal/Round.cs +++ b/EXILED/Exiled.Events/Handlers/Internal/Round.cs @@ -66,8 +66,6 @@ public static void OnWaitingForPlayers() /// public static void OnRestartingRound() { - Speaker.Pool.Clear(); - Scp049Role.TurnedPlayers.Clear(); Scp173Role.TurnedPlayers.Clear(); Scp096Role.TurnedPlayers.Clear(); From 7a909452e9ccc33e21a9f002f20ee4257a5b84ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 23 Mar 2026 00:44:38 +0300 Subject: [PATCH 077/102] remove useles using which is i added --- EXILED/Exiled.Events/Handlers/Internal/Round.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/EXILED/Exiled.Events/Handlers/Internal/Round.cs b/EXILED/Exiled.Events/Handlers/Internal/Round.cs index 9aded5ccf..cebcffa74 100644 --- a/EXILED/Exiled.Events/Handlers/Internal/Round.cs +++ b/EXILED/Exiled.Events/Handlers/Internal/Round.cs @@ -18,7 +18,6 @@ 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; From cc004bc7371d06dde949094b5da081c143634ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 23 Mar 2026 01:00:30 +0300 Subject: [PATCH 078/102] bool return --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 924176efa..3f20f4aa5 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -709,12 +709,16 @@ public string AddScheduledEvent(double timeInSeconds, Action action, string id = /// Removes a specific time-based event using its ID. /// /// The unique string identifier of the event to remove. - public void RemoveScheduledEvent(string id) + /// true if the event was successfully found and removed; otherwise, false. + public bool RemoveScheduledEvent(string id) { int removed = ScheduledEvents.RemoveAll(e => e.Id == id); - if (removed > 0) - UpdateNextScheduledEventIndex(); + if (removed <= 0) + return false; + + UpdateNextScheduledEventIndex(); + return true; } /// From 3c31f838f7eb5e2b91a81a1377cf690bb6d72b95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 23 Mar 2026 01:53:18 +0300 Subject: [PATCH 079/102] Add Filter --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 7 +++++++ EXILED/Exiled.API/Interfaces/IAudioFilter.cs | 21 ++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 EXILED/Exiled.API/Interfaces/IAudioFilter.cs diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 3f20f4aa5..b8d8c306f 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -264,6 +264,11 @@ public float PlaybackProgress /// public TrackData LastTrackInfo { get; private set; } + /// + /// Gets or sets the custom audio filter applied to the PCM data right before encoding. + /// + public IAudioFilter Filter { get; set; } + /// /// Gets the queue of audio tracks to be played sequentially. /// @@ -1046,6 +1051,8 @@ private IEnumerator PlayBackCoroutine() ResampleFrame(); } + Filter?.Process(frame); + int len = encoder.Encode(frame, encoded); if (len > 2) diff --git a/EXILED/Exiled.API/Interfaces/IAudioFilter.cs b/EXILED/Exiled.API/Interfaces/IAudioFilter.cs new file mode 100644 index 000000000..3ba8842d4 --- /dev/null +++ b/EXILED/Exiled.API/Interfaces/IAudioFilter.cs @@ -0,0 +1,21 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Interfaces +{ + /// + /// Represents a custom filter for the speaker. + /// + public interface IAudioFilter + { + /// + /// Processes the raw PCM audio frame directly before it is encoded and sending. + /// + /// The array of PCM audio samples. + void Process(float[] frame); + } +} From 0dd20cdb7f3fbb067219a3f2fed62422fce60ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Tue, 24 Mar 2026 00:45:46 +0300 Subject: [PATCH 080/102] Open Modular Api + Url play + Player play --- EXILED/Exiled.API/Exiled.API.csproj | 1 + .../Features/Audio/PlayerVoiceSource.cs | 173 ++++++++++++++++++ .../Features/Audio/PreloadedPcmSource.cs | 9 +- .../Features/Audio/WavStreamSource.cs | 2 +- .../Exiled.API/Features/Audio/WavUtility.cs | 27 +++ .../Features/Audio/WebPreloadWavPcmSource.cs | 156 ++++++++++++++++ EXILED/Exiled.API/Features/Toys/Speaker.cs | 140 ++++++++++---- .../Structs/AudioPlaybackOptions.cs | 34 ---- EXILED/Exiled.API/Structs/QueuedTrack.cs | 22 ++- 9 files changed, 475 insertions(+), 89 deletions(-) create mode 100644 EXILED/Exiled.API/Features/Audio/PlayerVoiceSource.cs create mode 100644 EXILED/Exiled.API/Features/Audio/WebPreloadWavPcmSource.cs delete mode 100644 EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs diff --git a/EXILED/Exiled.API/Exiled.API.csproj b/EXILED/Exiled.API/Exiled.API.csproj index 70f81bf0d..6dea6426a 100644 --- a/EXILED/Exiled.API/Exiled.API.csproj +++ b/EXILED/Exiled.API/Exiled.API.csproj @@ -40,6 +40,7 @@ + diff --git a/EXILED/Exiled.API/Features/Audio/PlayerVoiceSource.cs b/EXILED/Exiled.API/Features/Audio/PlayerVoiceSource.cs new file mode 100644 index 000000000..558f4246a --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PlayerVoiceSource.cs @@ -0,0 +1,173 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + using System.Buffers; + using System.Collections.Concurrent; + + using Exiled.API.Features; + using Exiled.API.Interfaces; + using Exiled.API.Structs; + + using LabApi.Events.Arguments.PlayerEvents; + + using VoiceChat; + using VoiceChat.Codec; + + /// + /// Provides a that captures and decodes live microphone input from a specific player. + /// + public sealed class PlayerVoiceSource : IPcmSource + { + private readonly int sourcePlayerId; + private readonly Player sourcePlayer; + private readonly OpusDecoder decoder; + private readonly ConcurrentQueue pcmQueue; + + private float[] decodeBuffer; + private DateTime lastPacketTime; + + /// + /// Initializes a new instance of the class. + /// + /// The player whose voice will be captured. + /// The broadcast delay in seconds. + public PlayerVoiceSource(Player player, float delay = 0f) + { + sourcePlayer = player; + sourcePlayerId = player.Id; + Delay = delay; + + decoder = new OpusDecoder(); + pcmQueue = new ConcurrentQueue(); + decodeBuffer = ArrayPool.Shared.Rent(VoiceChatSettings.PacketSizePerChannel); + + TrackInfo = new TrackData + { + Path = $"Live Stream from {player.Nickname}'s Microphone", + Duration = double.PositiveInfinity, + }; + + FillDelayBuffer(); + lastPacketTime = DateTime.UtcNow; + + LabApi.Events.Handlers.PlayerEvents.SendingVoiceMessage += OnVoiceChatting; + } + + /// + /// Gets or sets the broadcast delay in seconds. + /// + public float Delay { get; set; } + + /// + /// Gets the metadata of the streaming track. + /// + public TrackData TrackInfo { get; } + + /// + /// Gets the total duration of the audio in seconds. + /// + public double TotalDuration => double.PositiveInfinity; + + /// + /// Gets or sets the current playback position in seconds. + /// + public double CurrentTime + { + get => 0.0; + set => Seek(value); + } + + /// + /// Gets a value indicating whether the end of the stream has been reached. + /// + public bool Ended => sourcePlayer == null || !sourcePlayer.IsConnected; + + /// + /// Reads PCM data from the stream into the specified buffer. + /// + /// The buffer to fill with PCM data. + /// The offset in the buffer at which to begin writing. + /// The maximum number of samples to read. + /// The number of samples read. + public int Read(float[] buffer, int offset, int count) + { + if (Ended) + return 0; + + int written = 0; + while (written < count && pcmQueue.TryDequeue(out float sample)) + { + buffer[offset + written] = sample; + written++; + } + + return written; + } + + /// + public void Seek(double seconds) + { + Log.Info("[PlayerVoiceSource] Seeking is not supported for live player voice streams."); + } + + /// + public void Reset() + { + Log.Info("[PlayerVoiceSource] Resetting is not supported for live player voice streams."); + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + LabApi.Events.Handlers.PlayerEvents.SendingVoiceMessage -= OnVoiceChatting; + decoder?.Dispose(); + if (decodeBuffer != null) + { + ArrayPool.Shared.Return(decodeBuffer); + decodeBuffer = null; + } + } + + private void FillDelayBuffer() + { + if (Delay <= 0) + return; + + int delaySamples = (int)(Delay * VoiceChatSettings.SampleRate); + for (int i = 0; i < delaySamples; i++) + { + pcmQueue.Enqueue(0f); + } + } + + private void OnVoiceChatting(PlayerSendingVoiceMessageEventArgs ev) + { + if (ev.Player.PlayerId != sourcePlayerId) + return; + + if (ev.Message.DataLength <= 2) + return; + + if ((DateTime.UtcNow - lastPacketTime).TotalSeconds > 0.5) + FillDelayBuffer(); + + lastPacketTime = DateTime.UtcNow; + + int decodedSamples = decoder.Decode(ev.Message.Data, ev.Message.DataLength, decodeBuffer); + + for (int i = 0; i < decodedSamples; i++) + { + pcmQueue.Enqueue(decodeBuffer[i]); + } + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs index a78399bf3..3a759c42c 100644 --- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs @@ -15,18 +15,11 @@ namespace Exiled.API.Features.Audio using VoiceChat; /// - /// Represents a preloaded PCM audio source. + /// Provides a preloaded with Pcm data or file. /// public sealed class PreloadedPcmSource : IPcmSource { - /// - /// The PCM data buffer. - /// private readonly float[] data; - - /// - /// The current read position in the data buffer. - /// private int pos; /// diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs index a20add8e7..4d4df6d6d 100644 --- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs +++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs @@ -18,7 +18,7 @@ namespace Exiled.API.Features.Audio using VoiceChat; /// - /// Provides a PCM audio source from a WAV file stream. + /// Provides a from a WAV file stream. /// public sealed class WavStreamSource : IPcmSource { diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index c130f6122..59e18f3ba 100644 --- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -13,6 +13,7 @@ namespace Exiled.API.Features.Audio using System.IO; using System.Runtime.InteropServices; + using Exiled.API.Interfaces; using Exiled.API.Structs; using VoiceChat; @@ -24,6 +25,20 @@ public static class WavUtility { private const float Divide = 1f / 32768f; + /// + /// Evaluates the given path or URL and returns the appropriate for .wav playback. + /// + /// The local file path or web URL of the .wav file. + /// If true, streams local files directly from disk. If false, preloads them into memory. (Ignored for web URLs). + /// An initialized . + public static IPcmSource CreateWavPcmSource(string path, bool stream) + { + if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + return new WebPreloadWavPcmSource(path); + + return stream ? new WavStreamSource(path) : new PreloadedPcmSource(path); + } + /// /// Converts a WAV file at the specified path to a PCM float array. /// @@ -31,6 +46,18 @@ public static class WavUtility /// A tuple containing an array of floats representing the PCM data and its TrackData. public static (float[] PcmData, TrackData TrackInfo) WavToPcm(string path) { + if (!File.Exists(path)) + { + Log.Error($"[Speaker] The specified local file does not exist, path: `{path}`"); + throw new FileNotFoundException("File does not exist"); + } + + if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) + { + Log.Error($"[Speaker] The file type '{Path.GetExtension(path)}' is not supported for wav utility. Please use .wav file."); + throw new InvalidDataException("Unsupported WAV format."); + } + using FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.Read); int length = (int)fs.Length; diff --git a/EXILED/Exiled.API/Features/Audio/WebPreloadWavPcmSource.cs b/EXILED/Exiled.API/Features/Audio/WebPreloadWavPcmSource.cs new file mode 100644 index 000000000..956d06674 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/WebPreloadWavPcmSource.cs @@ -0,0 +1,156 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + using System.Collections.Generic; + using System.IO; + + using Exiled.API.Features; + using Exiled.API.Interfaces; + using Exiled.API.Structs; + + using MEC; + + using UnityEngine.Networking; + + /// + /// Provides a that downloads a .wav file from a URL and preloads it for playback. + /// + public sealed class WebPreloadWavPcmSource : IPcmSource + { + private string tempFilePath; + private IPcmSource internalSource; + + private bool isReady = false; + private bool isFailed = false; + + /// + /// Initializes a new instance of the class. + /// + /// The direct URL to the .wav file. + public WebPreloadWavPcmSource(string url) + { + TrackInfo = default; + Timing.RunCoroutine(Download(url)); + } + + /// + /// Gets the metadata of the preloaded track. + /// + public TrackData TrackInfo { get; private set; } + + /// + /// Gets the total duration of the audio in seconds. + /// + public double TotalDuration => isReady && internalSource != null ? internalSource.TotalDuration : 0.0; + + /// + /// Gets or sets the current playback position in seconds. + /// + public double CurrentTime + { + get => isReady && internalSource != null ? internalSource.CurrentTime : 0.0; + set => Seek(value); + } + + /// + /// Gets a value indicating whether the end of the playback has been reached. + /// + public bool Ended => isFailed || (isReady && internalSource != null && internalSource.Ended); + + /// + /// Reads PCM data from the audio source into the specified buffer. + /// + /// The buffer to fill with PCM data. + /// The offset in the buffer at which to begin writing. + /// The maximum number of samples to read. + /// The number of samples read. + public int Read(float[] buffer, int offset, int count) + { + if (isFailed) + return 0; + + if (!isReady || internalSource == null) + { + Array.Clear(buffer, offset, count); + return count; + } + + return internalSource.Read(buffer, offset, count); + } + + /// + /// Seeks to the specified position in the playback. + /// + /// The position in seconds to seek to. + public void Seek(double seconds) + { + if (isReady && internalSource != null) + internalSource.CurrentTime = seconds; + } + + /// + /// Resets the playback position to the start. + /// + public void Reset() + { + if (isReady && internalSource != null) + internalSource.Reset(); + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + internalSource?.Dispose(); + + if (!string.IsNullOrEmpty(tempFilePath) && File.Exists(tempFilePath)) + { + try + { + File.Delete(tempFilePath); + } + catch (Exception ex) + { + Log.Error($"[WebPreloadWavPcmSource] Failed to delete temporary audio file at path: {tempFilePath}. Please delete it yourself.\nException Details: {ex}"); + } + } + } + + private IEnumerator Download(string url) + { + using UnityWebRequest www = UnityWebRequest.Get(url); + yield return Timing.WaitUntilDone(www.SendWebRequest()); + + if (www.result != UnityWebRequest.Result.Success) + { + Log.Error($"[WebPreloadWavPcmSource] Failed to download audio! URL: {url} | Error: {www.error}"); + isFailed = true; + yield break; + } + + tempFilePath = Path.Combine(Paths.Exiled, $"temp_audio_{Guid.NewGuid()}.wav"); + + try + { + File.WriteAllBytes(tempFilePath, www.downloadHandler.data); + + internalSource = new PreloadedPcmSource(tempFilePath); + TrackInfo = internalSource.TrackInfo; + isReady = true; + } + catch (Exception e) + { + Log.Error($"[WebPreloadWavPcmSource] Failed to read the downloaded file! Ensure the link points to a valid .WAV file.\nException Details: {e.Message}"); + isFailed = true; + } + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index b8d8c306f..21328f11d 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -61,7 +61,6 @@ public class Speaker : AdminToy, IWrapper private static readonly Vector3 SpeakerParkPosition = Vector3.down * 999; - private IPcmSource source; private OpusEncoder encoder; private float[] frame; @@ -219,13 +218,13 @@ public bool IsPaused /// public double CurrentTime { - get => source?.CurrentTime ?? 0.0; + get => CurrentSource?.CurrentTime ?? 0.0; set { - if (source == null) + if (CurrentSource == null) return; - source.CurrentTime = value; + CurrentSource.CurrentTime = value; resampleTime = 0.0; resampleBufferFilled = 0; @@ -238,7 +237,7 @@ public double CurrentTime /// Gets the total duration of the current track in seconds. /// Returns 0 if not playing. /// - public double TotalDuration => source?.TotalDuration ?? 0.0; + public double TotalDuration => CurrentSource?.TotalDuration ?? 0.0; /// /// Gets the remaining playback time in seconds. @@ -259,6 +258,11 @@ public float PlaybackProgress } } + /// + /// Gets the currently playing audio source. + /// + public IPcmSource CurrentSource { get; private set; } + /// /// Gets the metadata information (Title, Artist, Duration) of the last played audio track. /// @@ -477,7 +481,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent speaker.ReturnToPoolAfter = true; - if (!speaker.Play(path, new() { Stream = stream })) + if (!speaker.PlayWav(path, true, stream)) { speaker.ReturnToPool(); return false; @@ -527,28 +531,37 @@ public static byte GetNextFreeControllerId(byte? preferredId = null) /// Plays a wav file through this speaker. (File must be 16-bit, mono, and 48kHz.) /// /// The path to the wav file. - /// The configuration options for playback. + /// If true, clears the upcoming tracks in the playlist before starting playback. + /// If true, the file is streamed from disk; otherwise, it is fully loaded into memory. /// true if the audio file was successfully found, loaded, and playback started; otherwise, false. - public bool Play(string path, AudioPlaybackOptions options = default) + public bool PlayWav(string path, bool clearQueue = true, bool stream = false) { - if (!File.Exists(path)) + if (string.IsNullOrWhiteSpace(path)) { - Log.Error($"[Speaker] The specified file does not exist, path: `{path}`."); + Log.Error("[Speaker] Provided path or URL cannot be null or empty!"); return false; } - if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) + bool isUrl = path.StartsWith("http", StringComparison.OrdinalIgnoreCase); + if (!isUrl) { - Log.Error($"[Speaker] The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file."); - return false; - } + if (!File.Exists(path)) + { + Log.Error($"[Speaker] The specified local file does not exist, path: `{path}`"); + return false; + } - TryInitializePlayBack(); - Stop(options.ClearQueue); + if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) + { + Log.Error($"[Speaker] Unsupported file format! Only .wav files are allowed on PlayWav method. Path: `{path}`"); + return false; + } + } + IPcmSource newSource; try { - source = options.Stream ? new WavStreamSource(path) : new PreloadedPcmSource(path); + newSource = WavUtility.CreateWavPcmSource(path, stream); } catch (Exception ex) { @@ -556,7 +569,49 @@ public bool Play(string path, AudioPlaybackOptions options = default) return false; } - LastTrackInfo = source.TrackInfo; + return Play(newSource, clearQueue); + } + + /// + /// Plays the live voice of a specific player through this speaker. + /// + /// The player whose voice will be broadcasted. + /// Outputs the newly created instance. + /// The broadcast delay in seconds. + /// If true, clears the upcoming tracks in the playlist before starting playback. + /// true if the playback started successfully; otherwise, false. + public bool PlayPlayerVoice(Player player, out PlayerVoiceSource voiceSource, float delayInSeconds = 0f, bool clearQueue = true) + { + voiceSource = null; + if (player == null) + { + Log.Error("[Speaker] Source player cannot be null when streaming live microphone!"); + return false; + } + + voiceSource = new PlayerVoiceSource(player, delayInSeconds); + return Play(voiceSource, clearQueue); + } + + /// + /// Plays audio directly from a provided PCM source. + /// + /// The custom IPcmSource to play. + /// If true, clears the upcoming tracks in the playlist before starting playback. + /// true if the source is valid and playback started; otherwise, false. + public bool Play(IPcmSource customSource, bool clearQueue = true) + { + if (customSource == null) + { + Log.Error("[Speaker] Provided custom IPcmSource is null!"); + return false; + } + + TryInitializePlayBack(); + Stop(clearQueue); + + CurrentSource = customSource; + LastTrackInfo = CurrentSource.TrackInfo; playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); return true; @@ -585,8 +640,8 @@ public void Stop(bool clearQueue = true) StopFade(); ResetEncoder(); ClearScheduledEvents(); - source?.Dispose(); - source = null; + CurrentSource?.Dispose(); + CurrentSource = null; } /// @@ -627,17 +682,29 @@ public void RestartTrack() } /// - /// Adds an audio file to the playback queue with specific options. If nothing is playing, playback starts immediately. + /// Helper method to easily queue a .wav file with stream support. + /// + /// The absolute path to the .wav file. + /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory. + /// true if successfully queued or started. + public bool QueueWavTrack(string path, bool isStream = false) + { + Func factory = () => WavUtility.CreateWavPcmSource(path, isStream); + + return QueueTrack(new QueuedTrack(path, factory)); + } + + /// + /// Adds a track to the playback queue. If nothing is playing, playback starts immediately. /// - /// The path to the wav file to enqueue. - /// The specific playback configuration for this track. + /// The queued track containing its creation logic and optional identifier. /// true if successfully queued or started. - public bool QueueTrack(string path, AudioPlaybackOptions options = default) + public bool QueueTrack(QueuedTrack track) { if (!playBackRoutine.IsRunning && !IsPaused) - return Play(path, options); + return Play(track.SourceProvider.Invoke()); - TrackQueue.Add(new QueuedTrack(path, options)); + TrackQueue.Add(track); return true; } @@ -667,7 +734,7 @@ public void SkipTrack() /// true if the track was successfully found and removed; otherwise, false. public bool RemoveTrack(string path, bool findFirst = true) { - int index = findFirst ? TrackQueue.FindIndex(t => t.Path == path) : TrackQueue.FindLastIndex(t => t.Path == path); + int index = findFirst ? TrackQueue.FindIndex(t => t.Name == path) : TrackQueue.FindLastIndex(t => t.Name == path); if (index == -1) return false; @@ -821,8 +888,7 @@ private bool TrySwitchToNextTrack() IPcmSource newSource; try { - bool useStream = nextTrack.Options.Stream; - newSource = useStream ? new WavStreamSource(nextTrack.Path) : new PreloadedPcmSource(nextTrack.Path); + newSource = nextTrack.SourceProvider.Invoke(); } catch (Exception ex) { @@ -830,10 +896,10 @@ private bool TrySwitchToNextTrack() continue; } - source?.Dispose(); - source = newSource; + CurrentSource?.Dispose(); + CurrentSource = newSource; - LastTrackInfo = source.TrackInfo; + LastTrackInfo = CurrentSource.TrackInfo; ResetEncoder(); ClearScheduledEvents(); @@ -938,7 +1004,7 @@ private void ResampleFrame() if (resampleBufferFilled == 0) { int toRead = resampleBuffer.Length - 4; - int actualRead = source.Read(resampleBuffer, 0, toRead); + int actualRead = CurrentSource.Read(resampleBuffer, 0, toRead); if (actualRead == 0) { @@ -960,7 +1026,7 @@ private void ResampleFrame() resampleBuffer[0] = resampleBuffer[resampleBufferFilled - 1]; int toRead = resampleBuffer.Length - 5; - int actualRead = source.Read(resampleBuffer, 1, toRead); + int actualRead = CurrentSource.Read(resampleBuffer, 1, toRead); if (actualRead == 0) { @@ -1042,7 +1108,7 @@ private IEnumerator PlayBackCoroutine() if (isPitchDefault) { - int read = source.Read(frame, 0, FrameSize); + int read = CurrentSource.Read(frame, 0, FrameSize); if (read < FrameSize) Array.Clear(frame, read, FrameSize - read); } @@ -1058,7 +1124,7 @@ private IEnumerator PlayBackCoroutine() if (len > 2) SendPacket(len); - if (!source.Ended) + if (!CurrentSource.Ended) continue; yield return Timing.WaitForOneFrame; @@ -1069,7 +1135,7 @@ private IEnumerator PlayBackCoroutine() if (Loop) { ResetEncoder(); - source.Reset(); + CurrentSource.Reset(); timeAccumulator = 0; resampleTime = resampleBufferFilled = 0; nextScheduledEventIndex = 0; diff --git a/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs b/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs deleted file mode 100644 index b16def6b8..000000000 --- a/EXILED/Exiled.API/Structs/AudioPlaybackOptions.cs +++ /dev/null @@ -1,34 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (c) ExMod Team. All rights reserved. -// Licensed under the CC BY-SA 3.0 license. -// -// ----------------------------------------------------------------------- - -namespace Exiled.API.Structs -{ - /// - /// Represents the configuration options for audio playback. - /// - public struct AudioPlaybackOptions - { - /// - /// Initializes a new instance of the struct with default values. - /// - public AudioPlaybackOptions() - { - Stream = false; - ClearQueue = false; - } - - /// - /// Gets or sets a value indicating whether to stream the audio directly from the disk (true) or preload it entirely into RAM (false). - /// - public bool Stream { get; set; } - - /// - /// Gets or sets a value indicating whether to clear any upcoming tracks in the playlist before playing the new track. - /// - public bool ClearQueue { get; set; } - } -} \ No newline at end of file diff --git a/EXILED/Exiled.API/Structs/QueuedTrack.cs b/EXILED/Exiled.API/Structs/QueuedTrack.cs index 6fd35411e..958a0ba9a 100644 --- a/EXILED/Exiled.API/Structs/QueuedTrack.cs +++ b/EXILED/Exiled.API/Structs/QueuedTrack.cs @@ -7,6 +7,10 @@ namespace Exiled.API.Structs { + using System; + + using Exiled.API.Interfaces; + /// /// Represents a track waiting in the queue, along with its specific playback options. /// @@ -15,22 +19,22 @@ public readonly struct QueuedTrack /// /// Initializes a new instance of the struct. /// - /// The path to the .wav file. - /// The specific playback configuration for this track. - public QueuedTrack(string path, AudioPlaybackOptions options = default) + /// The name, path, or identifier of the track (used for displaying or removing from queue). + /// A function that returns the instantiated . + public QueuedTrack(string name, Func sourceFactory) { - Path = path; - Options = options; + Name = name; + SourceProvider = sourceFactory; } /// - /// Gets the absolute path to the .wav file. + /// Gets the name, path, or identifier of the track. /// - public string Path { get; } + public string Name { get; } /// - /// Gets the playback options configured for this specific track. + /// Gets the provider function used to create the custom audio source on demand. /// - public AudioPlaybackOptions Options { get; } + public Func SourceProvider { get; } } } \ No newline at end of file From 9e0af85509ac2997a3e520c39f41cd812adf7994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Tue, 24 Mar 2026 21:43:45 +0300 Subject: [PATCH 081/102] \ --- .../{ => PcmSources}/PlayerVoiceSource.cs | 34 ++++-- .../PreloadWebWavPcmSource.cs} | 41 ++----- .../{ => PcmSources}/PreloadedPcmSource.cs | 4 +- .../Audio/{ => PcmSources}/WavStreamSource.cs | 4 +- .../Exiled.API/Features/Audio/WavUtility.cs | 91 ++++++++++++--- EXILED/Exiled.API/Features/Toys/Speaker.cs | 109 ++++++++++++------ 6 files changed, 191 insertions(+), 92 deletions(-) rename EXILED/Exiled.API/Features/Audio/{ => PcmSources}/PlayerVoiceSource.cs (81%) rename EXILED/Exiled.API/Features/Audio/{WebPreloadWavPcmSource.cs => PcmSources/PreloadWebWavPcmSource.cs} (76%) rename EXILED/Exiled.API/Features/Audio/{ => PcmSources}/PreloadedPcmSource.cs (97%) rename EXILED/Exiled.API/Features/Audio/{ => PcmSources}/WavStreamSource.cs (98%) diff --git a/EXILED/Exiled.API/Features/Audio/PlayerVoiceSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs similarity index 81% rename from EXILED/Exiled.API/Features/Audio/PlayerVoiceSource.cs rename to EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs index 558f4246a..e0be83497 100644 --- a/EXILED/Exiled.API/Features/Audio/PlayerVoiceSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs @@ -5,7 +5,7 @@ // // ----------------------------------------------------------------------- -namespace Exiled.API.Features.Audio +namespace Exiled.API.Features.Audio.PcmSources { using System; using System.Buffers; @@ -37,12 +37,15 @@ public sealed class PlayerVoiceSource : IPcmSource /// Initializes a new instance of the class. /// /// The player whose voice will be captured. + /// If true, prevents the player's original voice message's from being heard while broadcasting. /// The broadcast delay in seconds. - public PlayerVoiceSource(Player player, float delay = 0f) + public PlayerVoiceSource(Player player, bool blockOriginalVoice = false, float delay = 0f) { sourcePlayer = player; sourcePlayerId = player.Id; + Delay = delay; + BlockOriginalVoice = blockOriginalVoice; decoder = new OpusDecoder(); pcmQueue = new ConcurrentQueue(); @@ -50,7 +53,7 @@ public PlayerVoiceSource(Player player, float delay = 0f) TrackInfo = new TrackData { - Path = $"Live Stream from {player.Nickname}'s Microphone", + Path = $"{player.Nickname}-Mic", Duration = double.PositiveInfinity, }; @@ -65,6 +68,16 @@ public PlayerVoiceSource(Player player, float delay = 0f) /// public float Delay { get; set; } + /// + /// Gets or sets the threshold in seconds of silence required before the delay buffer is refilled. + /// + public double SilenceThreshold { get; set; } = 0.5; + + /// + /// Gets or sets a value indicating whether the player's original voice chat should be blocked while being broadcasted by this source. + /// + public bool BlockOriginalVoice { get; set; } = false; + /// /// Gets the metadata of the streaming track. /// @@ -101,14 +114,14 @@ public int Read(float[] buffer, int offset, int count) if (Ended) return 0; - int written = 0; - while (written < count && pcmQueue.TryDequeue(out float sample)) + int read = 0; + while (read < count && pcmQueue.TryDequeue(out float sample)) { - buffer[offset + written] = sample; - written++; + buffer[offset + read] = sample; + read++; } - return written; + return read; } /// @@ -157,7 +170,10 @@ private void OnVoiceChatting(PlayerSendingVoiceMessageEventArgs ev) if (ev.Message.DataLength <= 2) return; - if ((DateTime.UtcNow - lastPacketTime).TotalSeconds > 0.5) + if (BlockOriginalVoice) + ev.IsAllowed = false; + + if ((DateTime.UtcNow - lastPacketTime).TotalSeconds > SilenceThreshold) FillDelayBuffer(); lastPacketTime = DateTime.UtcNow; diff --git a/EXILED/Exiled.API/Features/Audio/WebPreloadWavPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs similarity index 76% rename from EXILED/Exiled.API/Features/Audio/WebPreloadWavPcmSource.cs rename to EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs index 956d06674..764b17a32 100644 --- a/EXILED/Exiled.API/Features/Audio/WebPreloadWavPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs @@ -1,15 +1,14 @@ // ----------------------------------------------------------------------- -// +// // Copyright (c) ExMod Team. All rights reserved. // Licensed under the CC BY-SA 3.0 license. // // ----------------------------------------------------------------------- -namespace Exiled.API.Features.Audio +namespace Exiled.API.Features.Audio.PcmSources { using System; using System.Collections.Generic; - using System.IO; using Exiled.API.Features; using Exiled.API.Interfaces; @@ -22,19 +21,18 @@ namespace Exiled.API.Features.Audio /// /// Provides a that downloads a .wav file from a URL and preloads it for playback. /// - public sealed class WebPreloadWavPcmSource : IPcmSource + public sealed class PreloadWebWavPcmSource : IPcmSource { - private string tempFilePath; private IPcmSource internalSource; private bool isReady = false; private bool isFailed = false; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The direct URL to the .wav file. - public WebPreloadWavPcmSource(string url) + public PreloadWebWavPcmSource(string url) { TrackInfo = default; Timing.RunCoroutine(Download(url)); @@ -105,24 +103,9 @@ public void Reset() } /// - /// Releases all resources used by the . + /// Releases all resources used by the . /// - public void Dispose() - { - internalSource?.Dispose(); - - if (!string.IsNullOrEmpty(tempFilePath) && File.Exists(tempFilePath)) - { - try - { - File.Delete(tempFilePath); - } - catch (Exception ex) - { - Log.Error($"[WebPreloadWavPcmSource] Failed to delete temporary audio file at path: {tempFilePath}. Please delete it yourself.\nException Details: {ex}"); - } - } - } + public void Dispose() => internalSource?.Dispose(); private IEnumerator Download(string url) { @@ -136,14 +119,14 @@ private IEnumerator Download(string url) yield break; } - tempFilePath = Path.Combine(Paths.Exiled, $"temp_audio_{Guid.NewGuid()}.wav"); - try { - File.WriteAllBytes(tempFilePath, www.downloadHandler.data); + byte[] rawBytes = www.downloadHandler.data; + (float[] pcmData, TrackData trackInfo) = WavUtility.WavToPcm(rawBytes); + trackInfo.Path = url; - internalSource = new PreloadedPcmSource(tempFilePath); - TrackInfo = internalSource.TrackInfo; + internalSource = new PreloadedPcmSource(pcmData); + TrackInfo = trackInfo; isReady = true; } catch (Exception e) diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs similarity index 97% rename from EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs rename to EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs index 3a759c42c..70e2fc152 100644 --- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs @@ -5,10 +5,12 @@ // // ----------------------------------------------------------------------- -namespace Exiled.API.Features.Audio +namespace Exiled.API.Features.Audio.PcmSources { using System; + using Exiled.API.Features.Audio; + using Exiled.API.Interfaces; using Exiled.API.Structs; diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs similarity index 98% rename from EXILED/Exiled.API/Features/Audio/WavStreamSource.cs rename to EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs index 4d4df6d6d..63534a59b 100644 --- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs @@ -5,13 +5,15 @@ // // ----------------------------------------------------------------------- -namespace Exiled.API.Features.Audio +namespace Exiled.API.Features.Audio.PcmSources { using System; using System.Buffers; using System.IO; using System.Runtime.InteropServices; + using Exiled.API.Features.Audio; + using Exiled.API.Interfaces; using Exiled.API.Structs; diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index 59e18f3ba..f66118ae9 100644 --- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -13,6 +13,7 @@ namespace Exiled.API.Features.Audio using System.IO; using System.Runtime.InteropServices; + using Exiled.API.Features.Audio.PcmSources; using Exiled.API.Interfaces; using Exiled.API.Structs; @@ -31,10 +32,10 @@ public static class WavUtility /// The local file path or web URL of the .wav file. /// If true, streams local files directly from disk. If false, preloads them into memory. (Ignored for web URLs). /// An initialized . - public static IPcmSource CreateWavPcmSource(string path, bool stream) + public static IPcmSource CreatePcmSource(string path, bool stream) { if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - return new WebPreloadWavPcmSource(path); + return new PreloadWebWavPcmSource(path); return stream ? new WavStreamSource(path) : new PreloadedPcmSource(path); } @@ -66,24 +67,12 @@ public static (float[] PcmData, TrackData TrackInfo) WavToPcm(string path) try { int bytesRead = fs.Read(rentedBuffer, 0, length); - using MemoryStream ms = new(rentedBuffer, 0, bytesRead); - TrackData metaData = SkipHeader(ms); - - int headerOffset = (int)ms.Position; - int dataLength = bytesRead - headerOffset; - - Span audioDataSpan = rentedBuffer.AsSpan(headerOffset, dataLength); - Span samples = MemoryMarshal.Cast(audioDataSpan); + (float[] PcmData, TrackData TrackInfo) result = ParseWavSpanToPcm(ms, rentedBuffer.AsSpan(0, bytesRead)); + result.TrackInfo.Path = path; - float[] pcm = new float[samples.Length]; - - for (int i = 0; i < samples.Length; i++) - pcm[i] = samples[i] * Divide; - - metaData.Path = path; - return (pcm, metaData); + return result; } finally { @@ -91,6 +80,41 @@ public static (float[] PcmData, TrackData TrackInfo) WavToPcm(string path) } } + /// + /// Converts a WAV byte array to a PCM float array. + /// + /// The raw bytes of the WAV file. + /// A tuple containing an array of floats representing the PCM data and its TrackData. + public static (float[] PcmData, TrackData TrackInfo) WavToPcm(byte[] data) + { + using MemoryStream ms = new(data, 0, data.Length); + + return ParseWavSpanToPcm(ms, data.AsSpan()); + } + + /// + /// Parses the WAV header from the provided stream and converts the remaining audio data span into a PCM float array. + /// + /// The stream used to read and skip the WAV header. + /// The complete span of WAV audio data including the header. + /// A tuple containing an array of floats representing the PCM data and its TrackData. + public static (float[] PcmData, TrackData TrackInfo) ParseWavSpanToPcm(Stream stream, ReadOnlySpan audioData) + { + TrackData metaData = SkipHeader(stream); + + int headerOffset = (int)stream.Position; + int dataLength = audioData.Length - headerOffset; + + ReadOnlySpan samples = MemoryMarshal.Cast(audioData.Slice(headerOffset, dataLength)); + + float[] pcm = new float[samples.Length]; + + for (int i = 0; i < samples.Length; i++) + pcm[i] = samples[i] * Divide; + + return (pcm, metaData); + } + /// /// Skips the WAV file header and validates that the format is PCM16 mono with the specified sample rate. /// @@ -205,5 +229,38 @@ public static TrackData SkipHeader(Stream stream) return trackData; } + + /// + /// Validates a given local file path or web URL to ensure it is suitable for WAV processing. + /// + /// The local file path or web URL to validate. + /// Outputs a specific error message explaining why the validation failed. Returns if successful. + /// true if the path is valid and safe to process; otherwise, false. + public static bool TryValidatePath(string path, out string errorMessage) + { + errorMessage = string.Empty; + if (string.IsNullOrWhiteSpace(path)) + { + errorMessage = "Provided path or URL cannot be null or empty!"; + return false; + } + + if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!File.Exists(path)) + { + errorMessage = $"The specified local file does not exist. Path: `{path}`"; + return false; + } + + if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) + { + errorMessage = $"Unsupported file format! Only .wav files are allowed. Path: `{path}`"; + return false; + } + + return true; + } } } \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 21328f11d..ae69c8373 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -16,8 +16,11 @@ namespace Exiled.API.Features.Toys using Enums; using Exiled.API.Features.Audio; + using Exiled.API.Features.Audio.PcmSources; using Exiled.API.Structs; + using HarmonyLib; + using Interfaces; using MEC; @@ -448,7 +451,7 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null) } /// - /// 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.) + /// Rents a speaker from the pool, plays a local wav file or web stream one time, and automatically returns it to the pool afterwards. (File must be 16 bit, mono and 48khz.) /// /// The path to the wav file. /// The local position of the speaker. @@ -463,9 +466,55 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null) /// The target player if PlayMode is Player. /// The list of target players if PlayMode is PlayerList. /// The condition if PlayMode is Predicate. + /// An optional audio filter to apply to the source. /// 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 = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null) + public static bool PlayFromPool(string path, Vector3 position, Transform parent = null, bool isSpatial = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null, IAudioFilter filter = null) { + if (!WavUtility.TryValidatePath(path, out string errorMessage)) + { + Log.Error($"[Speaker] {errorMessage}"); + return false; + } + + IPcmSource source; + try + { + source = WavUtility.CreatePcmSource(path, stream); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to initialize audio source for PlayFromPool. Path: '{path}'.\n{ex}"); + return false; + } + + return PlayFromPool(source, position, parent, isSpatial, volume, minDistance, maxDistance, pitch, playMode, targetPlayer, targetPlayers, predicate, filter); + } + + /// + /// Rents a speaker from the pool, plays a custom PCM source one time, and automatically returns it to the pool afterwards. + /// + /// The custom IPcmSource to play. + /// The local 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. + /// The target player if PlayMode is Player. + /// The list of target players if PlayMode is PlayerList. + /// The condition if PlayMode is Predicate. + /// An optional audio filter to apply to the source. + /// true if the source is valid and playback started; otherwise, false. + public static bool PlayFromPool(IPcmSource source, Vector3 position, Transform parent = null, bool isSpatial = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null, IAudioFilter filter = null) + { + if (source == null) + { + Log.Error("[Speaker] Provided custom IPcmSource is null for PlayFromPool!"); + return false; + } + Speaker speaker = Rent(parent, position); speaker.Volume = volume; @@ -478,10 +527,11 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent speaker.Predicate = predicate; speaker.TargetPlayer = targetPlayer; speaker.TargetPlayers = targetPlayers; + speaker.Filter = filter; speaker.ReturnToPoolAfter = true; - if (!speaker.PlayWav(path, true, stream)) + if (!speaker.Play(source, true)) { speaker.ReturnToPool(); return false; @@ -528,40 +578,24 @@ public static byte GetNextFreeControllerId(byte? preferredId = null) } /// - /// Plays a wav file through this speaker. (File must be 16-bit, mono, and 48kHz.) + /// Plays a local wav file or web URL through this speaker. (File must be 16-bit, mono, and 48kHz.) /// /// The path to the wav file. /// If true, clears the upcoming tracks in the playlist before starting playback. /// If true, the file is streamed from disk; otherwise, it is fully loaded into memory. /// true if the audio file was successfully found, loaded, and playback started; otherwise, false. - public bool PlayWav(string path, bool clearQueue = true, bool stream = false) + public bool Play(string path, bool clearQueue = true, bool stream = false) { - if (string.IsNullOrWhiteSpace(path)) + if (!WavUtility.TryValidatePath(path, out string errorMessage)) { - Log.Error("[Speaker] Provided path or URL cannot be null or empty!"); + Log.Error($"[Speaker] {errorMessage}"); return false; } - bool isUrl = path.StartsWith("http", StringComparison.OrdinalIgnoreCase); - if (!isUrl) - { - if (!File.Exists(path)) - { - Log.Error($"[Speaker] The specified local file does not exist, path: `{path}`"); - return false; - } - - if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) - { - Log.Error($"[Speaker] Unsupported file format! Only .wav files are allowed on PlayWav method. Path: `{path}`"); - return false; - } - } - IPcmSource newSource; try { - newSource = WavUtility.CreateWavPcmSource(path, stream); + newSource = WavUtility.CreatePcmSource(path, stream); } catch (Exception ex) { @@ -576,21 +610,30 @@ public bool PlayWav(string path, bool clearQueue = true, bool stream = false) /// Plays the live voice of a specific player through this speaker. /// /// The player whose voice will be broadcasted. - /// Outputs the newly created instance. + /// If true, prevents the player's original voice message's from being heard while broadcasting. /// The broadcast delay in seconds. /// If true, clears the upcoming tracks in the playlist before starting playback. /// true if the playback started successfully; otherwise, false. - public bool PlayPlayerVoice(Player player, out PlayerVoiceSource voiceSource, float delayInSeconds = 0f, bool clearQueue = true) + public bool PlayLiveVoice(Player player, bool blockOriginalVoice = false, float delayInSeconds = 0f, bool clearQueue = true) { - voiceSource = null; if (player == null) { Log.Error("[Speaker] Source player cannot be null when streaming live microphone!"); return false; } - voiceSource = new PlayerVoiceSource(player, delayInSeconds); - return Play(voiceSource, clearQueue); + PlayerVoiceSource source; + try + { + source = new PlayerVoiceSource(player, blockOriginalVoice, delayInSeconds); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to initialize live voice stream for player '{player.Nickname}' ({player.Id}).\nException Details: {ex}"); + return false; + } + + return Play(source, clearQueue); } /// @@ -687,12 +730,7 @@ public void RestartTrack() /// The absolute path to the .wav file. /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory. /// true if successfully queued or started. - public bool QueueWavTrack(string path, bool isStream = false) - { - Func factory = () => WavUtility.CreateWavPcmSource(path, isStream); - - return QueueTrack(new QueuedTrack(path, factory)); - } + public bool QueueWav(string path, bool isStream = false) => QueueTrack(new QueuedTrack(path, () => WavUtility.CreatePcmSource(path, isStream))); /// /// Adds a track to the playback queue. If nothing is playing, playback starts immediately. @@ -839,6 +877,7 @@ public void ReturnToPool() TargetPlayers = null; Pitch = 1f; + Filter = null; resampleTime = 0.0; resampleBufferFilled = 0; isPitchDefault = true; From cee0a6c9dcfe0bd1815cbc89cf3acb049e2e4949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Wed, 25 Mar 2026 00:26:37 +0300 Subject: [PATCH 082/102] add filter samples --- .../Features/Audio/Filters/EchoFilter.cs | 173 +++++++++++++ .../Audio/Filters/PitchShiftFilter.cs | 239 ++++++++++++++++++ EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +- 3 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs create mode 100644 EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs diff --git a/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs new file mode 100644 index 000000000..7a2ad248c --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs @@ -0,0 +1,173 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.Filters +{ + using System; + + using Exiled.API.Interfaces; + + using UnityEngine; + + /// + /// A true DSP Fractional Delay Filter equipped with an RBJ Butterworth Biquad Filter. + /// + public sealed class EchoFilter : IAudioFilter + { + private const float MaxDelayMs = 10000f; + private readonly float[] delayBuffer; + private readonly int maxBufferLength; + + private int writeIndex; + + private float b0; + private float b1; + private float b2; + private float a1; + private float a2; + private float x1; + private float x2; + private float y1; + private float y2; + + /// + /// Initializes a new instance of the class. + /// + /// The delay time in milliseconds (10 - 10000). + /// The feedback multiplier determining how long the echo lasts. + /// The volume of the original sound. + /// The volume of the echoed sound. + /// How much high-frequency is absorbed each bounce (0 = pure digital ring, 1 = heavy muffled echo). + public EchoFilter(float delayMs = 300f, float decay = 0.5f, float dry = 1.0f, float wet = 0.5f, float damp = 0.3f) + { + maxBufferLength = (int)(VoiceChat.VoiceChatSettings.SampleRate * (MaxDelayMs / 1000f)); + delayBuffer = new float[maxBufferLength]; + + writeIndex = 0; + x1 = x2 = y1 = y2 = 0f; + + Delay = delayMs; + Feedback = decay; + DryMix = dry; + WetMix = wet; + Damping = damp; + } + + /// + /// Gets or sets the delay time in milliseconds. Dynamically adjusts the read head. + /// + public float Delay + { + get => field; + set => field = Mathf.Clamp(value, 10f, MaxDelayMs); + } + + /// + /// Gets or sets the feedback multiplier. Determines how many times the echo repeats before dying out. + /// + public float Feedback + { + get => field; + set => field = Mathf.Clamp01(value); + } + + /// + /// Gets or sets the volume of the original (dry) unaffected sound. + /// + public float DryMix + { + get => field; + set => field = Mathf.Clamp01(value); + } + + /// + /// Gets or sets the volume of the delayed (wet) echoed sound. + /// + public float WetMix + { + get => field; + set => field = Mathf.Clamp01(value); + } + + /// + /// Gets or sets the damping coefficient. Automatically recalculates the RBJ Biquad coefficients. + /// + public float Damping + { + get => field; + set + { + field = Mathf.Clamp01(value); + CalculateBiquad(field); + } + } + + /// + /// Processes the raw PCM audio frame directly before it is encoded and sending. + /// + /// The array of PCM audio samples. + public void Process(float[] frame) + { + float currentDelayMs = Delay; + float currentFeedback = Feedback; + float currentDry = DryMix; + float currentWet = WetMix; + + float delaySamples = VoiceChat.VoiceChatSettings.SampleRate * (currentDelayMs / 1000f); + + for (int i = 0; i < frame.Length; i++) + { + float input = frame[i]; + float readPos = writeIndex - delaySamples; + if (readPos < 0) + readPos += maxBufferLength; + + int index1 = (int)readPos; + int index2 = (index1 + 1) % maxBufferLength; + float frac = readPos - index1; + + float delayedSample = (delayBuffer[index1] * (1f - frac)) + (delayBuffer[index2] * frac); + float filteredSample = (b0 * delayedSample) + (b1 * x1) + (b2 * x2) - (a1 * y1) - (a2 * y2); + + x2 = x1; + x1 = delayedSample; + y2 = y1; + y1 = filteredSample; + + float output = (input * currentDry) + (filteredSample * currentWet); + delayBuffer[writeIndex] = input + (filteredSample * currentFeedback); + + writeIndex++; + if (writeIndex >= maxBufferLength) + writeIndex = 0; + + frame[i] = output / (1f + Mathf.Abs(output)); + } + } + + /// + /// Calculates the Robert Bristow-Johnson (RBJ) Audio EQ parameters for the low-pass filter. + /// + private void CalculateBiquad(float dampValue) + { + float cutoffFrequency = Mathf.Lerp(20000f, 500f, dampValue); + + if (cutoffFrequency >= VoiceChat.VoiceChatSettings.SampleRate / 2f) + cutoffFrequency = (VoiceChat.VoiceChatSettings.SampleRate / 2f) - 100f; + + float w0 = 2f * Mathf.PI * cutoffFrequency / VoiceChat.VoiceChatSettings.SampleRate; + float alpha = Mathf.Sin(w0) / (2f * 0.7071f); + + float a0 = 1f + alpha; + b0 = ((1f - Mathf.Cos(w0)) / 2f) / a0; + b1 = (1f - Mathf.Cos(w0)) / a0; + b2 = ((1f - Mathf.Cos(w0)) / 2f) / a0; + a1 = (-2f * Mathf.Cos(w0)) / a0; + a2 = (1f - alpha) / a0; + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs new file mode 100644 index 000000000..1ae354605 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs @@ -0,0 +1,239 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.Filters +{ + using System; + + using Exiled.API.Interfaces; + + using UnityEngine; + + /// + /// A true DSP Granular Pitch Shifter based on the smbPitchShift algorithm. + /// + public sealed class PitchShiftFilter : IAudioFilter + { + private const int MaxFrameLength = 8192; + + private readonly float[] gInFIFO = new float[MaxFrameLength]; + private readonly float[] gOutFIFO = new float[MaxFrameLength]; + private readonly float[] gFFTworksp = new float[2 * MaxFrameLength]; + private readonly float[] gLastPhase = new float[(MaxFrameLength / 2) + 1]; + private readonly float[] gSumPhase = new float[(MaxFrameLength / 2) + 1]; + private readonly float[] gOutputAccum = new float[2 * MaxFrameLength]; + private readonly float[] gAnaFreq = new float[MaxFrameLength]; + private readonly float[] gAnaMagn = new float[MaxFrameLength]; + private readonly float[] gSynFreq = new float[MaxFrameLength]; + private readonly float[] gSynMagn = new float[MaxFrameLength]; + + private long gRover = 0; + + /// + /// Initializes a new instance of the class. + /// + /// The pitch multiplier. Above 1.0 for Helium/Thin voice, below 1.0 for Deep/Monster voice. + public PitchShiftFilter(float pitch = 1.5f) + { + Pitch = pitch; + } + + /// + /// Gets or sets the pitch multiplier dynamically during playback. + /// + public float Pitch + { + get => field; + set => field = Mathf.Clamp(value, 0.1f, 4.0f); + } + + /// + /// Processes the raw PCM audio frame directly before it is encoded and sending. + /// + /// The array of PCM audio samples. + public void Process(float[] frame) + { + if (Mathf.Abs(Pitch - 1.0f) < 0.001f) + return; + + SmbPitchShift(Pitch, frame.Length, 2048, 4, VoiceChat.VoiceChatSettings.SampleRate, frame, frame); + } + + /// + /// Stephan M. Bernsee's Phase Vocoder routine. + /// + private void SmbPitchShift(float pitchShift, long numSampsToProcess, long fftFrameSize, long osamp, float sampleRate, float[] indata, float[] outdata) + { + double magn, phase, tmp, window, real, imag; + double freqPerBin, expct; + long i, k, qpd, index, inFifoLatency, stepSize, fftFrameSize2; + + fftFrameSize2 = fftFrameSize / 2; + stepSize = fftFrameSize / osamp; + freqPerBin = sampleRate / (double)fftFrameSize; + expct = 2.0 * Math.PI * (double)stepSize / (double)fftFrameSize; + inFifoLatency = fftFrameSize - stepSize; + + if (gRover == 0) + gRover = inFifoLatency; + + for (i = 0; i < numSampsToProcess; i++) + { + gInFIFO[gRover] = indata[i]; + outdata[i] = gOutFIFO[gRover - inFifoLatency]; + gRover++; + + if (gRover >= fftFrameSize) + { + gRover = inFifoLatency; + + for (k = 0; k < fftFrameSize; k++) + { + window = (-0.5 * Math.Cos(2.0 * Math.PI * k / (double)fftFrameSize)) + 0.5; + gFFTworksp[2 * k] = (float)(gInFIFO[k] * window); + gFFTworksp[(2 * k) + 1] = 0.0f; + } + + SmbFft(gFFTworksp, fftFrameSize, -1); + + for (k = 0; k <= fftFrameSize2; k++) + { + real = gFFTworksp[2 * k]; + imag = gFFTworksp[(2 * k) + 1]; + + magn = 2.0 * Math.Sqrt((real * real) + (imag * imag)); + phase = Math.Atan2(imag, real); + + tmp = phase - gLastPhase[k]; + gLastPhase[k] = (float)phase; + + tmp -= (double)k * expct; + qpd = (long)(tmp / Math.PI); + if (qpd >= 0) + qpd += qpd & 1; + else + qpd -= qpd & 1; + tmp -= Math.PI * (double)qpd; + + tmp = osamp * tmp / (2.0 * Math.PI); + tmp = ((double)k * freqPerBin) + (tmp * freqPerBin); + + gAnaMagn[k] = (float)magn; + gAnaFreq[k] = (float)tmp; + } + + for (int zero = 0; zero < fftFrameSize; zero++) + { + gSynMagn[zero] = 0; + gSynFreq[zero] = 0; + } + + for (k = 0; k <= fftFrameSize2; k++) + { + index = (long)(k * pitchShift); + if (index <= fftFrameSize2) + { + gSynMagn[index] += gAnaMagn[k]; + gSynFreq[index] = gAnaFreq[k] * pitchShift; + } + } + + for (k = 0; k <= fftFrameSize2; k++) + { + magn = gSynMagn[k]; + tmp = gSynFreq[k]; + + tmp -= (double)k * freqPerBin; + tmp /= freqPerBin; + tmp = 2.0 * Math.PI * tmp / osamp; + tmp += (double)k * expct; + + gSumPhase[k] += (float)tmp; + phase = gSumPhase[k]; + + gFFTworksp[2 * k] = (float)(magn * Math.Cos(phase)); + gFFTworksp[(2 * k) + 1] = (float)(magn * Math.Sin(phase)); + } + + for (k = fftFrameSize + 2; k < 2 * fftFrameSize; k++) + gFFTworksp[k] = 0.0f; + + SmbFft(gFFTworksp, fftFrameSize, 1); + + for (k = 0; k < fftFrameSize; k++) + { + window = (-0.5 * Math.Cos(2.0 * Math.PI * (double)k / (double)fftFrameSize)) + 0.5; + gOutputAccum[k] += (float)(2.0 * window * gFFTworksp[2 * k] / (fftFrameSize2 * osamp)); + } + + for (k = 0; k < stepSize; k++) + gOutFIFO[k] = gOutputAccum[k]; + + Array.Copy(gOutputAccum, stepSize, gOutputAccum, 0, fftFrameSize); + for (k = 0; k < inFifoLatency; k++) + gInFIFO[k] = gInFIFO[k + stepSize]; + } + } + } + + private void SmbFft(float[] fftBuffer, long fftFrameSize, long sign) + { + float wr, wi, arg, temp; + float tr, ti, ur, ui; + long i, bitm, j, le, le2, k; + + for (i = 2; i < (2 * fftFrameSize) - 2; i += 2) + { + for (bitm = 2, j = 0; bitm < 2 * fftFrameSize; bitm <<= 1) + { + if ((i & bitm) != 0) + j++; + + j <<= 1; + } + + if (i < j) + { + temp = fftBuffer[i]; + fftBuffer[i] = fftBuffer[j]; + fftBuffer[j] = temp; + temp = fftBuffer[i + 1]; + fftBuffer[i + 1] = fftBuffer[j + 1]; + fftBuffer[j + 1] = temp; + } + } + + long max = (long)((Math.Log(fftFrameSize) / Math.Log(2.0)) + 0.5); + for (k = 0, le = 2; k < max; k++) + { + le <<= 1; + le2 = le >> 1; + ur = 1.0f; + ui = 0.0f; + arg = (float)(Math.PI / (le2 >> 1)); + wr = (float)Math.Cos(arg); + wi = (float)(sign * Math.Sin(arg)); + for (j = 0; j < le2; j += 2) + { + for (i = j; i < 2 * fftFrameSize; i += le) + { + tr = (fftBuffer[i + le2] * ur) - (fftBuffer[i + le2 + 1] * ui); + ti = (fftBuffer[i + le2] * ui) + (fftBuffer[i + le2 + 1] * ur); + fftBuffer[i + le2] = fftBuffer[i] - tr; + fftBuffer[i + le2 + 1] = fftBuffer[i + 1] - ti; + fftBuffer[i] += tr; + fftBuffer[i + 1] += ti; + } + + tr = (ur * wr) - (ui * wi); + ui = (ur * wi) + (ui * wr); + ur = tr; + } + } + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index ae69c8373..f2a17a76c 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -730,7 +730,7 @@ public void RestartTrack() /// The absolute path to the .wav file. /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory. /// true if successfully queued or started. - public bool QueueWav(string path, bool isStream = false) => QueueTrack(new QueuedTrack(path, () => WavUtility.CreatePcmSource(path, isStream))); + public bool QueueTrack(string path, bool isStream = false) => QueueTrack(new QueuedTrack(path, () => WavUtility.CreatePcmSource(path, isStream))); /// /// Adds a track to the playback queue. If nothing is playing, playback starts immediately. From a3afd6c2702029ff1f1677a7dfd1d055c412f4e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Wed, 25 Mar 2026 00:34:05 +0300 Subject: [PATCH 083/102] nh --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index f2a17a76c..e98802ea9 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -263,6 +263,7 @@ public float PlaybackProgress /// /// Gets the currently playing audio source. + /// Pre-made filters are available in the namespace. /// public IPcmSource CurrentSource { get; private set; } From ecedc1b9fa3ad891882206972e374ccdf7443020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Thu, 26 Mar 2026 15:25:42 +0300 Subject: [PATCH 084/102] . --- .../Exiled.API/Features/Audio/WavUtility.cs | 2 +- EXILED/Exiled.API/Features/Toys/Speaker.cs | 23 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index f66118ae9..f1e1080a8 100644 --- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -30,7 +30,7 @@ public static class WavUtility /// Evaluates the given path or URL and returns the appropriate for .wav playback. /// /// The local file path or web URL of the .wav file. - /// If true, streams local files directly from disk. If false, preloads them into memory. (Ignored for web URLs). + /// If true, streams local files directly from disk. If false, preloads them into memory (Ignored for web URLs). /// An initialized . public static IPcmSource CreatePcmSource(string path, bool stream) { diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index e98802ea9..31d0b6f45 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -454,7 +454,7 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null) /// /// Rents a speaker from the pool, plays a local wav file or web stream one time, and automatically returns it to the pool afterwards. (File must be 16 bit, mono and 48khz.) /// - /// The path to the wav file. + /// The path/url to the wav file. /// The local position of the speaker. /// The parent transform, if any. /// Whether the audio source is spatialized. @@ -463,7 +463,7 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null) /// 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. + /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs). /// The target player if PlayMode is Player. /// The list of target players if PlayMode is PlayerList. /// The condition if PlayMode is Predicate. @@ -581,9 +581,9 @@ public static byte GetNextFreeControllerId(byte? preferredId = null) /// /// Plays a local wav file or web URL through this speaker. (File must be 16-bit, mono, and 48kHz.) /// - /// The path to the wav file. + /// The path/url to the wav file. /// If true, clears the upcoming tracks in the playlist before starting playback. - /// If true, the file is streamed from disk; otherwise, it is fully loaded into memory. + /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs). /// true if the audio file was successfully found, loaded, and playback started; otherwise, false. public bool Play(string path, bool clearQueue = true, bool stream = false) { @@ -726,12 +726,21 @@ public void RestartTrack() } /// - /// Helper method to easily queue a .wav file with stream support. + /// Helper method to easily queue a .wav file/url with stream support. /// /// The absolute path to the .wav file. - /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory. + /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs). /// true if successfully queued or started. - public bool QueueTrack(string path, bool isStream = false) => QueueTrack(new QueuedTrack(path, () => WavUtility.CreatePcmSource(path, isStream))); + public bool QueueTrack(string path, bool isStream = false) + { + if (!WavUtility.TryValidatePath(path, out string errorMessage)) + { + Log.Error($"[Speaker] {errorMessage}"); + return false; + } + + return QueueTrack(new QueuedTrack(path, () => WavUtility.CreatePcmSource(path, isStream))); + } /// /// Adds a track to the playback queue. If nothing is playing, playback starts immediately. From 496ad283542e45ca97f7b4073cfdfacf83a5813d Mon Sep 17 00:00:00 2001 From: "@Someone" <45270312+Someone-193@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:56:13 -0400 Subject: [PATCH 085/102] fix: 14.2.6 update (#781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update * remove MarshmallowFF fix * Update doc 14.2.0.6 * fix footprint ctor for after player leaves tbh the error itself might come from whenever mirror decides to remove peers and stuff cuz async * I committed the commented out version when I was trying to recreate the bug 💔 * add docs + TryRaycastRoom fix --------- Co-authored-by: Yamato <66829532+louis1706@users.noreply.github.com> --- EXILED/EXILED.props | 2 +- .../Extensions/DamageTypeExtensions.cs | 9 +- EXILED/Exiled.API/Features/Items/Scp1509.cs | 36 ++++---- .../Exiled.Events/Handlers/Internal/Round.cs | 3 - .../Patches/Events/Item/Inspect.cs | 3 +- .../Scp1509/InspectingAndTriggeringAttack.cs | 3 +- .../Patches/Fixes/FixMarshmallowManFF.cs | 91 ------------------- .../Patches/Fixes/FootprintConstructorFix.cs | 58 ++++++++++++ .../Patches/Fixes/TryRaycastRoomFix.cs | 68 ++++++++++++++ EXILED/Exiled.Loader/AutoUpdateFiles.cs | 2 +- .../SCPSLRessources/NW_Documentation.md | 23 ++++- 11 files changed, 174 insertions(+), 124 deletions(-) delete mode 100644 EXILED/Exiled.Events/Patches/Fixes/FixMarshmallowManFF.cs create mode 100644 EXILED/Exiled.Events/Patches/Fixes/FootprintConstructorFix.cs create mode 100644 EXILED/Exiled.Events/Patches/Fixes/TryRaycastRoomFix.cs diff --git a/EXILED/EXILED.props b/EXILED/EXILED.props index af916a48b..395d4bd5f 100644 --- a/EXILED/EXILED.props +++ b/EXILED/EXILED.props @@ -15,7 +15,7 @@ - 9.13.1 + 9.13.2 false diff --git a/EXILED/Exiled.API/Extensions/DamageTypeExtensions.cs b/EXILED/Exiled.API/Extensions/DamageTypeExtensions.cs index 6fde63477..8c06d82d0 100644 --- a/EXILED/Exiled.API/Extensions/DamageTypeExtensions.cs +++ b/EXILED/Exiled.API/Extensions/DamageTypeExtensions.cs @@ -164,6 +164,8 @@ public static DamageType GetDamageType(DamageHandlerBase damageHandlerBase) return DamageType.GrayCandy; case Scp1509DamageHandler: return DamageType.Scp1509; + case MarshmallowDamageHandler: + return DamageType.Marshmallow; case Scp049DamageHandler scp049DamageHandler: return scp049DamageHandler.DamageSubType switch { @@ -204,13 +206,6 @@ public static DamageType GetDamageType(DamageHandlerBase damageHandlerBase) Log.Warn($"{nameof(DamageTypeExtensions)}.{nameof(damageHandlerBase)}: No matching {nameof(DamageType)} for {nameof(UniversalDamageHandler)} with ID {translation.Id}, type will be reported as {DamageType.Unknown}. Report this to EXILED Devs."); return DamageType.Unknown; } - - case AttackerDamageHandler attackerDamageHandler: - { - if (Player.TryGet(attackerDamageHandler.Attacker, out Player attacker) && attacker.CurrentItem?.Type == ItemType.MarshmallowItem) - return DamageType.Marshmallow; - return DamageType.Unknown; - } } return DamageType.Unknown; diff --git a/EXILED/Exiled.API/Features/Items/Scp1509.cs b/EXILED/Exiled.API/Features/Items/Scp1509.cs index b6c51626e..010aaf666 100644 --- a/EXILED/Exiled.API/Features/Items/Scp1509.cs +++ b/EXILED/Exiled.API/Features/Items/Scp1509.cs @@ -44,9 +44,9 @@ internal Scp1509() public new Scp1509Item Base { get; } /// - /// Gets the instance. + /// Gets the instance. /// - public Scp1509RespawnEligibility RespawnEligibility => Base._respawnEligibility; + public Scp1509RespawnCriteriaManager RespawnCriteriaManager => Base.RespawnCriteriaManager; /// /// Gets or sets the shield regeneration rate. @@ -89,8 +89,8 @@ public float UnequipDecayDelay /// public double NextResurrectTime { - get => Base._nextResurrectTime; - set => Base._nextResurrectTime = value; + get => Base.NextResurrectTime; + set => Base.NextResurrectTime = value; } /// @@ -107,8 +107,8 @@ public float MeleeCooldown /// public float RevivedAhpBonus { - get => Base._revivedPlayerAOEBonusAHP; - set => Base._revivedPlayerAOEBonusAHP = value; + get => Base.RevivedPlayerAOEBonusAHP; + set => Base.RevivedPlayerAOEBonusAHP = value; } /// @@ -116,8 +116,8 @@ public float RevivedAhpBonus /// public float RevivedAhpBonusDistance { - get => Base._revivedPlayerAOEBonusAHPDistance; - set => Base._revivedPlayerAOEBonusAHPDistance = value; + get => Base.RevivedPlayerAOEBonusAHPDistance; + set => Base.RevivedPlayerAOEBonusAHPDistance = value; } /// @@ -125,8 +125,8 @@ public float RevivedAhpBonusDistance /// public float MaxHs { - get => Base._equippedHS; - set => Base._equippedHS = value; + get => Base.EquippedHS; + set => Base.EquippedHS = value; } /// @@ -134,8 +134,8 @@ public float MaxHs /// public float RevivedBlurTime { - get => Base._revivedPlayerBlurTime; - set => Base._revivedPlayerBlurTime = value; + get => Base.RevivedPlayerBlurTime; + set => Base.RevivedPlayerBlurTime = value; } /// @@ -143,8 +143,12 @@ public float RevivedBlurTime /// public IEnumerable RevivedPlayers { - get => Base._revivedPlayers.Select(Player.Get); - set => Base._revivedPlayers = value.Select(x => x.ReferenceHub).ToList(); + get => Base.RevivedPlayers.Select(Player.Get); + set + { + Base.RevivedPlayers.Clear(); + Base.RevivedPlayers.AddRange(value.Select(x => x.ReferenceHub)); + } } /// @@ -152,13 +156,13 @@ public IEnumerable RevivedPlayers /// /// Role to respawn. /// Found player or null. - public Player GetEligibleSpectator(RoleTypeId roleTypeId) => Player.Get(RespawnEligibility.GetEligibleSpectator(roleTypeId)); + public Player GetEligibleSpectator(RoleTypeId roleTypeId) => Player.Get(Scp1509RespawnEligibility.GetEligibleSpectator(roleTypeId)); /// /// Checks if there is any eligible spectator for spawn. /// /// true if any spectator is found. Otherwise, false. - public bool IsAnyEligibleSpectators() => RespawnEligibility.IsAnyEligibleSpectators(); + public bool IsAnyEligibleSpectators() => Scp1509RespawnEligibility.IsAnyEligibleSpectators(); /// /// Clones current object. diff --git a/EXILED/Exiled.Events/Handlers/Internal/Round.cs b/EXILED/Exiled.Events/Handlers/Internal/Round.cs index cebcffa74..4ec235db1 100644 --- a/EXILED/Exiled.Events/Handlers/Internal/Round.cs +++ b/EXILED/Exiled.Events/Handlers/Internal/Round.cs @@ -94,9 +94,6 @@ public static void OnSpawningRagdoll(SpawningRagdollEventArgs ev) { if (ev.Role.IsDead() || !ev.Role.IsFpcRole()) ev.IsAllowed = false; - - if (ev.DamageHandlerBase is Exiled.Events.Patches.Fixes.FixMarshmallowManFF fixMarshamllowManFf) - ev.DamageHandlerBase = fixMarshamllowManFf.MarshmallowItem.NewDamageHandler; } /// diff --git a/EXILED/Exiled.Events/Patches/Events/Item/Inspect.cs b/EXILED/Exiled.Events/Patches/Events/Item/Inspect.cs index 860d064ea..4ba2d307e 100644 --- a/EXILED/Exiled.Events/Patches/Events/Item/Inspect.cs +++ b/EXILED/Exiled.Events/Patches/Events/Item/Inspect.cs @@ -269,7 +269,8 @@ private static IEnumerable Transpiler(IEnumerable newInstructions = ListPool.Pool.Get(instructions); - int index = newInstructions.FindLastIndex(x => x.opcode == OpCodes.Ldarg_0); + int offset = 2; + int index = newInstructions.FindIndex(x => x.opcode == OpCodes.Brfalse_S) + offset; Label returnLabel = generator.DefineLabel(); diff --git a/EXILED/Exiled.Events/Patches/Events/Scp1509/InspectingAndTriggeringAttack.cs b/EXILED/Exiled.Events/Patches/Events/Scp1509/InspectingAndTriggeringAttack.cs index 3b53a07e5..214aa79ae 100644 --- a/EXILED/Exiled.Events/Patches/Events/Scp1509/InspectingAndTriggeringAttack.cs +++ b/EXILED/Exiled.Events/Patches/Events/Scp1509/InspectingAndTriggeringAttack.cs @@ -59,7 +59,8 @@ private static IEnumerable Transpiler(IEnumerable x.opcode == OpCodes.Ldarg_0); + offset = 2; + index = newInstructions.FindLastIndex(x => x.opcode == OpCodes.Ldloc_2) + offset; newInstructions.InsertRange(index, new[] { diff --git a/EXILED/Exiled.Events/Patches/Fixes/FixMarshmallowManFF.cs b/EXILED/Exiled.Events/Patches/Fixes/FixMarshmallowManFF.cs deleted file mode 100644 index 51dab211b..000000000 --- a/EXILED/Exiled.Events/Patches/Fixes/FixMarshmallowManFF.cs +++ /dev/null @@ -1,91 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (c) ExMod Team. All rights reserved. -// Licensed under the CC BY-SA 3.0 license. -// -// ----------------------------------------------------------------------- - -namespace Exiled.Events.Patches.Fixes -{ - using System.Collections.Generic; - using System.Reflection.Emit; - - using CustomPlayerEffects; - using Exiled.API.Features; - using Exiled.API.Features.Pools; - using Footprinting; - using HarmonyLib; - using InventorySystem.Items.MarshmallowMan; - using InventorySystem.Items.Pickups; - using InventorySystem.Items.ThrowableProjectiles; - using Mirror; - using PlayerRoles; - using PlayerRoles.FirstPersonControl; - using PlayerStatsSystem; - using Respawning.NamingRules; - using Subtitles; - - using static HarmonyLib.AccessTools; - - /// - /// Patches the delegate. - /// - [HarmonyPatch(typeof(MarshmallowItem), nameof(MarshmallowItem.ServerAttack))] - internal class FixMarshmallowManFF : AttackerDamageHandler - { -#pragma warning disable SA1600 // Elements should be documented - public FixMarshmallowManFF(MarshmallowItem marshmallowItem, bool isEvilMode) - { - MarshmallowItem = marshmallowItem; - Attacker = new(marshmallowItem.Owner); - Damage = marshmallowItem._attackDamage; - ForceFullFriendlyFire = isEvilMode; - } - - public MarshmallowItem MarshmallowItem { get; set; } - - public override Footprint Attacker { get; set; } - - public override bool AllowSelfDamage { get; } = false; - - public override float Damage { get; set; } - - public override string RagdollInspectText { get; } = DeathTranslations.MarshmallowMan.RagdollTranslation; - - public override CassieAnnouncement CassieDeathAnnouncement { get; } = new() - { - Announcement = "TERMINATED BY MARSHMALLOW MAN", - SubtitleParts = - [ - new SubtitlePart(SubtitleType.TerminatedByMarshmallowMan, null), - ], - }; - - public override string DeathScreenText { get; } = DeathTranslations.MarshmallowMan.DeathscreenTranslation; - - public override string ServerLogsText => "Stabbed with Marshmallow Item by " + Attacker.Nickname; -#pragma warning restore SA1600 // Elements should be documented -#pragma warning disable SA1313 // Parameter names should begin with lower-case letter - - private static IEnumerable Transpiler(IEnumerable instructions) - { - List newInstructions = ListPool.Pool.Get(instructions); - - int index = newInstructions.FindIndex(instruction => instruction.Calls(PropertyGetter(typeof(MarshmallowItem), nameof(MarshmallowItem.NewDamageHandler)))); - - // replace the getter for NewDamageHandler with ctor of FixMarshmallowManFF - newInstructions.RemoveAt(index); - newInstructions.InsertRange(index, new List - { - new(OpCodes.Ldarg_0), - new(OpCodes.Callvirt, PropertyGetter(typeof(MarshmallowItem), nameof(MarshmallowItem.EvilMode))), - new(OpCodes.Newobj, Constructor(typeof(FixMarshmallowManFF), new[] { typeof(MarshmallowItem), typeof(bool) })), - }); - - for (int z = 0; z < newInstructions.Count; z++) - yield return newInstructions[z]; - - ListPool.Pool.Return(newInstructions); - } - } -} diff --git a/EXILED/Exiled.Events/Patches/Fixes/FootprintConstructorFix.cs b/EXILED/Exiled.Events/Patches/Fixes/FootprintConstructorFix.cs new file mode 100644 index 000000000..a5442d1df --- /dev/null +++ b/EXILED/Exiled.Events/Patches/Fixes/FootprintConstructorFix.cs @@ -0,0 +1,58 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.Events.Patches.Fixes +{ + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Linq; + using System.Reflection.Emit; + + using Exiled.API.Features.Pools; + using Footprinting; + using HarmonyLib; + using LiteNetLib; + using Mirror; + + using static HarmonyLib.AccessTools; + + /// + /// Patches constructor. + /// Fixes an issue where calling the constructor after a player disconnects throws an error. + /// + [HarmonyPatch(typeof(Footprint), MethodType.Constructor, typeof(ReferenceHub))] + public class FootprintConstructorFix + { + private static IEnumerable Transpiler(IEnumerable instructions) + { + List newInstructions = ListPool.Pool.Get(instructions); + + // the Ldnull next to the Stfld for IpAddress + int index = newInstructions.FindLastIndex(x => x.opcode == OpCodes.Ldnull); + + Label nullIpLabel = newInstructions[index].labels.First(); + + index -= 4; + + newInstructions.InsertRange(index, new CodeInstruction[] + { + // essentially just tack on '|| !LiteNetLib4MirrorServer.Peers.ContainsKey(hub.connectionToClient.connectionId)' for the ip address connected check. + new(OpCodes.Ldsfld, Field(typeof(Mirror.LiteNetLib4Mirror.LiteNetLib4MirrorServer), nameof(Mirror.LiteNetLib4Mirror.LiteNetLib4MirrorServer.Peers))), + new(OpCodes.Ldarg_1), + new(OpCodes.Callvirt, PropertyGetter(typeof(ReferenceHub), nameof(ReferenceHub.connectionToClient))), + new(OpCodes.Ldfld, Field(typeof(NetworkConnectionToClient), nameof(NetworkConnectionToClient.connectionId))), + new(OpCodes.Callvirt, Method(typeof(ConcurrentDictionary), nameof(ConcurrentDictionary.ContainsKey))), + new(OpCodes.Brfalse_S, nullIpLabel), + }); + + for (int z = 0; z < newInstructions.Count; z++) + yield return newInstructions[z]; + + ListPool.Pool.Return(newInstructions); + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.Events/Patches/Fixes/TryRaycastRoomFix.cs b/EXILED/Exiled.Events/Patches/Fixes/TryRaycastRoomFix.cs new file mode 100644 index 000000000..57fda78d7 --- /dev/null +++ b/EXILED/Exiled.Events/Patches/Fixes/TryRaycastRoomFix.cs @@ -0,0 +1,68 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.Events.Patches.Fixes +{ + using System.Collections.Generic; + using System.Linq; + using System.Reflection.Emit; + + using Exiled.API.Features.Pools; + using HarmonyLib; + using MapGeneration; + using UnityEngine; + + using static HarmonyLib.AccessTools; + + /// + /// Patches to fix the method accidentally returning true and leaving when hitting a IRoomObject with a null original room. + /// + /// + /// Most of the logic comes from https://github.com/KadavasKingdom/SLFixes so shoutout to SlejmUr. + /// + [HarmonyPatch(typeof(RoomUtils), nameof(RoomUtils.TryRaycastRoom))] + public class TryRaycastRoomFix + { + private static IEnumerable Transpiler(IEnumerable instructions) + { + List newInstructions = ListPool.Pool.Get(instructions); + + // room = comp1.OriginalRoom; + // return true; -- here + int index = newInstructions.FindIndex(x => x.opcode == OpCodes.Ldc_I4_1); + + // just a check for if they fix this. + if (newInstructions[index + 3].opcode != OpCodes.Ldarg_2) + { + for (int z = 0; z < newInstructions.Count; z++) + yield return newInstructions[z]; + + ListPool.Pool.Return(newInstructions); + yield break; + } + + // after the return, starts the next if statement + Label skipLabel = newInstructions[index + 2].labels.First(); + + newInstructions.InsertRange(index, new CodeInstruction[] + { + // if (room is null) + // goto skipLabel; + new(OpCodes.Ldarg_2), + new(OpCodes.Ldind_Ref), + new(OpCodes.Ldnull), + new(OpCodes.Call, Method(typeof(Object), "op_Inequality")), + new(OpCodes.Brfalse_S, skipLabel), + }); + + for (int z = 0; z < newInstructions.Count; z++) + yield return newInstructions[z]; + + ListPool.Pool.Return(newInstructions); + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.Loader/AutoUpdateFiles.cs b/EXILED/Exiled.Loader/AutoUpdateFiles.cs index 77a835aed..cca1835dd 100644 --- a/EXILED/Exiled.Loader/AutoUpdateFiles.cs +++ b/EXILED/Exiled.Loader/AutoUpdateFiles.cs @@ -17,6 +17,6 @@ public static class AutoUpdateFiles /// /// Gets which SCP: SL version generated Exiled. /// - public static readonly Version RequiredSCPSLVersion = new(14, 2, 0, 5); + public static readonly Version RequiredSCPSLVersion = new(14, 2, 0, 6); } } \ No newline at end of file diff --git a/EXILED/docs/articles/SCPSLRessources/NW_Documentation.md b/EXILED/docs/articles/SCPSLRessources/NW_Documentation.md index a854bbcea..86652e81a 100644 --- a/EXILED/docs/articles/SCPSLRessources/NW_Documentation.md +++ b/EXILED/docs/articles/SCPSLRessources/NW_Documentation.md @@ -19,7 +19,7 @@ title: NW Documentation --- -Last Update (14.2.0.4) +Last Update (14.2.0.6) ### Index @@ -157,6 +157,7 @@ Last Update (14.2.0.4) - [HintTranslations](#hinttranslations) - [HintType](#hinttype) - [HitboxType](#hitboxtype) +- [HitmarkerType](#hitmarkertype) - [HitResult](#hitresult) - [HolidayType](#holidaytype) - [HotkeysTranslation](#hotkeystranslation) @@ -2415,6 +2416,18 @@ Last Update (14.2.0.4) +### HitmarkerType + +
HitmarkerType + +``` + [0] = None + [1] = Regular + [2] = Blocked +``` + +
+ ### HitResult
InventorySystem.Items.Autosync.MeleeAutoSync+HitResult @@ -2605,6 +2618,7 @@ Last Update (14.2.0.4) [26] = MicroHidDamaged [27] = Scp127OnEquip [28] = SnakeHint + [29] = FirearmSprintSpeed ```
@@ -3552,6 +3566,8 @@ Last Update (14.2.0.4) [15] = InvalidProtocol [16] = NatMessage [17] = Empty + [18] = ReliableMerged + [19] = Total ``` @@ -5667,7 +5683,7 @@ Last Update (14.2.0.4) [7] = Mimicry [8] = Scp1576 [9] = PreGameLobby - [9] = PreGameLobby + [10] = Scp1507 ``` @@ -5850,7 +5866,7 @@ Last Update (14.2.0.4)
Damage Handlers -```md title="Latest Updated: 14.2.0.4" +```md title="Latest Updated: 14.2.0.6" All available DamageHandlers + Symbol ':' literally means "inherits from" @@ -5870,6 +5886,7 @@ All available DamageHandlers - PlayerStatsSystem.ExplosionDamageHandler : PlayerStatsSystem.AttackerDamageHandler, - PlayerStatsSystem.GrayCandyDamageHandler : PlayerStatsSystem.AttackerDamageHandler, - PlayerStatsSystem.JailbirdDamageHandler : PlayerStatsSystem.AttackerDamageHandler, + - PlayerStatsSystem.MarshmallowDamageHandler : PlayerStatsSystem.AttackerDamageHandler, - PlayerStatsSystem.MicroHidDamageHandler : PlayerStatsSystem.AttackerDamageHandler, DisintegrateDeathAnimation+IDisintegrateDamageHandler - PlayerStatsSystem.RecontainmentDamageHandler : PlayerStatsSystem.AttackerDamageHandler, - PlayerStatsSystem.Scp018DamageHandler : PlayerStatsSystem.AttackerDamageHandler, From a6c5e42add6d56aec6a35b8057132ca6f08567c6 Mon Sep 17 00:00:00 2001 From: Yamato <66829532+louis1706@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:57:48 +0100 Subject: [PATCH 086/102] Bump to v9.13.3 --- EXILED/EXILED.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/EXILED.props b/EXILED/EXILED.props index 395d4bd5f..052935a2c 100644 --- a/EXILED/EXILED.props +++ b/EXILED/EXILED.props @@ -15,7 +15,7 @@ - 9.13.2 + 9.13.3 false From a82cef2ecec190ea5a0e80c05bf2af708542e698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sat, 28 Mar 2026 23:50:59 +0300 Subject: [PATCH 087/102] t --- .../Audio/PcmSources/PlayerVoiceSource.cs | 4 +- EXILED/Exiled.API/Features/Toys/Speaker.cs | 120 +++++++++--------- 2 files changed, 63 insertions(+), 61 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs index e0be83497..ae7f5ed94 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs @@ -25,7 +25,6 @@ namespace Exiled.API.Features.Audio.PcmSources ///
public sealed class PlayerVoiceSource : IPcmSource { - private readonly int sourcePlayerId; private readonly Player sourcePlayer; private readonly OpusDecoder decoder; private readonly ConcurrentQueue pcmQueue; @@ -42,7 +41,6 @@ public sealed class PlayerVoiceSource : IPcmSource public PlayerVoiceSource(Player player, bool blockOriginalVoice = false, float delay = 0f) { sourcePlayer = player; - sourcePlayerId = player.Id; Delay = delay; BlockOriginalVoice = blockOriginalVoice; @@ -164,7 +162,7 @@ private void FillDelayBuffer() private void OnVoiceChatting(PlayerSendingVoiceMessageEventArgs ev) { - if (ev.Player.PlayerId != sourcePlayerId) + if (ev.Player != sourcePlayer) return; if (ev.Message.DataLength <= 2) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 31d0b6f45..07edf47fa 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -906,6 +906,60 @@ public void ReturnToPool() Pool.Enqueue(this); } + /// + /// Sends the constructed audio message to the appropriate players based on the current . + /// + /// The . + public void SendAudioMessage(AudioMessage audioMessage) + { + switch (PlayMode) + { + case SpeakerPlayMode.Global: + NetworkServer.SendToReady(audioMessage, Channel); + break; + + case SpeakerPlayMode.Player: + TargetPlayer?.Connection?.Send(audioMessage, Channel); + break; + + case SpeakerPlayMode.PlayerList: + + if (TargetPlayers is null) + break; + + using (NetworkWriterPooled writer = NetworkWriterPool.Get()) + { + NetworkMessages.Pack(audioMessage, 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(audioMessage, writer); + ArraySegment segment = writer.ToArraySegment(); + + foreach (Player ply in Player.List) + { + if (Predicate(ply)) + ply.Connection?.Send(segment, Channel); + } + } + + break; + } + } + private void TryInitializePlayBack() { if (isPlayBackInitialized) @@ -983,58 +1037,6 @@ private void ResetEncoder() } } - 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; @@ -1171,24 +1173,26 @@ private IEnumerator PlayBackCoroutine() int len = encoder.Encode(frame, encoded); if (len > 2) - SendPacket(len); + SendAudioMessage(new AudioMessage(ControllerId, encoded, len)); if (!CurrentSource.Ended) continue; - yield return Timing.WaitForOneFrame; - OnPlaybackFinished?.Invoke(); SpeakerEvents.OnPlaybackFinished(this); + yield return Timing.WaitForOneFrame; + if (Loop) { - ResetEncoder(); - CurrentSource.Reset(); + resampleTime = 0; timeAccumulator = 0; - resampleTime = resampleBufferFilled = 0; + resampleBufferFilled = 0; nextScheduledEventIndex = 0; + ResetEncoder(); + CurrentSource.Reset(); + OnPlaybackLooped?.Invoke(); SpeakerEvents.OnPlaybackLooped(this); continue; From 33c5880de22aea367074ff2cb0beaeb5d0193a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 29 Mar 2026 15:44:12 +0300 Subject: [PATCH 088/102] directorys magic struct to struct --- .../Features/Audio/Filters/EchoFilter.cs | 2 +- .../Audio/Filters/PitchShiftFilter.cs | 2 +- .../Audio/PcmSources/PlayerVoiceSource.cs | 6 ++-- .../PcmSources/PreloadWebWavPcmSource.cs | 12 +++---- .../Audio/PcmSources/PreloadedPcmSource.cs | 9 +++-- .../Audio/PcmSources/WavStreamSource.cs | 5 ++- .../Features/Audio/SpeakerEvents.cs | 2 +- .../Exiled.API/Features/Audio/WavUtility.cs | 20 +++++------ EXILED/Exiled.API/Features/Toys/Speaker.cs | 3 +- .../Interfaces/{ => Audio}/IAudioFilter.cs | 2 +- .../Interfaces/Audio/ILiveSource.cs | 16 +++++++++ .../Interfaces/{ => Audio}/IPcmSource.cs | 4 +-- EXILED/Exiled.API/Structs/Audio/AudioData.cs | 36 +++++++++++++++++++ .../Structs/{ => Audio}/QueuedTrack.cs | 4 +-- .../Structs/{ => Audio}/TrackData.cs | 2 +- 15 files changed, 88 insertions(+), 37 deletions(-) rename EXILED/Exiled.API/Interfaces/{ => Audio}/IAudioFilter.cs (94%) create mode 100644 EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs rename EXILED/Exiled.API/Interfaces/{ => Audio}/IPcmSource.cs (96%) create mode 100644 EXILED/Exiled.API/Structs/Audio/AudioData.cs rename EXILED/Exiled.API/Structs/{ => Audio}/QueuedTrack.cs (94%) rename EXILED/Exiled.API/Structs/{ => Audio}/TrackData.cs (98%) diff --git a/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs index 7a2ad248c..02b609831 100644 --- a/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs +++ b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs @@ -9,7 +9,7 @@ namespace Exiled.API.Features.Audio.Filters { using System; - using Exiled.API.Interfaces; + using Exiled.API.Interfaces.Audio; using UnityEngine; diff --git a/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs index 1ae354605..f785c3d5d 100644 --- a/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs +++ b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs @@ -9,7 +9,7 @@ namespace Exiled.API.Features.Audio.Filters { using System; - using Exiled.API.Interfaces; + using Exiled.API.Interfaces.Audio; using UnityEngine; diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs index ae7f5ed94..52d18b042 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs @@ -12,8 +12,8 @@ namespace Exiled.API.Features.Audio.PcmSources using System.Collections.Concurrent; using Exiled.API.Features; - using Exiled.API.Interfaces; - using Exiled.API.Structs; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; using LabApi.Events.Arguments.PlayerEvents; @@ -23,7 +23,7 @@ namespace Exiled.API.Features.Audio.PcmSources /// /// Provides a that captures and decodes live microphone input from a specific player. /// - public sealed class PlayerVoiceSource : IPcmSource + public sealed class PlayerVoiceSource : IPcmSource, ILiveSource { private readonly Player sourcePlayer; private readonly OpusDecoder decoder; diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs index 764b17a32..545593a1d 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs @@ -11,8 +11,8 @@ namespace Exiled.API.Features.Audio.PcmSources using System.Collections.Generic; using Exiled.API.Features; - using Exiled.API.Interfaces; - using Exiled.API.Structs; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; using MEC; @@ -122,11 +122,11 @@ private IEnumerator Download(string url) try { byte[] rawBytes = www.downloadHandler.data; - (float[] pcmData, TrackData trackInfo) = WavUtility.WavToPcm(rawBytes); - trackInfo.Path = url; + AudioData audioData = WavUtility.WavToPcm(rawBytes); + audioData.TrackInfo.Path = url; - internalSource = new PreloadedPcmSource(pcmData); - TrackInfo = trackInfo; + internalSource = new PreloadedPcmSource(audioData.Pcm); + TrackInfo = audioData.TrackInfo; isReady = true; } catch (Exception e) diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs index 70e2fc152..ce1dd6a77 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs @@ -10,9 +10,8 @@ namespace Exiled.API.Features.Audio.PcmSources using System; using Exiled.API.Features.Audio; - - using Exiled.API.Interfaces; - using Exiled.API.Structs; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; using VoiceChat; @@ -30,8 +29,8 @@ public sealed class PreloadedPcmSource : IPcmSource /// The path to the audio file. public PreloadedPcmSource(string path) { - (float[] PcmData, TrackData TrackInfo) result = WavUtility.WavToPcm(path); - data = result.PcmData; + AudioData result = WavUtility.WavToPcm(path); + data = result.Pcm; TrackInfo = result.TrackInfo; } diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs index 63534a59b..517ea026d 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs @@ -13,9 +13,8 @@ namespace Exiled.API.Features.Audio.PcmSources using System.Runtime.InteropServices; using Exiled.API.Features.Audio; - - using Exiled.API.Interfaces; - using Exiled.API.Structs; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; using VoiceChat; diff --git a/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs index 4f4dbe5ca..da580416f 100644 --- a/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs +++ b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs @@ -9,7 +9,7 @@ namespace Exiled.API.Features.Audio { using System; - using Exiled.API.Structs; + using Exiled.API.Structs.Audio; using Toys; diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index f1e1080a8..669f02a7f 100644 --- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -14,8 +14,8 @@ namespace Exiled.API.Features.Audio using System.Runtime.InteropServices; using Exiled.API.Features.Audio.PcmSources; - using Exiled.API.Interfaces; - using Exiled.API.Structs; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; using VoiceChat; @@ -27,7 +27,7 @@ public static class WavUtility private const float Divide = 1f / 32768f; /// - /// Evaluates the given path or URL and returns the appropriate for .wav playback. + /// Evaluates the given local path or URL and returns the appropriate for .wav playback. /// /// The local file path or web URL of the .wav file. /// If true, streams local files directly from disk. If false, preloads them into memory (Ignored for web URLs). @@ -44,8 +44,8 @@ public static IPcmSource CreatePcmSource(string path, bool stream) /// Converts a WAV file at the specified path to a PCM float array. ///
/// The file path of the WAV file to convert. - /// A tuple containing an array of floats representing the PCM data and its TrackData. - public static (float[] PcmData, TrackData TrackInfo) WavToPcm(string path) + /// A containing an array of floats representing the PCM data and its TrackData. + public static AudioData WavToPcm(string path) { if (!File.Exists(path)) { @@ -69,7 +69,7 @@ public static (float[] PcmData, TrackData TrackInfo) WavToPcm(string path) int bytesRead = fs.Read(rentedBuffer, 0, length); using MemoryStream ms = new(rentedBuffer, 0, bytesRead); - (float[] PcmData, TrackData TrackInfo) result = ParseWavSpanToPcm(ms, rentedBuffer.AsSpan(0, bytesRead)); + AudioData result = ParseWavSpanToPcm(ms, rentedBuffer.AsSpan(0, bytesRead)); result.TrackInfo.Path = path; return result; @@ -84,8 +84,8 @@ public static (float[] PcmData, TrackData TrackInfo) WavToPcm(string path) /// Converts a WAV byte array to a PCM float array. ///
/// The raw bytes of the WAV file. - /// A tuple containing an array of floats representing the PCM data and its TrackData. - public static (float[] PcmData, TrackData TrackInfo) WavToPcm(byte[] data) + /// A containing an array of floats representing the PCM data and its TrackData. + public static AudioData WavToPcm(byte[] data) { using MemoryStream ms = new(data, 0, data.Length); @@ -98,7 +98,7 @@ public static (float[] PcmData, TrackData TrackInfo) WavToPcm(byte[] data) /// The stream used to read and skip the WAV header. /// The complete span of WAV audio data including the header. /// A tuple containing an array of floats representing the PCM data and its TrackData. - public static (float[] PcmData, TrackData TrackInfo) ParseWavSpanToPcm(Stream stream, ReadOnlySpan audioData) + public static AudioData ParseWavSpanToPcm(Stream stream, ReadOnlySpan audioData) { TrackData metaData = SkipHeader(stream); @@ -112,7 +112,7 @@ public static (float[] PcmData, TrackData TrackInfo) ParseWavSpanToPcm(Stream st for (int i = 0; i < samples.Length; i++) pcm[i] = samples[i] * Divide; - return (pcm, metaData); + return new(pcm, metaData); } /// diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 07edf47fa..6bfdb7538 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -17,7 +17,8 @@ namespace Exiled.API.Features.Toys using Exiled.API.Features.Audio; using Exiled.API.Features.Audio.PcmSources; - using Exiled.API.Structs; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; using HarmonyLib; diff --git a/EXILED/Exiled.API/Interfaces/IAudioFilter.cs b/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs similarity index 94% rename from EXILED/Exiled.API/Interfaces/IAudioFilter.cs rename to EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs index 3ba8842d4..74e1528d3 100644 --- a/EXILED/Exiled.API/Interfaces/IAudioFilter.cs +++ b/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs @@ -5,7 +5,7 @@ // // ----------------------------------------------------------------------- -namespace Exiled.API.Interfaces +namespace Exiled.API.Interfaces.Audio { /// /// Represents a custom filter for the speaker. diff --git a/EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs b/EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs new file mode 100644 index 000000000..ad9c9caea --- /dev/null +++ b/EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs @@ -0,0 +1,16 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Interfaces.Audio +{ + /// + /// A marker interface used to identify PCM sources that are live or continuous. + /// + public interface ILiveSource + { + } +} diff --git a/EXILED/Exiled.API/Interfaces/IPcmSource.cs b/EXILED/Exiled.API/Interfaces/Audio/IPcmSource.cs similarity index 96% rename from EXILED/Exiled.API/Interfaces/IPcmSource.cs rename to EXILED/Exiled.API/Interfaces/Audio/IPcmSource.cs index c9f932c8b..6f0423b86 100644 --- a/EXILED/Exiled.API/Interfaces/IPcmSource.cs +++ b/EXILED/Exiled.API/Interfaces/Audio/IPcmSource.cs @@ -5,11 +5,11 @@ // // ----------------------------------------------------------------------- -namespace Exiled.API.Interfaces +namespace Exiled.API.Interfaces.Audio { using System; - using Exiled.API.Structs; + using Exiled.API.Structs.Audio; /// /// Represents a source of PCM audio data. diff --git a/EXILED/Exiled.API/Structs/Audio/AudioData.cs b/EXILED/Exiled.API/Structs/Audio/AudioData.cs new file mode 100644 index 000000000..e8924f8c1 --- /dev/null +++ b/EXILED/Exiled.API/Structs/Audio/AudioData.cs @@ -0,0 +1,36 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Structs.Audio +{ + /// + /// Represents raw audio data and its associated metadata. + /// + public struct AudioData + { + /// + /// Gets the raw PCM audio samples. + /// + public float[] Pcm; + + /// + /// Gets the metadata of the audio track, including its total duration. + /// + public TrackData TrackInfo; + + /// + /// Initializes a new instance of the struct. + /// + /// The raw PCM float array containing the audio data. + /// The metadata associated with the audio track. + public AudioData(float[] pcmData, TrackData trackInfo) + { + Pcm = pcmData; + TrackInfo = trackInfo; + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Structs/QueuedTrack.cs b/EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs similarity index 94% rename from EXILED/Exiled.API/Structs/QueuedTrack.cs rename to EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs index 958a0ba9a..34d1cfbf6 100644 --- a/EXILED/Exiled.API/Structs/QueuedTrack.cs +++ b/EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs @@ -5,11 +5,11 @@ // // ----------------------------------------------------------------------- -namespace Exiled.API.Structs +namespace Exiled.API.Structs.Audio { using System; - using Exiled.API.Interfaces; + using Exiled.API.Interfaces.Audio; /// /// Represents a track waiting in the queue, along with its specific playback options. diff --git a/EXILED/Exiled.API/Structs/TrackData.cs b/EXILED/Exiled.API/Structs/Audio/TrackData.cs similarity index 98% rename from EXILED/Exiled.API/Structs/TrackData.cs rename to EXILED/Exiled.API/Structs/Audio/TrackData.cs index fba0aca28..93f549369 100644 --- a/EXILED/Exiled.API/Structs/TrackData.cs +++ b/EXILED/Exiled.API/Structs/Audio/TrackData.cs @@ -5,7 +5,7 @@ // // ----------------------------------------------------------------------- -namespace Exiled.API.Structs +namespace Exiled.API.Structs.Audio { using System; From 4a19539253b27ded7701bf98fbee69d99dd204fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 29 Mar 2026 21:41:25 +0300 Subject: [PATCH 089/102] cache logic# --- .../Audio/PcmSources/CachedPcmSource.cs | 303 ++++++++++++++++++ .../Exiled.API/Features/Audio/WavUtility.cs | 15 +- EXILED/Exiled.API/Features/Toys/Speaker.cs | 47 ++- 3 files changed, 348 insertions(+), 17 deletions(-) create mode 100644 EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs new file mode 100644 index 000000000..46ac0a17d --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs @@ -0,0 +1,303 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.PcmSources +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.IO; + + using Exiled.API.Features.Audio; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using MEC; + + using RoundRestarting; + + using UnityEngine.Networking; + + using VoiceChat; + + /// + /// Provides an that caches audio data in memory for optimize, repeated playback. Also serves as the central audio cache manager for the server. + /// + public sealed class CachedPcmSource : IPcmSource + { + private readonly float[] data; + private int pos; + + static CachedPcmSource() => RoundRestart.OnRestartTriggered += ClearCacheOnRestart; + + /// + /// Initializes a new instance of the class or local WAV files or fetches already cached audio, assigning a custom name to a specific local file path. + /// + /// NOTE: URLs cannot be loaded directly here. Use Coroutine first. + /// The custom name/key to assign to this audio in the cache. + /// The absolute path to the local audio file. + public CachedPcmSource(string name, string path) + { + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(path)) + { + Log.Error($"[CachedPcmSource] Cannot initialize CachedPcmSource. Invalid name: '{name}' or path: '{path}'."); + throw new ArgumentException("Name and path cannot be null or empty."); + } + + if (AudioCache.TryGetValue(name, out AudioData cachedAudio)) + { + data = cachedAudio.Pcm; + TrackInfo = cachedAudio.TrackInfo; + Log.Debug($"[CachedPcmSource] Loaded audio from cache for key '{name}'."); + return; + } + + if (!AddSource(name, path)) + { + Log.Error($"[CachedPcmSource] Failed to load local file '{path}' into cache under the name '{name}'."); + throw new FileNotFoundException($"Failed to cache and load '{path}'."); + } + + if (AudioCache.TryGetValue(name, out AudioData createdAudio)) + { + data = createdAudio.Pcm; + TrackInfo = createdAudio.TrackInfo; + } + } + + /// + /// Gets the global audio cache dictionary. + /// + public static ConcurrentDictionary AudioCache { get; } = new(); + + /// + /// Gets or sets a value indicating whether the global audio cache should be cleared when a round restart is triggered. + /// + public static bool ClearCacheOnRoundRestart { get; set; } = true; + + /// + /// Gets the metadata of the loaded track. + /// + public TrackData TrackInfo { get; } + + /// + /// Gets a value indicating whether the end of the PCM data buffer has been reached. + /// + public bool Ended => pos >= data.Length; + + /// + /// Gets the total duration of the audio in seconds. + /// + public double TotalDuration => (double)data.Length / VoiceChatSettings.SampleRate; + + /// + /// Gets or sets the current playback position in seconds. + /// + public double CurrentTime + { + get => (double)pos / VoiceChatSettings.SampleRate; + set => Seek(value); + } + + /// + /// Loads a local Wav file and adds it to the cache. + /// + /// The custom name/key to assign. + /// The absolute path to the local Wav file. + /// true if successfully read and cached; otherwise, false. + public static bool AddSource(string name, string path) + { + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(path)) + { + Log.Error($"[CachedPcmSource] Cannot add source. Invalid name: '{name}' or path: '{path}'."); + return false; + } + + if (path.StartsWith("http")) + { + Log.Error($"[CachedPcmSource] Use Timing.RunCoroutine(CachedPreloadedPcmSource.AddUrlCoroutine(...)) for URLs! Path: '{path}'"); + return false; + } + + if (AudioCache.ContainsKey(name)) + { + Log.Info($"[CachedPcmSource] A source with the name '{name}' already exists in the cache. Skipping addition."); + return false; + } + + if (!File.Exists(path)) + { + Log.Error($"[CachedPcmSource] Local file not found: '{path}'"); + return false; + } + + try + { + AudioData parsedData = WavUtility.WavToPcm(path); + AudioCache.TryAdd(name, parsedData); + Log.Debug($"[CachedPcmSource] Successfully cached local file: '{path}' with name: '{name}'"); + return true; + } + catch (Exception ex) + { + Log.Error($"[CachedPcmSource] Failed to cache local file '{path}':\n{ex}"); + return false; + } + } + + /// + /// Directly adds raw PCM data and track information to the cache. + /// + /// The custom name/key to assign. + /// The raw PCM audio samples. + /// true if successfully added; otherwise, false. + public static bool AddSource(string name, float[] pcm) => AddSource(name, new AudioData { Pcm = pcm, TrackInfo = new TrackData { Title = name, Duration = (double)pcm.Length / VoiceChatSettings.SampleRate } }); + + /// + /// Directly adds raw PCM data and track information to the cache. + /// + /// The custom name/key to assign. + /// The struct containing PCM samples and metadata. + /// true if successfully added; otherwise, false. + public static bool AddSource(string name, AudioData audioData) + { + if (string.IsNullOrEmpty(name)) + { + Log.Error($"[CachedPcmSource] Cannot add source. Invalid name: '{name}'."); + return false; + } + + if (audioData.Equals(default(AudioData)) || audioData.Pcm == null) + { + Log.Error($"[CachedPcmSource] Cannot add source. AudioData is empty for name: '{name}'."); + return false; + } + + if (AudioCache.ContainsKey(name)) + { + Log.Info($"[CachedPcmSource] A source with the name '{name}' already exists in the cache. Skipping addition."); + return false; + } + + return AudioCache.TryAdd(name, audioData); + } + + /// + /// Asynchronously downloads a Web URL and adds it to the cache. + /// + /// The custom name/key to assign. + /// The HTTP/HTTPS URL to the Wav file. + /// A float IEnumerator for MEC execution. + public static IEnumerator AddUrlSource(string name, string url) + { + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) + { + Log.Error($"[CachedPcmSource] Cannot add URL source. Invalid name: '{name}' or URL: '{url}'."); + yield break; + } + + if (!url.StartsWith("http")) + { + Log.Error($"[CachedPcmSource] AddUrlCoroutine is only for web URLs! URL: '{url}'"); + yield break; + } + + if (AudioCache.ContainsKey(name)) + { + Log.Info($"[CachedPcmSource] A source with the name '{name}' already exists in the cache. Skipping addition."); + yield break; + } + + using UnityWebRequest www = UnityWebRequest.Get(url); + yield return Timing.WaitUntilDone(www.SendWebRequest()); + + if (www.result != UnityWebRequest.Result.Success) + { + Log.Error($"[CachedPcmSource] Web download failed for '{url}': {www.error}"); + yield break; + } + + try + { + byte[] downloadedBytes = www.downloadHandler.data; + AudioData parsedData = WavUtility.WavToPcm(downloadedBytes); + + parsedData.TrackInfo.Path = url; + + AudioCache.TryAdd(name, parsedData); + Log.Debug($"[CachedPcmSource] Successfully downloaded and cached URL: '{name}'"); + } + catch (Exception ex) + { + Log.Error($"[CachedPcmSource] Failed to parse downloaded WAV from '{url}':\n{ex}"); + } + } + + /// + /// Removes a specific audio track from the cache. + /// + /// The name/key of the cached audio to remove. + /// true if the item was successfully removed; otherwise, false. + public static bool Remove(string name) + { + return AudioCache.TryRemove(name, out _); + } + + /// + /// Clears the entire audio cache, freeing up RAM. Useful for RoundRestart or OnDisabled. + /// + public static void ClearCache() + { + AudioCache.Clear(); + } + + /// + /// Reads a sequence of PCM samples from the cached buffer into the specified array. + /// + /// The destination array. + /// The index to start writing. + /// The maximum number of samples to read. + /// The actual number of samples read. + public int Read(float[] buffer, int offset, int count) + { + int read = Math.Min(count, data.Length - pos); + Array.Copy(data, pos, buffer, offset, read); + pos += read; + + return read; + } + + /// + /// Seeks to the specified position in seconds. + /// + /// The target position in seconds. + public void Seek(double seconds) + { + long targetIndex = (long)(seconds * VoiceChatSettings.SampleRate); + pos = (int)Math.Max(0, Math.Min(targetIndex, data.Length)); + } + + /// + /// Resets the read position to the beginning of the PCM data buffer. + /// + public void Reset() + { + pos = 0; + } + + /// + public void Dispose() + { + } + + private static void ClearCacheOnRestart() + { + if (ClearCacheOnRoundRestart) + ClearCache(); + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index 669f02a7f..77ae5e808 100644 --- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -31,13 +31,20 @@ public static class WavUtility /// /// The local file path or web URL of the .wav file. /// If true, streams local files directly from disk. If false, preloads them into memory (Ignored for web URLs). + /// If true, loads the audio via for zero-latency memory playback. /// An initialized . - public static IPcmSource CreatePcmSource(string path, bool stream) + public static IPcmSource CreatePcmSource(string path, bool stream = false, bool cache = false) { - if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + if (!cache && path.StartsWith("http")) return new PreloadWebWavPcmSource(path); - return stream ? new WavStreamSource(path) : new PreloadedPcmSource(path); + if (cache) + return new CachedPcmSource(path, path); + + if (stream) + return new WavStreamSource(path); + + return new PreloadedPcmSource(path); } /// @@ -245,7 +252,7 @@ public static bool TryValidatePath(string path, out string errorMessage) return false; } - if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + if (path.StartsWith("http")) return true; if (!File.Exists(path)) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 6bfdb7538..eba9690ec 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -455,7 +455,7 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null) /// /// Rents a speaker from the pool, plays a local wav file or web stream one time, and automatically returns it to the pool afterwards. (File must be 16 bit, mono and 48khz.) /// - /// The path/url to the wav file. + /// The path/url or custom name(if is true) to the wav file. /// The local position of the speaker. /// The parent transform, if any. /// Whether the audio source is spatialized. @@ -463,16 +463,23 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null) /// 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. + /// If true, the file will be streamed from disk when played (Ignored for web URLs or if useCache is true). + /// If true, loads the audio via for optimize playback. /// The play mode determining how audio is sent to players. - /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs). /// The target player if PlayMode is Player. /// The list of target players if PlayMode is PlayerList. /// The condition if PlayMode is Predicate. /// An optional audio filter to apply to the source. /// 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 = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null, IAudioFilter filter = null) + public static bool PlayFromPool(string path, Vector3 position, Transform parent = null, bool isSpatial = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, bool stream = false, bool useCache = false, SpeakerPlayMode playMode = SpeakerPlayMode.Global, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null, IAudioFilter filter = null) { - if (!WavUtility.TryValidatePath(path, out string errorMessage)) + if (string.IsNullOrEmpty(path)) + { + Log.Error("[Speaker] Provided path/url or name cannot be null or empty!"); + return false; + } + + if (!useCache && !WavUtility.TryValidatePath(path, out string errorMessage)) { Log.Error($"[Speaker] {errorMessage}"); return false; @@ -481,7 +488,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent IPcmSource source; try { - source = WavUtility.CreatePcmSource(path, stream); + source = WavUtility.CreatePcmSource(path, stream, useCache); } catch (Exception ex) { @@ -582,13 +589,20 @@ public static byte GetNextFreeControllerId(byte? preferredId = null) /// /// Plays a local wav file or web URL through this speaker. (File must be 16-bit, mono, and 48kHz.) /// - /// The path/url to the wav file. + /// The path/url or custom name(if is true) to the wav file. /// If true, clears the upcoming tracks in the playlist before starting playback. /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs). + /// If true, loads the audio via for optimize playback. /// true if the audio file was successfully found, loaded, and playback started; otherwise, false. - public bool Play(string path, bool clearQueue = true, bool stream = false) + public bool Play(string path, bool clearQueue = true, bool stream = false, bool useCache = false) { - if (!WavUtility.TryValidatePath(path, out string errorMessage)) + if (string.IsNullOrEmpty(path)) + { + Log.Error("[Speaker] Provided path/url or name cannot be null or empty!"); + return false; + } + + if (!useCache && !WavUtility.TryValidatePath(path, out string errorMessage)) { Log.Error($"[Speaker] {errorMessage}"); return false; @@ -597,7 +611,7 @@ public bool Play(string path, bool clearQueue = true, bool stream = false) IPcmSource newSource; try { - newSource = WavUtility.CreatePcmSource(path, stream); + newSource = WavUtility.CreatePcmSource(path, stream, useCache); } catch (Exception ex) { @@ -729,18 +743,25 @@ public void RestartTrack() /// /// Helper method to easily queue a .wav file/url with stream support. /// - /// The absolute path to the .wav file. + /// The path/url or custom name(if is true) to the wav file. /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs). + /// If true, loads the audio via for optimize playback. /// true if successfully queued or started. - public bool QueueTrack(string path, bool isStream = false) + public bool QueueTrack(string path, bool isStream = false, bool useCache = false) { - if (!WavUtility.TryValidatePath(path, out string errorMessage)) + if (string.IsNullOrEmpty(path)) + { + Log.Error("[Speaker] Provided path or cache name cannot be null or empty!"); + return false; + } + + if (!useCache && !WavUtility.TryValidatePath(path, out string errorMessage)) { Log.Error($"[Speaker] {errorMessage}"); return false; } - return QueueTrack(new QueuedTrack(path, () => WavUtility.CreatePcmSource(path, isStream))); + return QueueTrack(new QueuedTrack(path, () => WavUtility.CreatePcmSource(path, isStream, useCache))); } /// From 9d6c91bb2d3fe322163d48c078907393201eeff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 29 Mar 2026 21:46:24 +0300 Subject: [PATCH 090/102] fix file name --- EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs index 46ac0a17d..4aa3489f6 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs @@ -1,5 +1,5 @@ // ----------------------------------------------------------------------- -// +// // Copyright (c) ExMod Team. All rights reserved. // Licensed under the CC BY-SA 3.0 license. // From c2611489332f1aba82892a9021a9fd4e03c6b581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 29 Mar 2026 21:52:29 +0300 Subject: [PATCH 091/102] pcm method --- .../Audio/PcmSources/CachedPcmSource.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs index 4aa3489f6..ca1b1ae26 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs @@ -69,6 +69,41 @@ public CachedPcmSource(string name, string path) } } + /// + /// Initializes a new instance of the class by directly injecting raw PCM audio samples into the cache under a custom name. + /// + /// The custom name/key to assign to this audio in the cache. + /// The raw PCM audio samples (float array). + public CachedPcmSource(string name, float[] pcm) + { + if (string.IsNullOrEmpty(name) || pcm == null || pcm.Length == 0) + { + Log.Error($"[CachedPcmSource] Cannot initialize CachedPcmSource. Invalid name or empty PCM data for '{name}'."); + throw new ArgumentException("Name cannot be null/empty and PCM data cannot be null/empty."); + } + + if (AudioCache.TryGetValue(name, out AudioData cachedAudio)) + { + data = cachedAudio.Pcm; + TrackInfo = cachedAudio.TrackInfo; + Log.Debug($"[CachedPcmSource] Loaded audio from cache for key '{name}'."); + return; + } + + if (!AddSource(name, pcm)) + { + Log.Error($"[CachedPcmSource] Failed to load raw PCM data into cache under the name '{name}'."); + throw new InvalidOperationException($"Failed to cache PCM data for '{name}'."); + } + + if (AudioCache.TryGetValue(name, out AudioData createdAudio)) + { + data = createdAudio.Pcm; + TrackInfo = createdAudio.TrackInfo; + Log.Debug($"[CachedPcmSource] Successfully cached raw PCM data as '{name}'."); + } + } + /// /// Gets the global audio cache dictionary. /// From 3b1970a45c7555ebc562833072a863762b6fb2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 29 Mar 2026 22:21:15 +0300 Subject: [PATCH 092/102] helper for url --- .../Audio/PcmSources/CachedPcmSource.cs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs index ca1b1ae26..96a8450aa 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs @@ -37,7 +37,7 @@ public sealed class CachedPcmSource : IPcmSource /// /// Initializes a new instance of the class or local WAV files or fetches already cached audio, assigning a custom name to a specific local file path. /// - /// NOTE: URLs cannot be loaded directly here. Use Coroutine first. + /// NOTE: URLs cannot be loaded directly here. Use . /// The custom name/key to assign to this audio in the cache. /// The absolute path to the local audio file. public CachedPcmSource(string name, string path) @@ -52,7 +52,7 @@ public CachedPcmSource(string name, string path) { data = cachedAudio.Pcm; TrackInfo = cachedAudio.TrackInfo; - Log.Debug($"[CachedPcmSource] Loaded audio from cache for key '{name}'."); + Log.Info($"[CachedPcmSource] Loaded audio from cache for key '{name}'."); return; } @@ -86,7 +86,7 @@ public CachedPcmSource(string name, float[] pcm) { data = cachedAudio.Pcm; TrackInfo = cachedAudio.TrackInfo; - Log.Debug($"[CachedPcmSource] Loaded audio from cache for key '{name}'."); + Log.Info($"[CachedPcmSource] Loaded audio from cache for key '{name}'."); return; } @@ -100,7 +100,7 @@ public CachedPcmSource(string name, float[] pcm) { data = createdAudio.Pcm; TrackInfo = createdAudio.TrackInfo; - Log.Debug($"[CachedPcmSource] Successfully cached raw PCM data as '{name}'."); + Log.Info($"[CachedPcmSource] Successfully cached raw PCM data as '{name}'."); } } @@ -174,7 +174,6 @@ public static bool AddSource(string name, string path) { AudioData parsedData = WavUtility.WavToPcm(path); AudioCache.TryAdd(name, parsedData); - Log.Debug($"[CachedPcmSource] Successfully cached local file: '{path}' with name: '{name}'"); return true; } catch (Exception ex) @@ -227,7 +226,15 @@ public static bool AddSource(string name, AudioData audioData) /// The custom name/key to assign. /// The HTTP/HTTPS URL to the Wav file. /// A float IEnumerator for MEC execution. - public static IEnumerator AddUrlSource(string name, string url) + public static CoroutineHandle AddUrlSource(string name, string url) => Timing.RunCoroutine(AddUrlSourceCoroutine(name, url)); + + /// + /// Asynchronously downloads a Web URL and adds it to the cache. + /// + /// The custom name/key to assign. + /// The HTTP/HTTPS URL to the Wav file. + /// A float IEnumerator for MEC execution. + public static IEnumerator AddUrlSourceCoroutine(string name, string url) { if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) { @@ -264,7 +271,6 @@ public static IEnumerator AddUrlSource(string name, string url) parsedData.TrackInfo.Path = url; AudioCache.TryAdd(name, parsedData); - Log.Debug($"[CachedPcmSource] Successfully downloaded and cached URL: '{name}'"); } catch (Exception ex) { From a33d8b3f17b2115fb1a88856dc673cb60666aab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Sun, 29 Mar 2026 23:39:06 +0300 Subject: [PATCH 093/102] refactor cache & add Playbacksettings class for too long param --- .../Features/Audio/AudioPcmCache.cs | 215 ++++++++++++++ .../Audio/PcmSources/CachedPcmSource.cs | 264 +++--------------- .../Features/Audio/PlaybackSettings.cs | 83 ++++++ EXILED/Exiled.API/Features/Toys/Speaker.cs | 105 +++---- 4 files changed, 398 insertions(+), 269 deletions(-) create mode 100644 EXILED/Exiled.API/Features/Audio/AudioPcmCache.cs create mode 100644 EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs diff --git a/EXILED/Exiled.API/Features/Audio/AudioPcmCache.cs b/EXILED/Exiled.API/Features/Audio/AudioPcmCache.cs new file mode 100644 index 000000000..4f77f29bb --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/AudioPcmCache.cs @@ -0,0 +1,215 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.IO; + + using Exiled.API.Structs.Audio; + + using MEC; + + using RoundRestarting; + + using UnityEngine.Networking; + + /// + /// Manages a global in-memory cache of decoded PCM audio data. Once cached, audio can be played using . + /// + public static class AudioPcmCache + { + static AudioPcmCache() + { + AudioCache = new(); + RoundRestart.OnRestartTriggered += OnRoundRestart; + } + + /// + /// Gets the underlying cache store, keyed by name. + /// + public static ConcurrentDictionary AudioCache { get; } + + /// + /// Gets or sets a value indicating whether the cache is automatically cleared when a round restart is triggered. + /// + public static bool ClearOnRoundRestart { get; set; } = true; + + /// + /// Loads and caches a local .wav file under the specified name. + /// + /// The unique cache key to assign to this audio. + /// The absolute path to the local .wav file. + /// true if the file was successfully loaded and cached; otherwise, false. + public static bool Add(string name, string path) + { + if (!ValidateName(name)) + return false; + + if (path.StartsWith("http")) + { + Log.Error($"[AudioCache] '{path}' is a URL. Use AudioCache.AddUrl() for web sources."); + return false; + } + + if (AudioCache.ContainsKey(name)) + { + Log.Debug($"[AudioCache] Key '{name}' already exists. Skipping."); + return false; + } + + if (!File.Exists(path)) + { + Log.Error($"[AudioCache] Local file not found: '{path}'"); + return false; + } + + try + { + AudioData parsed = WavUtility.WavToPcm(path); + return AudioCache.TryAdd(name, parsed); + } + catch (Exception ex) + { + Log.Error($"[AudioCache] Failed to load '{path}' into cache:\n{ex}"); + return false; + } + } + + /// + /// Caches raw PCM audio samples under the specified name. + /// + /// The unique cache key to assign. + /// The raw PCM float array to cache. + /// true if successfully added; otherwise, false. + public static bool Add(string name, float[] pcm) + { + if (pcm == null || pcm.Length == 0) + { + Log.Error($"[AudioCache] Cannot cache null or empty PCM array for key '{name}'."); + return false; + } + + TrackData trackInfo = new() + { + Title = name, + Duration = (double)pcm.Length / VoiceChat.VoiceChatSettings.SampleRate, + }; + + return Add(name, new AudioData(pcm, trackInfo)); + } + + /// + /// Caches a fully constructed under the specified name. + /// + /// The unique cache key to assign. + /// The to store. + /// true if successfully added; otherwise, false. + public static bool Add(string name, AudioData audioData) + { + if (!ValidateName(name)) + return false; + + if (audioData.Pcm == null || audioData.Pcm.Length == 0) + { + Log.Error($"[AudioCache] AudioData for key '{name}' has null or empty PCM."); + return false; + } + + if (AudioCache.ContainsKey(name)) + { + Log.Debug($"[AudioCache] Key '{name}' already exists. Skipping."); + return false; + } + + return AudioCache.TryAdd(name, audioData); + } + + /// + /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the cache. + /// + /// The unique cache key to assign. + /// The HTTP or HTTPS URL pointing to a valid .wav file. + /// A for the running download coroutine. + public static CoroutineHandle AddUrl(string name, string url) => Timing.RunCoroutine(AddUrlCoroutine(name, url)); + + /// + /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the cache. + /// + /// The unique cache key to assign. + /// The HTTP or HTTPS URL pointing to a valid .wav file. + /// A MEC-compatible of . + public static IEnumerator AddUrlCoroutine(string name, string url) + { + if (!ValidateName(name)) + yield break; + + if (string.IsNullOrEmpty(url) || !url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + Log.Error($"[AudioCache] Invalid URL for key '{name}': '{url}'. Must start with http/https."); + yield break; + } + + if (AudioCache.ContainsKey(name)) + { + Log.Debug($"[AudioCache] Key '{name}' already exists. Skipping URL download."); + yield break; + } + + using UnityWebRequest www = UnityWebRequest.Get(url); + yield return Timing.WaitUntilDone(www.SendWebRequest()); + + if (www.result != UnityWebRequest.Result.Success) + { + Log.Error($"[AudioCache] Download failed for '{url}': {www.error}"); + yield break; + } + + try + { + AudioData parsed = WavUtility.WavToPcm(www.downloadHandler.data); + parsed.TrackInfo.Path = url; + + if (AudioCache.TryAdd(name, parsed)) + Log.Debug($"[AudioCache] Successfully cached '{url}' as '{name}'."); + } + catch (Exception ex) + { + Log.Error($"[AudioCache] Failed to parse downloaded WAV from '{url}':\n{ex}"); + } + } + + /// + /// Removes a cached audio entry by name. + /// + /// The cache name/key to remove. + /// true if the entry was found and removed; otherwise, false. + public static bool Remove(string name) => AudioCache.TryRemove(name, out _); + + /// + /// Clears all entries from the audio cache, freeing all associated memory. + /// + public static void Clear() => AudioCache.Clear(); + + private static bool ValidateName(string name) + { + if (!string.IsNullOrEmpty(name)) + return true; + + Log.Error("[AudioCache] Cache name (key) cannot be null or empty."); + return false; + } + + private static void OnRoundRestart() + { + if (ClearOnRoundRestart) + Clear(); + } + } +} diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs index 96a8450aa..790a7fe7b 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs @@ -8,36 +8,49 @@ namespace Exiled.API.Features.Audio.PcmSources { using System; - using System.Collections.Concurrent; - using System.Collections.Generic; using System.IO; using Exiled.API.Features.Audio; using Exiled.API.Interfaces.Audio; using Exiled.API.Structs.Audio; - using MEC; - - using RoundRestarting; - - using UnityEngine.Networking; - using VoiceChat; /// - /// Provides an that caches audio data in memory for optimize, repeated playback. Also serves as the central audio cache manager for the server. + /// Provides an that plays audio data directly from the for optimize, repeated playback. /// public sealed class CachedPcmSource : IPcmSource { private readonly float[] data; private int pos; - static CachedPcmSource() => RoundRestart.OnRestartTriggered += ClearCacheOnRestart; + /// + /// Initializes a new instance of the class by fetching already cached audio using its name. + /// + /// The name/key of the audio in the cache. + public CachedPcmSource(string name) + { + if (string.IsNullOrEmpty(name)) + { + Log.Error("[CachedPcmSource] Cannot initialize CachedPcmSource. Cache name cannot be null or empty."); + throw new ArgumentException("Cache name cannot be null or empty.", nameof(name)); + } + + if (AudioPcmCache.AudioCache.TryGetValue(name, out AudioData cachedAudio)) + { + data = cachedAudio.Pcm; + TrackInfo = cachedAudio.TrackInfo; + } + else + { + Log.Error($"[CachedPcmSource] Audio with name '{name}' not found in AudioPcmCache."); + throw new FileNotFoundException($"Audio '{name}' is not cached. Please cache it first using AudioPcmCache."); + } + } /// - /// Initializes a new instance of the class or local WAV files or fetches already cached audio, assigning a custom name to a specific local file path. + /// Initializes a new instance of the class. Fetches cached audio or loads a local WAV file into the cache if not present. /// - /// NOTE: URLs cannot be loaded directly here. Use . /// The custom name/key to assign to this audio in the cache. /// The absolute path to the local audio file. public CachedPcmSource(string name, string path) @@ -45,32 +58,27 @@ public CachedPcmSource(string name, string path) if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(path)) { Log.Error($"[CachedPcmSource] Cannot initialize CachedPcmSource. Invalid name: '{name}' or path: '{path}'."); - throw new ArgumentException("Name and path cannot be null or empty."); - } - - if (AudioCache.TryGetValue(name, out AudioData cachedAudio)) - { - data = cachedAudio.Pcm; - TrackInfo = cachedAudio.TrackInfo; - Log.Info($"[CachedPcmSource] Loaded audio from cache for key '{name}'."); - return; + throw new ArgumentException("Name or path cannot be null or empty."); } - if (!AddSource(name, path)) + if (!AudioPcmCache.AudioCache.ContainsKey(name)) { - Log.Error($"[CachedPcmSource] Failed to load local file '{path}' into cache under the name '{name}'."); - throw new FileNotFoundException($"Failed to cache and load '{path}'."); + if (!AudioPcmCache.Add(name, path)) + { + Log.Error($"[CachedPcmSource] Failed to load local file '{path}' into cache under the name '{name}'."); + throw new FileNotFoundException($"Failed to cache and load '{path}'."); + } } - if (AudioCache.TryGetValue(name, out AudioData createdAudio)) + if (AudioPcmCache.AudioCache.TryGetValue(name, out AudioData cachedAudio)) { - data = createdAudio.Pcm; - TrackInfo = createdAudio.TrackInfo; + data = cachedAudio.Pcm; + TrackInfo = cachedAudio.TrackInfo; } } /// - /// Initializes a new instance of the class by directly injecting raw PCM audio samples into the cache under a custom name. + /// Initializes a new instance of the class by fetching cached audio or injecting raw PCM samples into the cache if not present. /// /// The custom name/key to assign to this audio in the cache. /// The raw PCM audio samples (float array). @@ -79,41 +87,25 @@ public CachedPcmSource(string name, float[] pcm) if (string.IsNullOrEmpty(name) || pcm == null || pcm.Length == 0) { Log.Error($"[CachedPcmSource] Cannot initialize CachedPcmSource. Invalid name or empty PCM data for '{name}'."); - throw new ArgumentException("Name cannot be null/empty and PCM data cannot be null/empty."); + throw new ArgumentException("Name or PCM data cannot be null."); } - if (AudioCache.TryGetValue(name, out AudioData cachedAudio)) + if (!AudioPcmCache.AudioCache.ContainsKey(name)) { - data = cachedAudio.Pcm; - TrackInfo = cachedAudio.TrackInfo; - Log.Info($"[CachedPcmSource] Loaded audio from cache for key '{name}'."); - return; + if (!AudioPcmCache.Add(name, pcm)) + { + Log.Error($"[CachedPcmSource] Failed to load raw PCM data into cache under the name '{name}'."); + throw new InvalidOperationException($"Failed to cache PCM data for '{name}'."); + } } - if (!AddSource(name, pcm)) + if (AudioPcmCache.AudioCache.TryGetValue(name, out AudioData cachedAudio)) { - Log.Error($"[CachedPcmSource] Failed to load raw PCM data into cache under the name '{name}'."); - throw new InvalidOperationException($"Failed to cache PCM data for '{name}'."); - } - - if (AudioCache.TryGetValue(name, out AudioData createdAudio)) - { - data = createdAudio.Pcm; - TrackInfo = createdAudio.TrackInfo; - Log.Info($"[CachedPcmSource] Successfully cached raw PCM data as '{name}'."); + data = cachedAudio.Pcm; + TrackInfo = cachedAudio.TrackInfo; } } - /// - /// Gets the global audio cache dictionary. - /// - public static ConcurrentDictionary AudioCache { get; } = new(); - - /// - /// Gets or sets a value indicating whether the global audio cache should be cleared when a round restart is triggered. - /// - public static bool ClearCacheOnRoundRestart { get; set; } = true; - /// /// Gets the metadata of the loaded track. /// @@ -138,164 +130,6 @@ public double CurrentTime set => Seek(value); } - /// - /// Loads a local Wav file and adds it to the cache. - /// - /// The custom name/key to assign. - /// The absolute path to the local Wav file. - /// true if successfully read and cached; otherwise, false. - public static bool AddSource(string name, string path) - { - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(path)) - { - Log.Error($"[CachedPcmSource] Cannot add source. Invalid name: '{name}' or path: '{path}'."); - return false; - } - - if (path.StartsWith("http")) - { - Log.Error($"[CachedPcmSource] Use Timing.RunCoroutine(CachedPreloadedPcmSource.AddUrlCoroutine(...)) for URLs! Path: '{path}'"); - return false; - } - - if (AudioCache.ContainsKey(name)) - { - Log.Info($"[CachedPcmSource] A source with the name '{name}' already exists in the cache. Skipping addition."); - return false; - } - - if (!File.Exists(path)) - { - Log.Error($"[CachedPcmSource] Local file not found: '{path}'"); - return false; - } - - try - { - AudioData parsedData = WavUtility.WavToPcm(path); - AudioCache.TryAdd(name, parsedData); - return true; - } - catch (Exception ex) - { - Log.Error($"[CachedPcmSource] Failed to cache local file '{path}':\n{ex}"); - return false; - } - } - - /// - /// Directly adds raw PCM data and track information to the cache. - /// - /// The custom name/key to assign. - /// The raw PCM audio samples. - /// true if successfully added; otherwise, false. - public static bool AddSource(string name, float[] pcm) => AddSource(name, new AudioData { Pcm = pcm, TrackInfo = new TrackData { Title = name, Duration = (double)pcm.Length / VoiceChatSettings.SampleRate } }); - - /// - /// Directly adds raw PCM data and track information to the cache. - /// - /// The custom name/key to assign. - /// The struct containing PCM samples and metadata. - /// true if successfully added; otherwise, false. - public static bool AddSource(string name, AudioData audioData) - { - if (string.IsNullOrEmpty(name)) - { - Log.Error($"[CachedPcmSource] Cannot add source. Invalid name: '{name}'."); - return false; - } - - if (audioData.Equals(default(AudioData)) || audioData.Pcm == null) - { - Log.Error($"[CachedPcmSource] Cannot add source. AudioData is empty for name: '{name}'."); - return false; - } - - if (AudioCache.ContainsKey(name)) - { - Log.Info($"[CachedPcmSource] A source with the name '{name}' already exists in the cache. Skipping addition."); - return false; - } - - return AudioCache.TryAdd(name, audioData); - } - - /// - /// Asynchronously downloads a Web URL and adds it to the cache. - /// - /// The custom name/key to assign. - /// The HTTP/HTTPS URL to the Wav file. - /// A float IEnumerator for MEC execution. - public static CoroutineHandle AddUrlSource(string name, string url) => Timing.RunCoroutine(AddUrlSourceCoroutine(name, url)); - - /// - /// Asynchronously downloads a Web URL and adds it to the cache. - /// - /// The custom name/key to assign. - /// The HTTP/HTTPS URL to the Wav file. - /// A float IEnumerator for MEC execution. - public static IEnumerator AddUrlSourceCoroutine(string name, string url) - { - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) - { - Log.Error($"[CachedPcmSource] Cannot add URL source. Invalid name: '{name}' or URL: '{url}'."); - yield break; - } - - if (!url.StartsWith("http")) - { - Log.Error($"[CachedPcmSource] AddUrlCoroutine is only for web URLs! URL: '{url}'"); - yield break; - } - - if (AudioCache.ContainsKey(name)) - { - Log.Info($"[CachedPcmSource] A source with the name '{name}' already exists in the cache. Skipping addition."); - yield break; - } - - using UnityWebRequest www = UnityWebRequest.Get(url); - yield return Timing.WaitUntilDone(www.SendWebRequest()); - - if (www.result != UnityWebRequest.Result.Success) - { - Log.Error($"[CachedPcmSource] Web download failed for '{url}': {www.error}"); - yield break; - } - - try - { - byte[] downloadedBytes = www.downloadHandler.data; - AudioData parsedData = WavUtility.WavToPcm(downloadedBytes); - - parsedData.TrackInfo.Path = url; - - AudioCache.TryAdd(name, parsedData); - } - catch (Exception ex) - { - Log.Error($"[CachedPcmSource] Failed to parse downloaded WAV from '{url}':\n{ex}"); - } - } - - /// - /// Removes a specific audio track from the cache. - /// - /// The name/key of the cached audio to remove. - /// true if the item was successfully removed; otherwise, false. - public static bool Remove(string name) - { - return AudioCache.TryRemove(name, out _); - } - - /// - /// Clears the entire audio cache, freeing up RAM. Useful for RoundRestart or OnDisabled. - /// - public static void ClearCache() - { - AudioCache.Clear(); - } - /// /// Reads a sequence of PCM samples from the cached buffer into the specified array. /// @@ -334,11 +168,5 @@ public void Reset() public void Dispose() { } - - private static void ClearCacheOnRestart() - { - if (ClearCacheOnRoundRestart) - ClearCache(); - } } } \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs new file mode 100644 index 000000000..f2a918b41 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs @@ -0,0 +1,83 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + using System.Collections.Generic; + + using Exiled.API.Enums; + using Exiled.API.Features; + using Exiled.API.Features.Toys; + using Exiled.API.Interfaces.Audio; + + /// + /// Represents all configurable audio and network settings for play from pool method. + /// + public class PlaybackSettings + { + /// + /// Gets or sets the volume level. + /// + public float Volume { get; set; } = Speaker.DefaultVolume; + + /// + /// Gets or sets the playback pitch. + /// + public float Pitch { get; set; } = 1f; + + /// + /// Gets or sets a value indicating whether the audio source is spatialized (3D sound). + /// + public bool IsSpatial { get; set; } = Speaker.DefaultSpatial; + + /// + /// Gets or sets the minimum distance at which the audio reaches full volume. + /// + public float MinDistance { get; set; } = Speaker.DefaultMinDistance; + + /// + /// Gets or sets the maximum distance at which the audio can be heard. + /// + public float MaxDistance { get; set; } = Speaker.DefaultMaxDistance; + + /// + /// Gets or sets a value indicating whether the file should be streamed from disk. + /// + public bool Stream { get; set; } = false; + + /// + /// Gets or sets a value indicating whether to load the audio via the Cache Manager for optimize playback. + /// + public bool UseCache { get; set; } = false; + + /// + /// Gets or sets the play mode determining how the audio is sent to players. + /// + public SpeakerPlayMode PlayMode { get; set; } = SpeakerPlayMode.Global; + + /// + /// Gets or sets the target player (used when PlayMode is Player). + /// + public Player TargetPlayer { get; set; } = null; + + /// + /// Gets or sets the list of target players (used when PlayMode is PlayerList). + /// + public HashSet TargetPlayers { get; set; } = null; + + /// + /// Gets or sets the condition used to determine which players hear the audio. + /// + public Func Predicate { get; set; } = null; + + /// + /// Gets or sets an optional custom audio filter to apply to the PCM data. + /// + public IAudioFilter Filter { get; set; } = null; + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index eba9690ec..539882473 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -9,7 +9,6 @@ namespace Exiled.API.Features.Toys { using System; using System.Collections.Generic; - using System.IO; using AdminToys; @@ -20,8 +19,6 @@ namespace Exiled.API.Features.Toys using Exiled.API.Interfaces.Audio; using Exiled.API.Structs.Audio; - using HarmonyLib; - using Interfaces; using MEC; @@ -48,17 +45,36 @@ namespace Exiled.API.Features.Toys /// public class Speaker : AdminToy, IWrapper { + /// + /// The default volume level of the base SpeakerToy prefab. + /// + public const float DefaultVolume = 1f; + + /// + /// The default minimum spatial distance of the base SpeakerToy prefab. + /// + public const float DefaultMinDistance = 1f; + + /// + /// The default maximum spatial distance of the base SpeakerToy prefab. + /// + public const float DefaultMaxDistance = 15f; + + /// + /// The default network controller ID of the base SpeakerToy prefab. + /// + public const byte DefaultControllerId = 0; + + /// + /// The default spatialization setting of the base SpeakerToy prefab. + /// + public const bool DefaultSpatial = true; + /// /// 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; + internal static readonly Queue Pool; private const int FrameSize = VoiceChatSettings.PacketSizePerChannel; private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate; @@ -83,7 +99,11 @@ public class Speaker : AdminToy, IWrapper private bool isPitchDefault = true; private bool needsSyncWait = false; - static Speaker() => RoundRestart.OnRestartTriggered += Pool.Clear; + static Speaker() + { + Pool = new(); + RoundRestart.OnRestartTriggered += Pool.Clear; + } /// /// Initializes a new instance of the class. @@ -264,7 +284,7 @@ public float PlaybackProgress /// /// Gets the currently playing audio source. - /// Pre-made filters are available in the namespace. + /// Pre-made filters are available in the namespace. /// public IPcmSource CurrentSource { get; private set; } @@ -455,23 +475,12 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null) /// /// Rents a speaker from the pool, plays a local wav file or web stream one time, and automatically returns it to the pool afterwards. (File must be 16 bit, mono and 48khz.) /// - /// The path/url or custom name(if is true) to the wav file. + /// The path/url or custom name(if is true) to the wav file. /// The local 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. - /// If true, the file will be streamed from disk when played (Ignored for web URLs or if useCache is true). - /// If true, loads the audio via for optimize playback. - /// The play mode determining how audio is sent to players. - /// The target player if PlayMode is Player. - /// The list of target players if PlayMode is PlayerList. - /// The condition if PlayMode is Predicate. - /// An optional audio filter to apply to the source. + /// The optional audio and network settings. If null, default settings are used. /// 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 = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, bool stream = false, bool useCache = false, SpeakerPlayMode playMode = SpeakerPlayMode.Global, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null, IAudioFilter filter = null) + public static bool PlayFromPool(string path, Vector3 position, Transform parent = null, PlaybackSettings settings = null) { if (string.IsNullOrEmpty(path)) { @@ -479,7 +488,8 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent return false; } - if (!useCache && !WavUtility.TryValidatePath(path, out string errorMessage)) + settings ??= new PlaybackSettings(); + if (!settings.UseCache && !WavUtility.TryValidatePath(path, out string errorMessage)) { Log.Error($"[Speaker] {errorMessage}"); return false; @@ -488,7 +498,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent IPcmSource source; try { - source = WavUtility.CreatePcmSource(path, stream, useCache); + source = WavUtility.CreatePcmSource(path, settings.Stream, settings.UseCache); } catch (Exception ex) { @@ -496,7 +506,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent return false; } - return PlayFromPool(source, position, parent, isSpatial, volume, minDistance, maxDistance, pitch, playMode, targetPlayer, targetPlayers, predicate, filter); + return PlayFromPool(source, position, parent, settings); } /// @@ -505,18 +515,9 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent /// The custom IPcmSource to play. /// The local 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. - /// The target player if PlayMode is Player. - /// The list of target players if PlayMode is PlayerList. - /// The condition if PlayMode is Predicate. - /// An optional audio filter to apply to the source. + /// The optional audio and network settings. If null, default settings are used. /// true if the source is valid and playback started; otherwise, false. - public static bool PlayFromPool(IPcmSource source, Vector3 position, Transform parent = null, bool isSpatial = DefaultSpatial, float volume = DefaultVolume, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, float pitch = 1f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, Player targetPlayer = null, HashSet targetPlayers = null, Func predicate = null, IAudioFilter filter = null) + public static bool PlayFromPool(IPcmSource source, Vector3 position, Transform parent = null, PlaybackSettings settings = null) { if (source == null) { @@ -526,17 +527,19 @@ public static bool PlayFromPool(IPcmSource source, Vector3 position, Transform p Speaker speaker = Rent(parent, position); - speaker.Volume = volume; - speaker.IsSpatial = isSpatial; - speaker.MinDistance = minDistance; - speaker.MaxDistance = maxDistance; - - speaker.Pitch = pitch; - speaker.PlayMode = playMode; - speaker.Predicate = predicate; - speaker.TargetPlayer = targetPlayer; - speaker.TargetPlayers = targetPlayers; - speaker.Filter = filter; + settings ??= new PlaybackSettings(); + + speaker.Volume = settings.Volume; + speaker.IsSpatial = settings.IsSpatial; + speaker.MinDistance = settings.MinDistance; + speaker.MaxDistance = settings.MaxDistance; + + speaker.Pitch = settings.Pitch; + speaker.PlayMode = settings.PlayMode; + speaker.Predicate = settings.Predicate; + speaker.TargetPlayer = settings.TargetPlayer; + speaker.TargetPlayers = settings.TargetPlayers; + speaker.Filter = settings.Filter; speaker.ReturnToPoolAfter = true; From 85f2dcbbe6920e177857722e01e9b13841fac2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 30 Mar 2026 00:49:54 +0300 Subject: [PATCH 094/102] It seems to get more complicated each time. --- .../{AudioPcmCache.cs => AudioDataStorage.cs} | 94 +++++++++---------- .../Audio/PcmSources/CachedPcmSource.cs | 42 +++++---- .../Features/Audio/PlaybackSettings.cs | 7 +- .../Exiled.API/Features/Audio/WavUtility.cs | 14 +-- EXILED/Exiled.API/Features/Toys/Speaker.cs | 6 +- 5 files changed, 85 insertions(+), 78 deletions(-) rename EXILED/Exiled.API/Features/Audio/{AudioPcmCache.cs => AudioDataStorage.cs} (60%) diff --git a/EXILED/Exiled.API/Features/Audio/AudioPcmCache.cs b/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs similarity index 60% rename from EXILED/Exiled.API/Features/Audio/AudioPcmCache.cs rename to EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs index 4f77f29bb..2a8602403 100644 --- a/EXILED/Exiled.API/Features/Audio/AudioPcmCache.cs +++ b/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs @@ -1,5 +1,5 @@ // ----------------------------------------------------------------------- -// +// // Copyright (c) ExMod Team. All rights reserved. // Licensed under the CC BY-SA 3.0 license. // @@ -21,78 +21,78 @@ namespace Exiled.API.Features.Audio using UnityEngine.Networking; /// - /// Manages a global in-memory cache of decoded PCM audio data. Once cached, audio can be played using . + /// Manages a global in-memory storage of decoded PCM audio data. Once stored, audio can be played using . /// - public static class AudioPcmCache + public static class AudioDataStorage { - static AudioPcmCache() + static AudioDataStorage() { - AudioCache = new(); + AudioStorage = new(); RoundRestart.OnRestartTriggered += OnRoundRestart; } /// - /// Gets the underlying cache store, keyed by name. + /// Gets the underlying storage, keyed by name. /// - public static ConcurrentDictionary AudioCache { get; } + public static ConcurrentDictionary AudioStorage { get; } /// - /// Gets or sets a value indicating whether the cache is automatically cleared when a round restart is triggered. + /// Gets or sets a value indicating whether the storage is automatically cleared when a round restart is triggered. /// public static bool ClearOnRoundRestart { get; set; } = true; /// - /// Loads and caches a local .wav file under the specified name. + /// Loads and stores a local .wav file under the specified name. /// - /// The unique cache key to assign to this audio. + /// The unique storage key to assign to this audio. /// The absolute path to the local .wav file. - /// true if the file was successfully loaded and cached; otherwise, false. + /// true if the file was successfully loaded and stored; otherwise, false. public static bool Add(string name, string path) { if (!ValidateName(name)) return false; - if (path.StartsWith("http")) + if (AudioStorage.ContainsKey(name)) { - Log.Error($"[AudioCache] '{path}' is a URL. Use AudioCache.AddUrl() for web sources."); + Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping add."); return false; } - if (AudioCache.ContainsKey(name)) + if (path.StartsWith("http")) { - Log.Debug($"[AudioCache] Key '{name}' already exists. Skipping."); + Log.Error($"[AudioDataStorage] '{path}' is a URL. Use AudioDataStorage.AddUrl() for web sources."); return false; } if (!File.Exists(path)) { - Log.Error($"[AudioCache] Local file not found: '{path}'"); + Log.Error($"[AudioDataStorage] Local file not found: '{path}'"); return false; } try { AudioData parsed = WavUtility.WavToPcm(path); - return AudioCache.TryAdd(name, parsed); + return AudioStorage.TryAdd(name, parsed); } catch (Exception ex) { - Log.Error($"[AudioCache] Failed to load '{path}' into cache:\n{ex}"); + Log.Error($"[AudioDataStorage] Failed to load '{path}' into storage:\n{ex}"); return false; } } /// - /// Caches raw PCM audio samples under the specified name. + /// Stores raw PCM audio samples under the specified name. /// - /// The unique cache key to assign. - /// The raw PCM float array to cache. + /// The unique storage key to assign. + /// The raw PCM float array to store. /// true if successfully added; otherwise, false. public static bool Add(string name, float[] pcm) { - if (pcm == null || pcm.Length == 0) + if (pcm == null) { - Log.Error($"[AudioCache] Cannot cache null or empty PCM array for key '{name}'."); + Log.Error($"[AudioDataStorage] Cannot store null array for key '{name}'."); return false; } @@ -106,9 +106,9 @@ public static bool Add(string name, float[] pcm) } /// - /// Caches a fully constructed under the specified name. + /// Stores a fully constructed under the specified name. /// - /// The unique cache key to assign. + /// The unique storage key to assign. /// The to store. /// true if successfully added; otherwise, false. public static bool Add(string name, AudioData audioData) @@ -118,31 +118,31 @@ public static bool Add(string name, AudioData audioData) if (audioData.Pcm == null || audioData.Pcm.Length == 0) { - Log.Error($"[AudioCache] AudioData for key '{name}' has null or empty PCM."); + Log.Error($"[AudioDataStorage] AudioData for key '{name}' has null or empty PCM."); return false; } - if (AudioCache.ContainsKey(name)) + if (AudioStorage.ContainsKey(name)) { - Log.Debug($"[AudioCache] Key '{name}' already exists. Skipping."); + Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping add."); return false; } - return AudioCache.TryAdd(name, audioData); + return AudioStorage.TryAdd(name, audioData); } /// - /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the cache. + /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the storage. /// - /// The unique cache key to assign. + /// The unique storage key to assign. /// The HTTP or HTTPS URL pointing to a valid .wav file. /// A for the running download coroutine. public static CoroutineHandle AddUrl(string name, string url) => Timing.RunCoroutine(AddUrlCoroutine(name, url)); /// - /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the cache. + /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the storage. /// - /// The unique cache key to assign. + /// The unique storage key to assign. /// The HTTP or HTTPS URL pointing to a valid .wav file. /// A MEC-compatible of . public static IEnumerator AddUrlCoroutine(string name, string url) @@ -150,15 +150,15 @@ public static IEnumerator AddUrlCoroutine(string name, string url) if (!ValidateName(name)) yield break; - if (string.IsNullOrEmpty(url) || !url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrEmpty(url) || !url.StartsWith("http")) { - Log.Error($"[AudioCache] Invalid URL for key '{name}': '{url}'. Must start with http/https."); + Log.Error($"[AudioDataStorage] Invalid URL for key '{name}': '{url}'. Must start with http/https."); yield break; } - if (AudioCache.ContainsKey(name)) + if (AudioStorage.ContainsKey(name)) { - Log.Debug($"[AudioCache] Key '{name}' already exists. Skipping URL download."); + Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping download."); yield break; } @@ -167,7 +167,7 @@ public static IEnumerator AddUrlCoroutine(string name, string url) if (www.result != UnityWebRequest.Result.Success) { - Log.Error($"[AudioCache] Download failed for '{url}': {www.error}"); + Log.Error($"[AudioDataStorage] Download failed for '{url}': {www.error}"); yield break; } @@ -175,34 +175,32 @@ public static IEnumerator AddUrlCoroutine(string name, string url) { AudioData parsed = WavUtility.WavToPcm(www.downloadHandler.data); parsed.TrackInfo.Path = url; - - if (AudioCache.TryAdd(name, parsed)) - Log.Debug($"[AudioCache] Successfully cached '{url}' as '{name}'."); + AudioStorage.TryAdd(name, parsed); } catch (Exception ex) { - Log.Error($"[AudioCache] Failed to parse downloaded WAV from '{url}':\n{ex}"); + Log.Error($"[AudioDataStorage] Failed to parse downloaded WAV from '{url}':\n{ex}"); } } /// - /// Removes a cached audio entry by name. + /// Removes a stored audio entry by name. /// - /// The cache name/key to remove. + /// The storage name/key to remove. /// true if the entry was found and removed; otherwise, false. - public static bool Remove(string name) => AudioCache.TryRemove(name, out _); + public static bool Remove(string name) => AudioStorage.TryRemove(name, out _); /// - /// Clears all entries from the audio cache, freeing all associated memory. + /// Clears all entries from the audio storage, freeing all associated memory. /// - public static void Clear() => AudioCache.Clear(); + public static void Clear() => AudioStorage.Clear(); private static bool ValidateName(string name) { if (!string.IsNullOrEmpty(name)) return true; - Log.Error("[AudioCache] Cache name (key) cannot be null or empty."); + Log.Error("[AudioDataStorage] Storage name (key) cannot be null or empty."); return false; } diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs index 790a7fe7b..01ea22267 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs @@ -17,7 +17,7 @@ namespace Exiled.API.Features.Audio.PcmSources using VoiceChat; /// - /// Provides an that plays audio data directly from the for optimize, repeated playback. + /// Provides an that plays audio data directly from the for optimized, repeated playback. /// public sealed class CachedPcmSource : IPcmSource { @@ -36,16 +36,14 @@ public CachedPcmSource(string name) throw new ArgumentException("Cache name cannot be null or empty.", nameof(name)); } - if (AudioPcmCache.AudioCache.TryGetValue(name, out AudioData cachedAudio)) + if (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio)) { - data = cachedAudio.Pcm; - TrackInfo = cachedAudio.TrackInfo; - } - else - { - Log.Error($"[CachedPcmSource] Audio with name '{name}' not found in AudioPcmCache."); - throw new FileNotFoundException($"Audio '{name}' is not cached. Please cache it first using AudioPcmCache."); + Log.Error($"[CachedPcmSource] Audio with name '{name}' not found in AudioDataStorage."); + throw new FileNotFoundException($"Audio '{name}' is not cached. Please cache it first using AudioDataStorage"); } + + data = cachedAudio.Pcm; + TrackInfo = cachedAudio.TrackInfo; } /// @@ -61,20 +59,23 @@ public CachedPcmSource(string name, string path) throw new ArgumentException("Name or path cannot be null or empty."); } - if (!AudioPcmCache.AudioCache.ContainsKey(name)) + if (!AudioDataStorage.AudioStorage.ContainsKey(name)) { - if (!AudioPcmCache.Add(name, path)) + if (!AudioDataStorage.Add(name, path)) { Log.Error($"[CachedPcmSource] Failed to load local file '{path}' into cache under the name '{name}'."); throw new FileNotFoundException($"Failed to cache and load '{path}'."); } } - if (AudioPcmCache.AudioCache.TryGetValue(name, out AudioData cachedAudio)) + if (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio)) { - data = cachedAudio.Pcm; - TrackInfo = cachedAudio.TrackInfo; + Log.Error($"[CachedPcmSource] Audio with name '{name}' could not be retrieved from storage after adding."); + throw new InvalidOperationException($"Failed to retrieve '{name}' from storage after caching."); } + + data = cachedAudio.Pcm; + TrackInfo = cachedAudio.TrackInfo; } /// @@ -90,20 +91,23 @@ public CachedPcmSource(string name, float[] pcm) throw new ArgumentException("Name or PCM data cannot be null."); } - if (!AudioPcmCache.AudioCache.ContainsKey(name)) + if (!AudioDataStorage.AudioStorage.ContainsKey(name)) { - if (!AudioPcmCache.Add(name, pcm)) + if (!AudioDataStorage.Add(name, pcm)) { Log.Error($"[CachedPcmSource] Failed to load raw PCM data into cache under the name '{name}'."); throw new InvalidOperationException($"Failed to cache PCM data for '{name}'."); } } - if (AudioPcmCache.AudioCache.TryGetValue(name, out AudioData cachedAudio)) + if (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio)) { - data = cachedAudio.Pcm; - TrackInfo = cachedAudio.TrackInfo; + Log.Error($"[CachedPcmSource] Audio with name '{name}' could not be retrieved from storage after adding."); + throw new InvalidOperationException($"Failed to retrieve '{name}' from storage after caching."); } + + data = cachedAudio.Pcm; + TrackInfo = cachedAudio.TrackInfo; } /// diff --git a/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs index f2a918b41..7def86b18 100644 --- a/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs +++ b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs @@ -20,6 +20,11 @@ namespace Exiled.API.Features.Audio /// public class PlaybackSettings { + /// + /// Gets a global, read-only instance of with all default values. + /// + public static readonly PlaybackSettings Default = new(); + /// /// Gets or sets the volume level. /// @@ -51,7 +56,7 @@ public class PlaybackSettings public bool Stream { get; set; } = false; /// - /// Gets or sets a value indicating whether to load the audio via the Cache Manager for optimize playback. + /// Gets or sets a value indicating whether to load the audio via the storage Manager for optimized playback. /// public bool UseCache { get; set; } = false; diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs index 77ae5e808..5eb818a34 100644 --- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -35,12 +35,12 @@ public static class WavUtility /// An initialized . public static IPcmSource CreatePcmSource(string path, bool stream = false, bool cache = false) { - if (!cache && path.StartsWith("http")) - return new PreloadWebWavPcmSource(path); - if (cache) return new CachedPcmSource(path, path); + if (path.StartsWith("http")) + return new PreloadWebWavPcmSource(path); + if (stream) return new WavStreamSource(path); @@ -56,13 +56,13 @@ public static AudioData WavToPcm(string path) { if (!File.Exists(path)) { - Log.Error($"[Speaker] The specified local file does not exist, path: `{path}`"); + Log.Error($"[WavUtility] The specified local file does not exist, path: `{path}`"); throw new FileNotFoundException("File does not exist"); } if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) { - Log.Error($"[Speaker] The file type '{Path.GetExtension(path)}' is not supported for wav utility. Please use .wav file."); + Log.Error($"[WavUtility] The file type '{Path.GetExtension(path)}' is not supported for wav utility. Please use .wav file."); throw new InvalidDataException("Unsupported WAV format."); } @@ -161,7 +161,7 @@ public static TrackData SkipHeader(Stream stream) if (format != 1 || channels != 1 || rate != VoiceChatSettings.SampleRate || bits != 16) { - Log.Error($"[Speaker] Invalid WAV format (format={format}, channels={channels}, rate={rate}, bits={bits}). Expected PCM16, mono and {VoiceChatSettings.SampleRate}Hz."); + Log.Error($"[WavUtility] Invalid WAV format (format={format}, channels={channels}, rate={rate}, bits={bits}). Expected PCM16, mono and {VoiceChatSettings.SampleRate}Hz."); throw new InvalidDataException("Unsupported WAV format."); } @@ -229,7 +229,7 @@ public static TrackData SkipHeader(Stream stream) if (stream.Position >= stream.Length) { - Log.Error("[Speaker] WAV file does not contain a 'data' chunk."); + Log.Error("[WavUtility] 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/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 539882473..ccb9a26ac 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -475,7 +475,7 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null) /// /// Rents a speaker from the pool, plays a local wav file or web stream one time, and automatically returns it to the pool afterwards. (File must be 16 bit, mono and 48khz.) /// - /// The path/url or custom name(if is true) to the wav file. + /// The path/url or custom name/key (if has set to true) to the wav file. /// The local position of the speaker. /// The parent transform, if any. /// The optional audio and network settings. If null, default settings are used. @@ -595,7 +595,7 @@ public static byte GetNextFreeControllerId(byte? preferredId = null) /// The path/url or custom name(if is true) to the wav file. /// If true, clears the upcoming tracks in the playlist before starting playback. /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs). - /// If true, loads the audio via for optimize playback. + /// If true, loads the audio via for optimized playback. /// true if the audio file was successfully found, loaded, and playback started; otherwise, false. public bool Play(string path, bool clearQueue = true, bool stream = false, bool useCache = false) { @@ -748,7 +748,7 @@ public void RestartTrack() /// /// The path/url or custom name(if is true) to the wav file. /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs). - /// If true, loads the audio via for optimize playback. + /// If true, loads the audio via for optimized playback. /// true if successfully queued or started. public bool QueueTrack(string path, bool isStream = false, bool useCache = false) { From 2b79d88b9f50157aaf4e03bcc53e460091fe0be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 30 Mar 2026 01:30:02 +0300 Subject: [PATCH 095/102] d --- .../Features/Audio/PcmSources/WavStreamSource.cs | 12 +++++++----- .../Exiled.API/Features/Audio/PlaybackSettings.cs | 14 +++++++++++--- EXILED/Exiled.API/Features/Toys/Speaker.cs | 5 +++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs index 517ea026d..32a219a2b 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs @@ -77,6 +77,11 @@ public double CurrentTime /// The number of samples read. public int Read(float[] buffer, int offset, int count) { + count = Math.Min(count, buffer.Length - offset); + + if (count <= 0) + return 0; + int bytesNeeded = count * 2; if (internalBuffer.Length < bytesNeeded) @@ -96,13 +101,10 @@ public int Read(float[] buffer, int offset, int count) Span byteSpan = internalBuffer.AsSpan(0, bytesRead); Span shortSpan = MemoryMarshal.Cast(byteSpan); - int samplesInDestination = buffer.Length - offset; - int samplesToWrite = Math.Min(shortSpan.Length, samplesInDestination); - - for (int i = 0; i < samplesToWrite; i++) + for (int i = 0; i < shortSpan.Length; i++) buffer[offset + i] = shortSpan[i] * Divide; - return samplesToWrite; + return shortSpan.Length; } /// diff --git a/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs index 7def86b18..3f12526e8 100644 --- a/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs +++ b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs @@ -15,6 +15,8 @@ namespace Exiled.API.Features.Audio using Exiled.API.Features.Toys; using Exiled.API.Interfaces.Audio; + using Mirror; + /// /// Represents all configurable audio and network settings for play from pool method. /// @@ -52,6 +54,7 @@ public class PlaybackSettings /// /// Gets or sets a value indicating whether the file should be streamed from disk. + /// Ignored for web URLs and cached sources. /// public bool Stream { get; set; } = false; @@ -60,23 +63,28 @@ public class PlaybackSettings /// public bool UseCache { get; set; } = false; + /// + /// Gets or sets the Mirror network channel used for sending audio packets. + /// + public int Channel { get; set; } = Channels.ReliableOrdered2; + /// /// Gets or sets the play mode determining how the audio is sent to players. /// public SpeakerPlayMode PlayMode { get; set; } = SpeakerPlayMode.Global; /// - /// Gets or sets the target player (used when PlayMode is Player). + /// Gets or sets the target player (used when is ). /// public Player TargetPlayer { get; set; } = null; /// - /// Gets or sets the list of target players (used when PlayMode is PlayerList). + /// Gets or sets the list of target players (used when is ). /// public HashSet TargetPlayers { get; set; } = null; /// - /// Gets or sets the condition used to determine which players hear the audio. + /// Gets or sets the condition used to determine which players hear the audio (used when is ). /// public Func Predicate { get; set; } = null; diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index ccb9a26ac..e30d7a498 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -488,7 +488,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent return false; } - settings ??= new PlaybackSettings(); + settings ??= PlaybackSettings.Default; if (!settings.UseCache && !WavUtility.TryValidatePath(path, out string errorMessage)) { Log.Error($"[Speaker] {errorMessage}"); @@ -527,7 +527,7 @@ public static bool PlayFromPool(IPcmSource source, Vector3 position, Transform p Speaker speaker = Rent(parent, position); - settings ??= new PlaybackSettings(); + settings ??= PlaybackSettings.Default; speaker.Volume = settings.Volume; speaker.IsSpatial = settings.IsSpatial; @@ -535,6 +535,7 @@ public static bool PlayFromPool(IPcmSource source, Vector3 position, Transform p speaker.MaxDistance = settings.MaxDistance; speaker.Pitch = settings.Pitch; + speaker.Channel = settings.Channel; speaker.PlayMode = settings.PlayMode; speaker.Predicate = settings.Predicate; speaker.TargetPlayer = settings.TargetPlayer; From 78038a55adb0b574c6a1544582c376f5d42a5eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 30 Mar 2026 01:58:03 +0300 Subject: [PATCH 096/102] change ai filter to more optimized ai filter --- .../Audio/Filters/PitchShiftFilter.cs | 290 ++++++++++-------- 1 file changed, 166 insertions(+), 124 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs index f785c3d5d..34a9faa61 100644 --- a/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs +++ b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs @@ -18,32 +18,55 @@ namespace Exiled.API.Features.Audio.Filters /// public sealed class PitchShiftFilter : IAudioFilter { + private const int FftFrameSize = 2048; + private const int FftFrameSize2 = FftFrameSize / 2; private const int MaxFrameLength = 8192; + private static readonly float[] HannWindow = BuildHannWindow(); + private readonly float[] gInFIFO = new float[MaxFrameLength]; private readonly float[] gOutFIFO = new float[MaxFrameLength]; - private readonly float[] gFFTworksp = new float[2 * MaxFrameLength]; - private readonly float[] gLastPhase = new float[(MaxFrameLength / 2) + 1]; - private readonly float[] gSumPhase = new float[(MaxFrameLength / 2) + 1]; - private readonly float[] gOutputAccum = new float[2 * MaxFrameLength]; - private readonly float[] gAnaFreq = new float[MaxFrameLength]; - private readonly float[] gAnaMagn = new float[MaxFrameLength]; - private readonly float[] gSynFreq = new float[MaxFrameLength]; - private readonly float[] gSynMagn = new float[MaxFrameLength]; + private readonly float[] gFFTworksp = new float[2 * FftFrameSize]; + private readonly float[] gLastPhase = new float[FftFrameSize2 + 1]; + private readonly float[] gSumPhase = new float[FftFrameSize2 + 1]; + private readonly float[] gOutputAccum = new float[2 * FftFrameSize]; + private readonly float[] gAnaFreq = new float[FftFrameSize]; + private readonly float[] gAnaMagn = new float[FftFrameSize]; + private readonly float[] gSynFreq = new float[FftFrameSize]; + private readonly float[] gSynMagn = new float[FftFrameSize]; + + private readonly float[] outputBuffer = new float[MaxFrameLength]; + + private readonly float[] twiddleCos; + private readonly float[] twiddleSin; private long gRover = 0; + private int cachedOversample = -1; + private long stepSize; + private long inFifoLatency; + private float expct; + /// /// Initializes a new instance of the class. /// - /// The pitch multiplier. Above 1.0 for Helium/Thin voice, below 1.0 for Deep/Monster voice. - public PitchShiftFilter(float pitch = 1.5f) + /// The pitch multiplier. Above 1.0 for higher pitch, below 1.0 for lower pitch. + /// + /// The overlap factor controlling quality vs CPU usage. Higher values produce better quality but require more CPU. Must be a power of 2. Typical values: 2 (low CPU), 4 (default, balanced), 8 (high quality). + /// + public PitchShiftFilter(float pitch = 1.5f, int oversample = 4) { + twiddleCos = new float[FftFrameSize]; + twiddleSin = new float[FftFrameSize]; + PrecomputeTwiddleFactors(); + Pitch = pitch; + Oversample = oversample; } /// - /// Gets or sets the pitch multiplier dynamically during playback. + /// Gets or sets the pitch multiplier applied during playback. + /// Values above 1.0 produce a higher (thinner) pitch; values below 1.0 produce a lower (deeper) pitch. /// public float Pitch { @@ -52,186 +75,205 @@ public float Pitch } /// - /// Processes the raw PCM audio frame directly before it is encoded and sending. + /// Gets or sets the overlap factor controlling quality versus CPU usage. + /// Higher values improve quality but increase processing cost. Must be a power of 2. + /// Typical values: 2 (low CPU), 4 (balanced, default), 8 (high quality). /// - /// The array of PCM audio samples. + public int Oversample + { + get => field; + set + { + field = Mathf.Clamp(value, 2, 32); + cachedOversample = -1; + } + } + + /// public void Process(float[] frame) { if (Mathf.Abs(Pitch - 1.0f) < 0.001f) return; - SmbPitchShift(Pitch, frame.Length, 2048, 4, VoiceChat.VoiceChatSettings.SampleRate, frame, frame); + EnsureOversampleConstants(); + SmbPitchShift(Pitch, frame.Length, frame, outputBuffer); + + Array.Copy(outputBuffer, frame, frame.Length); } - /// - /// Stephan M. Bernsee's Phase Vocoder routine. - /// - private void SmbPitchShift(float pitchShift, long numSampsToProcess, long fftFrameSize, long osamp, float sampleRate, float[] indata, float[] outdata) + private static float[] BuildHannWindow() { - double magn, phase, tmp, window, real, imag; - double freqPerBin, expct; - long i, k, qpd, index, inFifoLatency, stepSize, fftFrameSize2; + float[] window = new float[FftFrameSize]; + for (int i = 0; i < FftFrameSize; i++) + window[i] = 0.5f - (0.5f * Mathf.Cos(2.0f * Mathf.PI * i / FftFrameSize)); - fftFrameSize2 = fftFrameSize / 2; - stepSize = fftFrameSize / osamp; - freqPerBin = sampleRate / (double)fftFrameSize; - expct = 2.0 * Math.PI * (double)stepSize / (double)fftFrameSize; - inFifoLatency = fftFrameSize - stepSize; + return window; + } + + private void PrecomputeTwiddleFactors() + { + for (int le = 4, k = 0; le <= FftFrameSize * 2; le <<= 1, k++) + { + int le2 = le >> 1; + float arg = Mathf.PI / (le2 >> 1); + twiddleCos[k] = Mathf.Cos(arg); + twiddleSin[k] = Mathf.Sin(arg); + } + } + + private void EnsureOversampleConstants() + { + if (cachedOversample == Oversample) + return; + + cachedOversample = Oversample; + stepSize = FftFrameSize / Oversample; + inFifoLatency = FftFrameSize - stepSize; + expct = 2.0f * Mathf.PI * stepSize / FftFrameSize; if (gRover == 0) gRover = inFifoLatency; + } + + private void SmbPitchShift(float pitchShift, int numSampsToProcess, float[] indata, float[] outdata) + { + float freqPerBin = VoiceChat.VoiceChatSettings.SampleRate / (float)FftFrameSize; - for (i = 0; i < numSampsToProcess; i++) + for (int i = 0; i < numSampsToProcess; i++) { gInFIFO[gRover] = indata[i]; outdata[i] = gOutFIFO[gRover - inFifoLatency]; gRover++; - if (gRover >= fftFrameSize) + if (gRover < FftFrameSize) + continue; + + gRover = inFifoLatency; + + for (int k = 0; k < FftFrameSize; k++) { - gRover = inFifoLatency; + gFFTworksp[2 * k] = gInFIFO[k] * HannWindow[k]; + gFFTworksp[(2 * k) + 1] = 0.0f; + } - for (k = 0; k < fftFrameSize; k++) - { - window = (-0.5 * Math.Cos(2.0 * Math.PI * k / (double)fftFrameSize)) + 0.5; - gFFTworksp[2 * k] = (float)(gInFIFO[k] * window); - gFFTworksp[(2 * k) + 1] = 0.0f; - } + SmbFft(gFFTworksp, -1); - SmbFft(gFFTworksp, fftFrameSize, -1); + for (int k = 0; k <= FftFrameSize2; k++) + { + float real = gFFTworksp[2 * k]; + float imag = gFFTworksp[(2 * k) + 1]; - for (k = 0; k <= fftFrameSize2; k++) - { - real = gFFTworksp[2 * k]; - imag = gFFTworksp[(2 * k) + 1]; + float magn = 2.0f * Mathf.Sqrt((real * real) + (imag * imag)); + float phase = Mathf.Atan2(imag, real); - magn = 2.0 * Math.Sqrt((real * real) + (imag * imag)); - phase = Math.Atan2(imag, real); + float tmp = phase - gLastPhase[k]; + gLastPhase[k] = phase; - tmp = phase - gLastPhase[k]; - gLastPhase[k] = (float)phase; + tmp -= k * expct; - tmp -= (double)k * expct; - qpd = (long)(tmp / Math.PI); - if (qpd >= 0) - qpd += qpd & 1; - else - qpd -= qpd & 1; - tmp -= Math.PI * (double)qpd; + long qpd = (long)(tmp / Mathf.PI); + if (qpd >= 0) + qpd += qpd & 1; + else + qpd -= qpd & 1; - tmp = osamp * tmp / (2.0 * Math.PI); - tmp = ((double)k * freqPerBin) + (tmp * freqPerBin); + tmp -= Mathf.PI * qpd; - gAnaMagn[k] = (float)magn; - gAnaFreq[k] = (float)tmp; - } + tmp = Oversample * tmp / (2.0f * Mathf.PI); + tmp = (k * freqPerBin) + (tmp * freqPerBin); - for (int zero = 0; zero < fftFrameSize; zero++) - { - gSynMagn[zero] = 0; - gSynFreq[zero] = 0; - } + gAnaMagn[k] = magn; + gAnaFreq[k] = tmp; + } + + Array.Clear(gSynMagn, 0, FftFrameSize); + Array.Clear(gSynFreq, 0, FftFrameSize); - for (k = 0; k <= fftFrameSize2; k++) + for (int k = 0; k <= FftFrameSize2; k++) + { + long index = (long)(k * pitchShift); + if (index <= FftFrameSize2) { - index = (long)(k * pitchShift); - if (index <= fftFrameSize2) - { - gSynMagn[index] += gAnaMagn[k]; - gSynFreq[index] = gAnaFreq[k] * pitchShift; - } + gSynMagn[index] += gAnaMagn[k]; + gSynFreq[index] = gAnaFreq[k] * pitchShift; } + } - for (k = 0; k <= fftFrameSize2; k++) - { - magn = gSynMagn[k]; - tmp = gSynFreq[k]; + for (int k = 0; k <= FftFrameSize2; k++) + { + float magn = gSynMagn[k]; + float tmp = gSynFreq[k]; - tmp -= (double)k * freqPerBin; - tmp /= freqPerBin; - tmp = 2.0 * Math.PI * tmp / osamp; - tmp += (double)k * expct; + tmp -= k * freqPerBin; + tmp /= freqPerBin; + tmp = 2.0f * Mathf.PI * tmp / Oversample; + tmp += k * expct; - gSumPhase[k] += (float)tmp; - phase = gSumPhase[k]; + gSumPhase[k] += tmp; - gFFTworksp[2 * k] = (float)(magn * Math.Cos(phase)); - gFFTworksp[(2 * k) + 1] = (float)(magn * Math.Sin(phase)); - } + gFFTworksp[2 * k] = magn * Mathf.Cos(gSumPhase[k]); + gFFTworksp[(2 * k) + 1] = magn * Mathf.Sin(gSumPhase[k]); + } - for (k = fftFrameSize + 2; k < 2 * fftFrameSize; k++) - gFFTworksp[k] = 0.0f; + Array.Clear(gFFTworksp, FftFrameSize + 2, FftFrameSize - 2); - SmbFft(gFFTworksp, fftFrameSize, 1); + SmbFft(gFFTworksp, 1); - for (k = 0; k < fftFrameSize; k++) - { - window = (-0.5 * Math.Cos(2.0 * Math.PI * (double)k / (double)fftFrameSize)) + 0.5; - gOutputAccum[k] += (float)(2.0 * window * gFFTworksp[2 * k] / (fftFrameSize2 * osamp)); - } + for (int k = 0; k < FftFrameSize; k++) + gOutputAccum[k] += 2.0f * HannWindow[k] * gFFTworksp[2 * k] / (FftFrameSize2 * Oversample); - for (k = 0; k < stepSize; k++) - gOutFIFO[k] = gOutputAccum[k]; + for (int k = 0; k < stepSize; k++) + gOutFIFO[k] = gOutputAccum[k]; - Array.Copy(gOutputAccum, stepSize, gOutputAccum, 0, fftFrameSize); - for (k = 0; k < inFifoLatency; k++) - gInFIFO[k] = gInFIFO[k + stepSize]; - } + Array.Copy(gOutputAccum, stepSize, gOutputAccum, 0, FftFrameSize); + Array.Clear(gOutputAccum, FftFrameSize, (int)stepSize); + + Array.Copy(gInFIFO, stepSize, gInFIFO, 0, inFifoLatency); } } - private void SmbFft(float[] fftBuffer, long fftFrameSize, long sign) + private void SmbFft(float[] fftBuffer, int sign) { - float wr, wi, arg, temp; - float tr, ti, ur, ui; - long i, bitm, j, le, le2, k; - - for (i = 2; i < (2 * fftFrameSize) - 2; i += 2) + for (int i = 2; i < (2 * FftFrameSize) - 2; i += 2) { - for (bitm = 2, j = 0; bitm < 2 * fftFrameSize; bitm <<= 1) + int j = 0; + for (int bitm = 2; bitm < 2 * FftFrameSize; bitm <<= 1) { if ((i & bitm) != 0) j++; - j <<= 1; } if (i < j) { - temp = fftBuffer[i]; - fftBuffer[i] = fftBuffer[j]; - fftBuffer[j] = temp; - temp = fftBuffer[i + 1]; - fftBuffer[i + 1] = fftBuffer[j + 1]; - fftBuffer[j + 1] = temp; + (fftBuffer[j], fftBuffer[i]) = (fftBuffer[i], fftBuffer[j]); + (fftBuffer[j + 1], fftBuffer[i + 1]) = (fftBuffer[i + 1], fftBuffer[j + 1]); } } - long max = (long)((Math.Log(fftFrameSize) / Math.Log(2.0)) + 0.5); - for (k = 0, le = 2; k < max; k++) + int stageIndex = 0; + for (int le = 4; le <= FftFrameSize * 2; le <<= 1, stageIndex++) { - le <<= 1; - le2 = le >> 1; - ur = 1.0f; - ui = 0.0f; - arg = (float)(Math.PI / (le2 >> 1)); - wr = (float)Math.Cos(arg); - wi = (float)(sign * Math.Sin(arg)); - for (j = 0; j < le2; j += 2) + int le2 = le >> 1; + float wr = twiddleCos[stageIndex]; + float wi = twiddleSin[stageIndex] * sign; + float ur = 1.0f, ui = 0.0f; + + for (int j = 0; j < le2; j += 2) { - for (i = j; i < 2 * fftFrameSize; i += le) + for (int i = j; i < 2 * FftFrameSize; i += le) { - tr = (fftBuffer[i + le2] * ur) - (fftBuffer[i + le2 + 1] * ui); - ti = (fftBuffer[i + le2] * ui) + (fftBuffer[i + le2 + 1] * ur); + float tr = (fftBuffer[i + le2] * ur) - (fftBuffer[i + le2 + 1] * ui); + float ti = (fftBuffer[i + le2] * ui) + (fftBuffer[i + le2 + 1] * ur); fftBuffer[i + le2] = fftBuffer[i] - tr; fftBuffer[i + le2 + 1] = fftBuffer[i + 1] - ti; fftBuffer[i] += tr; fftBuffer[i + 1] += ti; } - tr = (ur * wr) - (ui * wi); + float newUr = (ur * wr) - (ui * wi); ui = (ur * wi) + (ui * wr); - ur = tr; + ur = newUr; } } } From 3816886dd665f01e28413968c38b008412bca9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 30 Mar 2026 17:00:02 +0300 Subject: [PATCH 097/102] simplfy playervoicesource --- .../Audio/PcmSources/PlayerVoiceSource.cs | 38 +---- EXILED/Exiled.API/Features/Toys/Speaker.cs | 140 ++++++++++++++---- 2 files changed, 116 insertions(+), 62 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs index 52d18b042..487c5a55c 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs @@ -30,19 +30,15 @@ public sealed class PlayerVoiceSource : IPcmSource, ILiveSource private readonly ConcurrentQueue pcmQueue; private float[] decodeBuffer; - private DateTime lastPacketTime; /// /// Initializes a new instance of the class. /// /// The player whose voice will be captured. /// If true, prevents the player's original voice message's from being heard while broadcasting. - /// The broadcast delay in seconds. - public PlayerVoiceSource(Player player, bool blockOriginalVoice = false, float delay = 0f) + public PlayerVoiceSource(Player player, bool blockOriginalVoice = false) { sourcePlayer = player; - - Delay = delay; BlockOriginalVoice = blockOriginalVoice; decoder = new OpusDecoder(); @@ -55,22 +51,9 @@ public PlayerVoiceSource(Player player, bool blockOriginalVoice = false, float d Duration = double.PositiveInfinity, }; - FillDelayBuffer(); - lastPacketTime = DateTime.UtcNow; - LabApi.Events.Handlers.PlayerEvents.SendingVoiceMessage += OnVoiceChatting; } - /// - /// Gets or sets the broadcast delay in seconds. - /// - public float Delay { get; set; } - - /// - /// Gets or sets the threshold in seconds of silence required before the delay buffer is refilled. - /// - public double SilenceThreshold { get; set; } = 0.5; - /// /// Gets or sets a value indicating whether the player's original voice chat should be blocked while being broadcasted by this source. /// @@ -98,7 +81,7 @@ public double CurrentTime /// /// Gets a value indicating whether the end of the stream has been reached. /// - public bool Ended => sourcePlayer == null || !sourcePlayer.IsConnected; + public bool Ended => sourcePlayer?.GameObject == null; /// /// Reads PCM data from the stream into the specified buffer. @@ -148,18 +131,6 @@ public void Dispose() } } - private void FillDelayBuffer() - { - if (Delay <= 0) - return; - - int delaySamples = (int)(Delay * VoiceChatSettings.SampleRate); - for (int i = 0; i < delaySamples; i++) - { - pcmQueue.Enqueue(0f); - } - } - private void OnVoiceChatting(PlayerSendingVoiceMessageEventArgs ev) { if (ev.Player != sourcePlayer) @@ -171,11 +142,6 @@ private void OnVoiceChatting(PlayerSendingVoiceMessageEventArgs ev) if (BlockOriginalVoice) ev.IsAllowed = false; - if ((DateTime.UtcNow - lastPacketTime).TotalSeconds > SilenceThreshold) - FillDelayBuffer(); - - lastPacketTime = DateTime.UtcNow; - int decodedSamples = decoder.Decode(ev.Message.Data, ev.Message.DataLength, decodeBuffer); for (int i = 0; i < decodedSamples; i++) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index e30d7a498..8b6732654 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -8,7 +8,11 @@ namespace Exiled.API.Features.Toys { using System; + using System.Buffers; + using System.Collections.Concurrent; using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; using AdminToys; @@ -90,6 +94,9 @@ public class Speaker : AdminToy, IWrapper private CoroutineHandle playBackRoutine; private CoroutineHandle fadeRoutine; + private CancellationTokenSource cancelTokenSource; + private ConcurrentQueue<(byte[] Data, int Length, bool IsEndOfTrack)> packetQueue; + private double resampleTime; private int resampleBufferFilled; private int nextScheduledEventIndex = 0; @@ -634,7 +641,7 @@ public bool Play(string path, bool clearQueue = true, bool stream = false, bool /// The broadcast delay in seconds. /// If true, clears the upcoming tracks in the playlist before starting playback. /// true if the playback started successfully; otherwise, false. - public bool PlayLiveVoice(Player player, bool blockOriginalVoice = false, float delayInSeconds = 0f, bool clearQueue = true) + public bool PlayLiveVoice(Player player, bool blockOriginalVoice = false, bool clearQueue = true) { if (player == null) { @@ -645,7 +652,7 @@ public bool PlayLiveVoice(Player player, bool blockOriginalVoice = false, float PlayerVoiceSource source; try { - source = new PlayerVoiceSource(player, blockOriginalVoice, delayInSeconds); + source = new PlayerVoiceSource(player, blockOriginalVoice); } catch (Exception ex) { @@ -689,6 +696,22 @@ public void Stop(bool clearQueue = true) if (!isPlayBackInitialized) return; + if (cancelTokenSource != null) + { + cancelTokenSource.Cancel(); + cancelTokenSource.Dispose(); + cancelTokenSource = null; + } + + if (packetQueue != null) + { + while (packetQueue.TryDequeue(out (byte[] Data, int Length, bool IsEndOfTrack) oldFrame)) + { + if (oldFrame.Data != null) + ArrayPool.Shared.Return(oldFrame.Data); + } + } + if (playBackRoutine.IsRunning) { playBackRoutine.IsRunning = false; @@ -787,16 +810,12 @@ public bool QueueTrack(QueuedTrack track) /// public void SkipTrack() { + Stop(clearQueue: false); + if (TrySwitchToNextTrack()) { IsPaused = false; - - if (!playBackRoutine.IsRunning) - playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); - } - else - { - Stop(); + playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); } } @@ -993,6 +1012,7 @@ private void TryInitializePlayBack() isPlayBackInitialized = true; + packetQueue = new(); frame = new float[FrameSize]; resampleBuffer = Array.Empty(); encoder = new(OpusApplicationType.Audio); @@ -1152,12 +1172,62 @@ private void OnToyRemoved(AdminToyBase toy) OnPlaybackStopped = null; } + private void ProducerWorker(CancellationToken token) + { + try + { + while (!token.IsCancellationRequested) + { + if (packetQueue.Count > 15) + { + Thread.Sleep(5); + continue; + } + + if (CurrentSource == null) + break; + + if (isPitchDefault) + { + int read = CurrentSource.Read(frame, 0, FrameSize); + if (read < FrameSize) + Array.Clear(frame, read, FrameSize - read); + } + else + { + ResampleFrame(); + } + + Filter?.Process(frame); + + int len = encoder.Encode(frame, encoded); + + if (len > 2) + { + byte[] pooled = ArrayPool.Shared.Rent(len); + Buffer.BlockCopy(encoded, 0, pooled, 0, len); + packetQueue.Enqueue((pooled, len, CurrentSource.Ended)); + } + else + { + packetQueue.Enqueue((null, 0, CurrentSource.Ended)); + } + + if (CurrentSource.Ended) + break; + } + } + catch (Exception ex) + { + Log.Debug($"[Speaker] Async audio producer safely aborted: {ex.Message}"); + } + } + private IEnumerator PlayBackCoroutine() { if (needsSyncWait) { int framesPassed = Time.frameCount - idChangeFrame; - while (framesPassed < 2) { yield return Timing.WaitForOneFrame; @@ -1173,6 +1243,15 @@ private IEnumerator PlayBackCoroutine() resampleTime = 0.0; resampleBufferFilled = 0; + while (packetQueue.TryDequeue(out (byte[] Data, int Length, bool IsEndOfTrack) oldFrame)) + { + if (oldFrame.Data != null) + ArrayPool.Shared.Return(oldFrame.Data); + } + + cancelTokenSource = new CancellationTokenSource(); + Task.Run(() => ProducerWorker(cancelTokenSource.Token)); + float timeAccumulator = 0f; while (true) @@ -1183,25 +1262,19 @@ private IEnumerator PlayBackCoroutine() { timeAccumulator -= FrameTime; - if (isPitchDefault) + if (!packetQueue.TryDequeue(out (byte[] Data, int Length, bool IsEndOfTrack) encoded)) { - int read = CurrentSource.Read(frame, 0, FrameSize); - if (read < FrameSize) - Array.Clear(frame, read, FrameSize - read); + timeAccumulator = 0f; + break; } - else + + if (encoded.Length > 2 && encoded.Data != null) { - ResampleFrame(); + SendAudioMessage(new AudioMessage(ControllerId, encoded.Data, encoded.Length)); + ArrayPool.Shared.Return(encoded.Data); } - Filter?.Process(frame); - - int len = encoder.Encode(frame, encoded); - - if (len > 2) - SendAudioMessage(new AudioMessage(ControllerId, encoded, len)); - - if (!CurrentSource.Ended) + if (!encoded.IsEndOfTrack) continue; OnPlaybackFinished?.Invoke(); @@ -1217,7 +1290,17 @@ private IEnumerator PlayBackCoroutine() nextScheduledEventIndex = 0; ResetEncoder(); - CurrentSource.Reset(); + CurrentSource?.Reset(); + + while (packetQueue.TryDequeue(out (byte[] Data, int Length, bool IsEndOfTrack) oldFrame)) + { + if (oldFrame.Data != null) + ArrayPool.Shared.Return(oldFrame.Data); + } + + cancelTokenSource.Cancel(); + cancelTokenSource = new CancellationTokenSource(); + Task.Run(() => ProducerWorker(cancelTokenSource.Token)); OnPlaybackLooped?.Invoke(); SpeakerEvents.OnPlaybackLooped(this); @@ -1227,6 +1310,11 @@ private IEnumerator PlayBackCoroutine() if (TrySwitchToNextTrack()) { timeAccumulator = 0f; + + cancelTokenSource?.Cancel(); + cancelTokenSource = new CancellationTokenSource(); + Task.Run(() => ProducerWorker(cancelTokenSource.Token)); + continue; } @@ -1248,7 +1336,7 @@ private IEnumerator PlayBackCoroutine() } catch (Exception ex) { - Log.Error($"[Speaker] Failed to execute scheduled time event at {ScheduledEvents[nextScheduledEventIndex].Time:F2}s.\nException Details: {ex}"); + Log.Error($"[Speaker] Failed to execute scheduled time event: {ex}"); } nextScheduledEventIndex++; From b11500fcb058f07bd8f3d324e7a752be44274e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 30 Mar 2026 17:18:01 +0300 Subject: [PATCH 098/102] Update Speaker.cs --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 71 ++++++---------------- 1 file changed, 19 insertions(+), 52 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 8b6732654..f7d89406e 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -327,6 +327,19 @@ public float Pitch get; set { + if (field == value) + return; + + if (CurrentSource is ILiveSource) + { + field = 1.0f; + isPitchDefault = true; + resampleTime = 0.0; + resampleBufferFilled = 0; + Log.Warn("[Speaker] Pitch adjustment is not supported for live sources. Pitch has been reset to default (1.0)."); + return; + } + field = Mathf.Max(0.1f, Mathf.Abs(value)); isPitchDefault = Mathf.Abs(field - 1.0f) < 0.0001f; if (isPitchDefault) @@ -638,7 +651,6 @@ public bool Play(string path, bool clearQueue = true, bool stream = false, bool /// /// The player whose voice will be broadcasted. /// If true, prevents the player's original voice message's from being heard while broadcasting. - /// The broadcast delay in seconds. /// If true, clears the upcoming tracks in the playlist before starting playback. /// true if the playback started successfully; otherwise, false. public bool PlayLiveVoice(Player player, bool blockOriginalVoice = false, bool clearQueue = true) @@ -683,6 +695,9 @@ public bool Play(IPcmSource customSource, bool clearQueue = true) CurrentSource = customSource; LastTrackInfo = CurrentSource.TrackInfo; + if (CurrentSource is ILiveSource) + Pitch = 1.0f; + playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); return true; } @@ -1048,6 +1063,9 @@ private bool TrySwitchToNextTrack() CurrentSource?.Dispose(); CurrentSource = newSource; + if (CurrentSource is ILiveSource) + Pitch = 1.0f; + LastTrackInfo = CurrentSource.TrackInfo; ResetEncoder(); @@ -1172,57 +1190,6 @@ private void OnToyRemoved(AdminToyBase toy) OnPlaybackStopped = null; } - private void ProducerWorker(CancellationToken token) - { - try - { - while (!token.IsCancellationRequested) - { - if (packetQueue.Count > 15) - { - Thread.Sleep(5); - continue; - } - - if (CurrentSource == null) - break; - - if (isPitchDefault) - { - int read = CurrentSource.Read(frame, 0, FrameSize); - if (read < FrameSize) - Array.Clear(frame, read, FrameSize - read); - } - else - { - ResampleFrame(); - } - - Filter?.Process(frame); - - int len = encoder.Encode(frame, encoded); - - if (len > 2) - { - byte[] pooled = ArrayPool.Shared.Rent(len); - Buffer.BlockCopy(encoded, 0, pooled, 0, len); - packetQueue.Enqueue((pooled, len, CurrentSource.Ended)); - } - else - { - packetQueue.Enqueue((null, 0, CurrentSource.Ended)); - } - - if (CurrentSource.Ended) - break; - } - } - catch (Exception ex) - { - Log.Debug($"[Speaker] Async audio producer safely aborted: {ex.Message}"); - } - } - private IEnumerator PlayBackCoroutine() { if (needsSyncWait) From 777e652ce0c0fe09e78edfe2f4d83b2eacf3cc6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 30 Mar 2026 17:33:52 +0300 Subject: [PATCH 099/102] old way tasks maybe later --- EXILED/Exiled.API/Features/Toys/Speaker.cs | 147 ++++++--------------- 1 file changed, 41 insertions(+), 106 deletions(-) diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index f7d89406e..fcf51a1f0 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -8,11 +8,7 @@ namespace Exiled.API.Features.Toys { using System; - using System.Buffers; - using System.Collections.Concurrent; using System.Collections.Generic; - using System.Threading; - using System.Threading.Tasks; using AdminToys; @@ -94,9 +90,6 @@ public class Speaker : AdminToy, IWrapper private CoroutineHandle playBackRoutine; private CoroutineHandle fadeRoutine; - private CancellationTokenSource cancelTokenSource; - private ConcurrentQueue<(byte[] Data, int Length, bool IsEndOfTrack)> packetQueue; - private double resampleTime; private int resampleBufferFilled; private int nextScheduledEventIndex = 0; @@ -711,22 +704,6 @@ public void Stop(bool clearQueue = true) if (!isPlayBackInitialized) return; - if (cancelTokenSource != null) - { - cancelTokenSource.Cancel(); - cancelTokenSource.Dispose(); - cancelTokenSource = null; - } - - if (packetQueue != null) - { - while (packetQueue.TryDequeue(out (byte[] Data, int Length, bool IsEndOfTrack) oldFrame)) - { - if (oldFrame.Data != null) - ArrayPool.Shared.Return(oldFrame.Data); - } - } - if (playBackRoutine.IsRunning) { playBackRoutine.IsRunning = false; @@ -825,12 +802,30 @@ public bool QueueTrack(QueuedTrack track) /// public void SkipTrack() { + if (TrackQueue.Count == 0) + { + Stop(); + return; + } + Stop(clearQueue: false); - if (TrySwitchToNextTrack()) + QueuedTrack nextTrack = TrackQueue[0]; + TrackQueue.RemoveAt(0); + + try + { + IPcmSource newSource = nextTrack.SourceProvider.Invoke(); + + OnTrackSwitching?.Invoke(nextTrack); + SpeakerEvents.OnTrackSwitching(this, nextTrack); + + Play(newSource, clearQueue: false); + } + catch (Exception ex) { - IsPaused = false; - playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); + Log.Error($"[Speaker] Playlist next track failed: '{nextTrack}'.\n{ex}"); + SkipTrack(); } } @@ -1027,7 +1022,6 @@ private void TryInitializePlayBack() isPlayBackInitialized = true; - packetQueue = new(); frame = new float[FrameSize]; resampleBuffer = Array.Empty(); encoder = new(OpusApplicationType.Audio); @@ -1039,48 +1033,6 @@ private void TryInitializePlayBack() AdminToyBase.OnRemoved += OnToyRemoved; } - private bool TrySwitchToNextTrack() - { - while (TrackQueue.Count > 0) - { - QueuedTrack nextTrack = TrackQueue[0]; - OnTrackSwitching?.Invoke(nextTrack); - SpeakerEvents.OnTrackSwitching(this, nextTrack); - - TrackQueue.RemoveAt(0); - - IPcmSource newSource; - try - { - newSource = nextTrack.SourceProvider.Invoke(); - } - catch (Exception ex) - { - Log.Error($"[Speaker] Playlist next track failed: '{nextTrack}'.\n{ex}"); - continue; - } - - CurrentSource?.Dispose(); - CurrentSource = newSource; - - if (CurrentSource is ILiveSource) - Pitch = 1.0f; - - LastTrackInfo = CurrentSource.TrackInfo; - - ResetEncoder(); - ClearScheduledEvents(); - resampleTime = 0.0; - resampleBufferFilled = 0; - - OnPlaybackStarted?.Invoke(); - SpeakerEvents.OnPlaybackStarted(this); - return true; - } - - return false; - } - private void UpdateNextScheduledEventIndex() { nextScheduledEventIndex = 0; @@ -1210,15 +1162,6 @@ private IEnumerator PlayBackCoroutine() resampleTime = 0.0; resampleBufferFilled = 0; - while (packetQueue.TryDequeue(out (byte[] Data, int Length, bool IsEndOfTrack) oldFrame)) - { - if (oldFrame.Data != null) - ArrayPool.Shared.Return(oldFrame.Data); - } - - cancelTokenSource = new CancellationTokenSource(); - Task.Run(() => ProducerWorker(cancelTokenSource.Token)); - float timeAccumulator = 0f; while (true) @@ -1229,19 +1172,25 @@ private IEnumerator PlayBackCoroutine() { timeAccumulator -= FrameTime; - if (!packetQueue.TryDequeue(out (byte[] Data, int Length, bool IsEndOfTrack) encoded)) + if (isPitchDefault) { - timeAccumulator = 0f; - break; + int read = CurrentSource.Read(frame, 0, FrameSize); + if (read < FrameSize) + Array.Clear(frame, read, FrameSize - read); } - - if (encoded.Length > 2 && encoded.Data != null) + else { - SendAudioMessage(new AudioMessage(ControllerId, encoded.Data, encoded.Length)); - ArrayPool.Shared.Return(encoded.Data); + ResampleFrame(); } - if (!encoded.IsEndOfTrack) + Filter?.Process(frame); + + int len = encoder.Encode(frame, encoded); + + if (len > 2) + SendAudioMessage(new AudioMessage(ControllerId, encoded, len)); + + if (!CurrentSource.Ended) continue; OnPlaybackFinished?.Invoke(); @@ -1257,32 +1206,18 @@ private IEnumerator PlayBackCoroutine() nextScheduledEventIndex = 0; ResetEncoder(); - CurrentSource?.Reset(); - - while (packetQueue.TryDequeue(out (byte[] Data, int Length, bool IsEndOfTrack) oldFrame)) - { - if (oldFrame.Data != null) - ArrayPool.Shared.Return(oldFrame.Data); - } - - cancelTokenSource.Cancel(); - cancelTokenSource = new CancellationTokenSource(); - Task.Run(() => ProducerWorker(cancelTokenSource.Token)); + CurrentSource.Reset(); OnPlaybackLooped?.Invoke(); SpeakerEvents.OnPlaybackLooped(this); continue; } - if (TrySwitchToNextTrack()) + if (TrackQueue.Count > 0) { - timeAccumulator = 0f; - - cancelTokenSource?.Cancel(); - cancelTokenSource = new CancellationTokenSource(); - Task.Run(() => ProducerWorker(cancelTokenSource.Token)); - - continue; + playBackRoutine.IsRunning = false; + SkipTrack(); + yield break; } if (ReturnToPoolAfter) @@ -1303,7 +1238,7 @@ private IEnumerator PlayBackCoroutine() } catch (Exception ex) { - Log.Error($"[Speaker] Failed to execute scheduled time event: {ex}"); + Log.Error($"[Speaker] Failed to execute scheduled time event at {ScheduledEvents[nextScheduledEventIndex].Time:F2}s.\nException Details: {ex}"); } nextScheduledEventIndex++; From 40217002644a5a9850767641c5718e708795c359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Mon, 30 Mar 2026 17:53:07 +0300 Subject: [PATCH 100/102] filter reset + change useless Concurrent --- .../Features/Audio/Filters/EchoFilter.cs | 8 ++++++++ .../Features/Audio/Filters/PitchShiftFilter.cs | 18 ++++++++++++++++++ .../Audio/PcmSources/PlayerVoiceSource.cs | 6 +++--- EXILED/Exiled.API/Features/Toys/Speaker.cs | 4 ++++ .../Interfaces/Audio/IAudioFilter.cs | 5 +++++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs index 02b609831..53fa8124d 100644 --- a/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs +++ b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs @@ -149,6 +149,14 @@ public void Process(float[] frame) } } + /// + public void Reset() + { + Array.Clear(delayBuffer, 0, delayBuffer.Length); + writeIndex = 0; + x1 = x2 = y1 = y2 = 0f; + } + /// /// Calculates the Robert Bristow-Johnson (RBJ) Audio EQ parameters for the low-pass filter. /// diff --git a/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs index 34a9faa61..e2cf32f56 100644 --- a/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs +++ b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs @@ -101,6 +101,24 @@ public void Process(float[] frame) Array.Copy(outputBuffer, frame, frame.Length); } + /// + public void Reset() + { + Array.Clear(gInFIFO, 0, gInFIFO.Length); + Array.Clear(gOutFIFO, 0, gOutFIFO.Length); + Array.Clear(gFFTworksp, 0, gFFTworksp.Length); + Array.Clear(gLastPhase, 0, gLastPhase.Length); + Array.Clear(gSumPhase, 0, gSumPhase.Length); + Array.Clear(gOutputAccum, 0, gOutputAccum.Length); + Array.Clear(gAnaFreq, 0, gAnaFreq.Length); + Array.Clear(gAnaMagn, 0, gAnaMagn.Length); + Array.Clear(gSynFreq, 0, gSynFreq.Length); + Array.Clear(gSynMagn, 0, gSynMagn.Length); + Array.Clear(outputBuffer, 0, outputBuffer.Length); + + gRover = 0; + } + private static float[] BuildHannWindow() { float[] window = new float[FftFrameSize]; diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs index 487c5a55c..bbddb54a7 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs @@ -7,9 +7,9 @@ namespace Exiled.API.Features.Audio.PcmSources { - using System; using System.Buffers; using System.Collections.Concurrent; + using System.Collections.Generic; using Exiled.API.Features; using Exiled.API.Interfaces.Audio; @@ -27,7 +27,7 @@ public sealed class PlayerVoiceSource : IPcmSource, ILiveSource { private readonly Player sourcePlayer; private readonly OpusDecoder decoder; - private readonly ConcurrentQueue pcmQueue; + private readonly Queue pcmQueue; private float[] decodeBuffer; @@ -42,7 +42,7 @@ public PlayerVoiceSource(Player player, bool blockOriginalVoice = false) BlockOriginalVoice = blockOriginalVoice; decoder = new OpusDecoder(); - pcmQueue = new ConcurrentQueue(); + pcmQueue = new Queue(); decodeBuffer = ArrayPool.Shared.Rent(VoiceChatSettings.PacketSizePerChannel); TrackInfo = new TrackData diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index fcf51a1f0..ab21b7cc5 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -253,6 +253,7 @@ public double CurrentTime resampleBufferFilled = 0; ResetEncoder(); + Filter?.Reset(); UpdateNextScheduledEventIndex(); } } @@ -718,6 +719,8 @@ public void Stop(bool clearQueue = true) StopFade(); ResetEncoder(); ClearScheduledEvents(); + + Filter?.Reset(); CurrentSource?.Dispose(); CurrentSource = null; } @@ -1206,6 +1209,7 @@ private IEnumerator PlayBackCoroutine() nextScheduledEventIndex = 0; ResetEncoder(); + Filter?.Reset(); CurrentSource.Reset(); OnPlaybackLooped?.Invoke(); diff --git a/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs b/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs index 74e1528d3..3bd096a2a 100644 --- a/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs +++ b/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs @@ -17,5 +17,10 @@ public interface IAudioFilter /// /// The array of PCM audio samples. void Process(float[] frame); + + /// + /// Resets the internal state and buffers of the filter. + /// + void Reset(); } } From 7729ed9b31865207b5fb60f979ca130755fffc84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Tue, 31 Mar 2026 16:36:21 +0300 Subject: [PATCH 101/102] class to struct --- .../Features/Audio/PlaybackSettings.cs | 8 ++-- EXILED/Exiled.API/Features/Toys/Speaker.cs | 37 ++++++++++--------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs index 3f12526e8..6ca303970 100644 --- a/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs +++ b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs @@ -20,12 +20,14 @@ namespace Exiled.API.Features.Audio /// /// Represents all configurable audio and network settings for play from pool method. /// - public class PlaybackSettings + public struct PlaybackSettings { /// - /// Gets a global, read-only instance of with all default values. + /// Initializes a new instance of the struct. /// - public static readonly PlaybackSettings Default = new(); + public PlaybackSettings() + { + } /// /// Gets or sets the volume level. diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index ab21b7cc5..8a7d8aec6 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -5,6 +5,7 @@ // // ----------------------------------------------------------------------- +#pragma warning disable SA1129 // Do not use default value type constructor namespace Exiled.API.Features.Toys { using System; @@ -494,7 +495,7 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null) /// The parent transform, if any. /// The optional audio and network settings. If null, default settings are used. /// 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, PlaybackSettings settings = null) + public static bool PlayFromPool(string path, Vector3 position, Transform parent = null, in PlaybackSettings? settings = null) { if (string.IsNullOrEmpty(path)) { @@ -502,8 +503,8 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent return false; } - settings ??= PlaybackSettings.Default; - if (!settings.UseCache && !WavUtility.TryValidatePath(path, out string errorMessage)) + PlaybackSettings settingsFull = settings ?? new PlaybackSettings(); + if (!settingsFull.UseCache && !WavUtility.TryValidatePath(path, out string errorMessage)) { Log.Error($"[Speaker] {errorMessage}"); return false; @@ -512,7 +513,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent IPcmSource source; try { - source = WavUtility.CreatePcmSource(path, settings.Stream, settings.UseCache); + source = WavUtility.CreatePcmSource(path, settingsFull.Stream, settingsFull.UseCache); } catch (Exception ex) { @@ -520,7 +521,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent return false; } - return PlayFromPool(source, position, parent, settings); + return PlayFromPool(source, position, parent, settingsFull); } /// @@ -531,7 +532,7 @@ public static bool PlayFromPool(string path, Vector3 position, Transform parent /// The parent transform, if any. /// The optional audio and network settings. If null, default settings are used. /// true if the source is valid and playback started; otherwise, false. - public static bool PlayFromPool(IPcmSource source, Vector3 position, Transform parent = null, PlaybackSettings settings = null) + public static bool PlayFromPool(IPcmSource source, Vector3 position, Transform parent = null, in PlaybackSettings? settings = null) { if (source == null) { @@ -541,20 +542,20 @@ public static bool PlayFromPool(IPcmSource source, Vector3 position, Transform p Speaker speaker = Rent(parent, position); - settings ??= PlaybackSettings.Default; + PlaybackSettings settingsFull = settings ?? new PlaybackSettings(); - speaker.Volume = settings.Volume; - speaker.IsSpatial = settings.IsSpatial; - speaker.MinDistance = settings.MinDistance; - speaker.MaxDistance = settings.MaxDistance; + speaker.Volume = settingsFull.Volume; + speaker.IsSpatial = settingsFull.IsSpatial; + speaker.MinDistance = settingsFull.MinDistance; + speaker.MaxDistance = settingsFull.MaxDistance; - speaker.Pitch = settings.Pitch; - speaker.Channel = settings.Channel; - speaker.PlayMode = settings.PlayMode; - speaker.Predicate = settings.Predicate; - speaker.TargetPlayer = settings.TargetPlayer; - speaker.TargetPlayers = settings.TargetPlayers; - speaker.Filter = settings.Filter; + speaker.Pitch = settingsFull.Pitch; + speaker.Channel = settingsFull.Channel; + speaker.PlayMode = settingsFull.PlayMode; + speaker.Predicate = settingsFull.Predicate; + speaker.TargetPlayer = settingsFull.TargetPlayer; + speaker.TargetPlayers = settingsFull.TargetPlayers; + speaker.Filter = settingsFull.Filter; speaker.ReturnToPoolAfter = true; From 722ebaca5f9e202c209c35d5431a8209876c64d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?= Date: Tue, 31 Mar 2026 18:02:35 +0300 Subject: [PATCH 102/102] fix slient crash probablty, memory leak, neww sources --- .../Features/Audio/PcmSources/MixerSource.cs | 168 +++++++++++++++ .../PcmSources/PreloadWebWavPcmSource.cs | 41 +++- .../Audio/PcmSources/VoiceRssTtsSource.cs | 201 ++++++++++++++++++ EXILED/Exiled.API/Features/Toys/Speaker.cs | 84 ++++++++ 4 files changed, 487 insertions(+), 7 deletions(-) create mode 100644 EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs create mode 100644 EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs new file mode 100644 index 000000000..20a04ceb5 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs @@ -0,0 +1,168 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.PcmSources +{ + using System; + using System.Collections.Generic; + using System.Linq; + + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using UnityEngine; + + /// + /// Provides an that dynamically mixes multiple audio sources together in real-time. + /// + /// This allows playing overlapping sounds (e.g., background music + voice announcements) simultaneously + /// through a single speaker without needing multiple Voice Controller IDs. + /// + /// + public sealed class MixerSource : IPcmSource + { + private readonly List sources = new(); + private float[] tempBuffer; + + /// + /// Initializes a new instance of the class with the specified initial sources. + /// + /// An array of instances to mix. + public MixerSource(params IPcmSource[] initialSources) + { + if (initialSources != null) + sources.AddRange(initialSources.Where(s => s != null)); + + TrackInfo = new TrackData { Path = "Audio Mixer", Duration = 0 }; + } + + /// + /// Gets or sets a value indicating whether the mixer should stay alive and output silence even when all internal sources have finished playing. + /// + public bool KeepAlive { get; set; } = false; + + /// + /// Gets the metadata of the mixer track. + /// + public TrackData TrackInfo { get; } + + /// + /// Gets the maximum total duration of all active sources in the mixer, in seconds. + /// + public double TotalDuration => sources.Count > 0 ? sources.Max(x => x.TotalDuration) : 0.0; + + /// + /// Gets or sets the current playback position in seconds across all active sources. + /// + public double CurrentTime + { + get => sources.Count > 0 ? sources.Max(x => x.CurrentTime) : 0.0; + set => Seek(value); + } + + /// + /// Gets a value indicating whether all internal sources have ended and is set to false. + /// + public bool Ended => !KeepAlive && (sources.Count == 0 || sources.All(x => x.Ended)); + + /// + /// Reads a sequence of mixed PCM samples from all active sources into the specified buffer. + /// + /// The destination buffer to fill with mixed PCM data. + /// The zero-based index in at which to begin writing. + /// The maximum number of samples to read and mix. + /// The number of samples written to the . + public int Read(float[] buffer, int offset, int count) + { + if (tempBuffer == null || tempBuffer.Length < count) + tempBuffer = new float[count]; + + Array.Clear(buffer, offset, count); + int maxRead = 0; + + for (int i = sources.Count - 1; i >= 0; i--) + { + IPcmSource src = sources[i]; + + if (src.Ended) + { + src.Dispose(); + sources.RemoveAt(i); + continue; + } + + int read = src.Read(tempBuffer, 0, count); + if (read > maxRead) + maxRead = read; + + for (int j = 0; j < read; j++) + buffer[offset + j] += tempBuffer[j]; + } + + for (int i = 0; i < maxRead; i++) + buffer[offset + i] = Mathf.Clamp(buffer[offset + i], -1f, 1f); + + return KeepAlive ? count : maxRead; + } + + /// + /// Seeks to the specified position in seconds for all active sources in the mixer. + /// + /// The target position in seconds. + public void Seek(double seconds) + { + foreach (IPcmSource pcmSource in sources) + pcmSource.Seek(seconds); + } + + /// + /// Resets the playback position to the start for all active sources in the mixer. + /// + public void Reset() + { + foreach (IPcmSource pcmSource in sources) + pcmSource.Reset(); + } + + /// + /// Releases all resources used by the and automatically disposes of all internal sources. + /// + public void Dispose() + { + foreach (IPcmSource pcmSource in sources) + pcmSource?.Dispose(); + + sources.Clear(); + } + + /// + /// Dynamically adds a new to the mixer while it is playing. + /// + /// The audio source to add. + public void AddSource(IPcmSource source) + { + if (source != null) + sources.Add(source); + } + + /// + /// Dynamically removes an existing from the mixer. + /// + /// The audio source to remove. + /// If true, automatically calls Dispose on the removed source. + public void RemoveSource(IPcmSource source, bool dispose = true) + { + if (source == null) + return; + + if (dispose) + source.Dispose(); + + sources.Remove(source); + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs index 545593a1d..ee466fc30 100644 --- a/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs @@ -24,6 +24,8 @@ namespace Exiled.API.Features.Audio.PcmSources public sealed class PreloadWebWavPcmSource : IPcmSource { private IPcmSource internalSource; + private UnityWebRequest webRequest; + private CoroutineHandle downloadRoutine; private bool isReady = false; private bool isFailed = false; @@ -35,7 +37,7 @@ public sealed class PreloadWebWavPcmSource : IPcmSource public PreloadWebWavPcmSource(string url) { TrackInfo = default; - Timing.RunCoroutine(Download(url)); + downloadRoutine = Timing.RunCoroutine(Download(url)); } /// @@ -105,23 +107,43 @@ public void Reset() /// /// Releases all resources used by the . /// - public void Dispose() => internalSource?.Dispose(); + public void Dispose() + { + if (downloadRoutine.IsRunning) + downloadRoutine.IsRunning = false; + + webRequest?.Abort(); + webRequest?.Dispose(); + internalSource?.Dispose(); + } private IEnumerator Download(string url) { - using UnityWebRequest www = UnityWebRequest.Get(url); - yield return Timing.WaitUntilDone(www.SendWebRequest()); + webRequest = null; - if (www.result != UnityWebRequest.Result.Success) + try { - Log.Error($"[WebPreloadWavPcmSource] Failed to download audio! URL: {url} | Error: {www.error}"); + webRequest = UnityWebRequest.Get(url); + } + catch (Exception ex) + { + Log.Error($"[WebPreloadWavPcmSource] Failed to download audio! URL: {url} | Error: {ex.Message}"); isFailed = true; yield break; } + yield return Timing.WaitUntilDone(webRequest.SendWebRequest()); + try { - byte[] rawBytes = www.downloadHandler.data; + if (webRequest.result != UnityWebRequest.Result.Success) + { + Log.Error($"[WebPreloadWavPcmSource] Failed to download audio! URL: {url} | Error: {webRequest.error}"); + isFailed = true; + yield break; + } + + byte[] rawBytes = webRequest.downloadHandler.data; AudioData audioData = WavUtility.WavToPcm(rawBytes); audioData.TrackInfo.Path = url; @@ -134,6 +156,11 @@ private IEnumerator Download(string url) Log.Error($"[WebPreloadWavPcmSource] Failed to read the downloaded file! Ensure the link points to a valid .WAV file.\nException Details: {e.Message}"); isFailed = true; } + finally + { + webRequest?.Dispose(); + webRequest = null; + } } } } \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs new file mode 100644 index 000000000..57f4680c8 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs @@ -0,0 +1,201 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.PcmSources +{ + using System; + using System.Collections.Generic; + + using Exiled.API.Features; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using MEC; + + using UnityEngine.Networking; + + /// + /// Provides a that converts text to speech using the VoiceRSS Text-to-Speech API. + /// + public sealed class VoiceRssTtsSource : IPcmSource + { + private const string ApiEndpoint = "https://api.voicerss.org/"; + private const string AudioFormat = "48khz_16bit_mono"; + + private IPcmSource internalSource; + private UnityWebRequest webRequest; + private CoroutineHandle downloadRoutine; + + private bool isReady = false; + private bool isFailed = false; + + /// + /// Initializes a new instance of the class. + /// + /// The text to convert to speech.(Length limited by 100KB). + /// Your VoiceRSS API key. Get a free key at . + /// The language and locale code for the TTS voice. See for all supported language codes. + /// Optional specific voice name for the selected language.(See for available voices per language.) + /// Speech rate from -10 (slowest) to 10 (fastest). Defaults to 0 (normal speed). + public VoiceRssTtsSource(string text, string apiKey, string language = "en-us", string voice = null, int rate = 0) + { + if (string.IsNullOrEmpty(text)) + { + isFailed = true; + Log.Error("[VoiceRssTtsSource] Text cannot be null or empty."); + throw new ArgumentException("Text cannot be null or empty.", nameof(text)); + } + + if (string.IsNullOrEmpty(apiKey)) + { + isFailed = true; + Log.Error("[VoiceRssTtsSource] API key cannot be null or empty. Get a free key at https://www.voicerss.org/registration.aspx"); + throw new ArgumentException("API key cannot be null or empty. Get a free key at https://www.voicerss.org/registration.aspx", nameof(apiKey)); + } + + TrackInfo = new TrackData { Path = $"VoiceRssTts: {text}", Duration = 0.0 }; + downloadRoutine = Timing.RunCoroutine(DownloadRoutine(text, apiKey, language, voice, rate)); + } + + /// + /// Gets the metadata of the loaded track. + /// + public TrackData TrackInfo { get; private set; } + + /// + /// Gets the total duration of the audio in seconds. Returns 0 while the download is in progress. + /// + public double TotalDuration => isReady && internalSource != null ? internalSource.TotalDuration : 0.0; + + /// + /// Gets or sets the current playback position in seconds. + /// + public double CurrentTime + { + get => isReady && internalSource != null ? internalSource.CurrentTime : 0.0; + set => Seek(value); + } + + /// + /// Gets a value indicating whether playback has ended or the download has failed. + /// + public bool Ended => isFailed || (isReady && internalSource != null && internalSource.Ended); + + /// + /// Reads PCM data from the audio source into the specified buffer. + /// + /// The buffer to fill with PCM data. + /// The offset in the buffer at which to begin writing. + /// The maximum number of samples to read. + /// The number of samples read. + public int Read(float[] buffer, int offset, int count) + { + if (isFailed) + return 0; + + if (!isReady || internalSource == null) + { + Array.Clear(buffer, offset, count); + return count; + } + + return internalSource.Read(buffer, offset, count); + } + + /// + /// Seeks to the specified position in seconds. + /// + /// The target position in seconds. + public void Seek(double seconds) + { + if (isReady && internalSource != null) + internalSource.Seek(seconds); + } + + /// + /// Resets playback to the beginning. + /// + public void Reset() + { + if (isReady && internalSource != null) + internalSource.Reset(); + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + if (downloadRoutine.IsRunning) + downloadRoutine.IsRunning = false; + + webRequest?.Abort(); + webRequest?.Dispose(); + internalSource?.Dispose(); + } + + private IEnumerator DownloadRoutine(string text, string apiKey, string language, string voice, int rate) + { + webRequest = null; + string clampedRate = Math.Clamp(rate, -10, 10).ToString(); + string url = $"{ApiEndpoint}?key={Uri.EscapeDataString(apiKey)}&hl={Uri.EscapeDataString(language)}&c=WAV&f={AudioFormat}&r={clampedRate}&src={Uri.EscapeDataString(text)}"; + + if (!string.IsNullOrEmpty(voice)) + url += $"&v={Uri.EscapeDataString(voice)}"; + + try + { + webRequest = UnityWebRequest.Get(url); + } + catch (Exception ex) + { + Log.Error($"[VoiceRssTtsSource] Failed to start web request! URL: {url} | Error: {ex.Message}"); + isFailed = true; + yield break; + } + + yield return Timing.WaitUntilDone(webRequest.SendWebRequest()); + + try + { + if (webRequest.result != UnityWebRequest.Result.Success) + { + Log.Error($"[VoiceRssTtsSource] Download failed! Error: {webRequest.error}"); + isFailed = true; + yield break; + } + + string contentType = webRequest.GetResponseHeader("Content-Type") ?? string.Empty; + if (!contentType.Contains("audio") && !contentType.Contains("wav") && !contentType.Contains("octet-stream")) + { + string apiError = webRequest.downloadHandler.text; + Log.Error($"[VoiceRssTtsSource] API Error: {apiError}"); + isFailed = true; + yield break; + } + + byte[] rawBytes = webRequest.downloadHandler.data; + AudioData audioData = WavUtility.WavToPcm(rawBytes); + audioData.TrackInfo.Path = $"VoiceRSS: {text}"; + + internalSource = new PreloadedPcmSource(audioData.Pcm); + TrackInfo = audioData.TrackInfo; + isReady = true; + } + catch (Exception e) + { + Log.Error($"[VoiceRssTtsSource] Parsing Error! Ensure the API returns a valid PCM16 WAV.\nDetails: {e.Message}"); + isFailed = true; + } + finally + { + webRequest?.Dispose(); + webRequest = null; + } + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 8a7d8aec6..1b072e04f 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -670,6 +670,51 @@ public bool PlayLiveVoice(Player player, bool blockOriginalVoice = false, bool c return Play(source, clearQueue); } + /// + /// Creates a from the provided paths/URLs and starts playing it mixed. + /// + /// If true, clears the upcoming tracks in the playlist before starting playback. + /// An array of paths or URLs to the audio files to mix and play. + /// true if at least one valid source was found and playback started; otherwise, false. + public bool PlayMixed(bool clearQueue = true, params string[] paths) + { + if (paths == null || paths.Length == 0) + { + Log.Error("[Speaker] No paths provided for PlayMixed!"); + return false; + } + + if (clearQueue) + TrackQueue.Clear(); + + bool added = false; + + foreach (string path in paths) + { + if (!WavUtility.TryValidatePath(path, out string errorMessage)) + { + Log.Error($"[Speaker] Invalid path skipped for mixing: '{path}' | Reason: {errorMessage}"); + continue; + } + + try + { + IPcmSource newSource = WavUtility.CreatePcmSource(path, false, false); + if (newSource != null) + { + if (AddMixed(newSource)) + added = true; + } + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to mix path '{path}'. Exception: {ex.Message}"); + } + } + + return added; + } + /// /// Plays audio directly from a provided PCM source. /// @@ -697,6 +742,45 @@ public bool Play(IPcmSource customSource, bool clearQueue = true) return true; } + /// + /// Dynamically mixes a new audio source into the currently playing audio without interrupting it. + /// + /// The additional to mix with the current playback. + /// true if the source was successfully mixed or started; otherwise, false. + public bool AddMixed(IPcmSource extraSource) + { + if (extraSource == null) + { + Log.Error("[Speaker] Provided extra IPcmSource for mixing is null!"); + return false; + } + + if (!playBackRoutine.IsRunning || CurrentSource == null || CurrentSource.Ended) + return Play(extraSource, false); + + if (extraSource is ILiveSource) + Pitch = 1.0f; + + if (CurrentSource is MixerSource currentMixer) + { + currentMixer.AddSource(extraSource); + return true; + } + + try + { + IPcmSource oldSource = CurrentSource; + MixerSource newMixer = new(oldSource, extraSource); + CurrentSource = newMixer; + return true; + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to transition to MixerSource on the fly!\nException Details: {ex}"); + return false; + } + } + /// /// Stops playback. ///