From 6585b640c66b4b16f7d174e1c486f0dd5f692290 Mon Sep 17 00:00:00 2001
From: Mike <146554836+MikeSus1@users.noreply.github.com>
Date: Mon, 16 Feb 2026 15:15:04 +0100
Subject: [PATCH 01/33] refractor!: rename blindness effectype (#317)
* Fix EffectType
* fix: ref fix build
* Blinded -> Blurred
replace all blinded by blurred
* Docs
docs blindness effect
---
EXILED/Exiled.API/Enums/EffectType.cs | 6 +++---
.../Exiled.API/Extensions/EffectTypeExtension.cs | 4 ++--
EXILED/Exiled.API/Features/Items/FlashGrenade.cs | 14 +++++++-------
.../Features/Pickups/FlashGrenadePickup.cs | 10 +++++-----
.../Pickups/Projectiles/FlashbangProjectile.cs | 4 ++--
5 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/EXILED/Exiled.API/Enums/EffectType.cs b/EXILED/Exiled.API/Enums/EffectType.cs
index d2f0a1394b..dc79e7c481 100644
--- a/EXILED/Exiled.API/Enums/EffectType.cs
+++ b/EXILED/Exiled.API/Enums/EffectType.cs
@@ -44,9 +44,9 @@ public enum EffectType
Bleeding,
///
- /// Blurs the player's screen.
+ /// Make the player screen darker.
///
- Blinded,
+ Blindness,
///
/// Increases damage the player receives. Does not apply any standalone damage.
@@ -256,7 +256,7 @@ public enum EffectType
PitDeath,
///
- /// .
+ /// Blurs the player's screen.
///
Blurred,
diff --git a/EXILED/Exiled.API/Extensions/EffectTypeExtension.cs b/EXILED/Exiled.API/Extensions/EffectTypeExtension.cs
index 088438e085..69677e71c7 100644
--- a/EXILED/Exiled.API/Extensions/EffectTypeExtension.cs
+++ b/EXILED/Exiled.API/Extensions/EffectTypeExtension.cs
@@ -34,7 +34,7 @@ public static class EffectTypeExtension
{ EffectType.AmnesiaVision, typeof(AmnesiaVision) },
{ EffectType.Asphyxiated, typeof(Asphyxiated) },
{ EffectType.Bleeding, typeof(Bleeding) },
- { EffectType.Blinded, typeof(Blindness) },
+ { EffectType.Blindness, typeof(Blindness) },
{ EffectType.BodyshotReduction, typeof(BodyshotReduction) },
{ EffectType.Burned, typeof(Burned) },
{ EffectType.CardiacArrest, typeof(CardiacArrest) },
@@ -194,7 +194,7 @@ or EffectType.Corroding or EffectType.Decontaminating or EffectType.Hemorrhage o
/// Whether the effect is a negative effect.
///
public static bool IsNegative(this EffectType effect) => IsHarmful(effect) || effect is EffectType.AmnesiaItems
- or EffectType.AmnesiaVision or EffectType.Blinded or EffectType.Burned or EffectType.Concussed or EffectType.Deafened
+ or EffectType.AmnesiaVision or EffectType.Blindness or EffectType.Burned or EffectType.Concussed or EffectType.Deafened
or EffectType.Disabled or EffectType.Ensnared or EffectType.Exhausted or EffectType.Flashed or EffectType.SinkHole
or EffectType.Stained or EffectType.InsufficientLighting or EffectType.SoundtrackMute or EffectType.Scanned or EffectType.Slowness;
diff --git a/EXILED/Exiled.API/Features/Items/FlashGrenade.cs b/EXILED/Exiled.API/Features/Items/FlashGrenade.cs
index 867d11b3ad..88e9129fb0 100644
--- a/EXILED/Exiled.API/Features/Items/FlashGrenade.cs
+++ b/EXILED/Exiled.API/Features/Items/FlashGrenade.cs
@@ -59,12 +59,12 @@ public float MinimalDurationEffect
}
///
- /// Gets or sets the additional duration of the effect.
+ /// Gets or sets the additional duration of the effect.
///
- public float AdditionalBlindedEffect
+ public float AdditionalBlurredEffect
{
- get => Projectile.AdditionalBlindedEffect;
- set => Projectile.AdditionalBlindedEffect = value;
+ get => Projectile.AdditionalBlurredEffect;
+ set => Projectile.AdditionalBlurredEffect = value;
}
///
@@ -105,7 +105,7 @@ public FlashbangProjectile SpawnActive(Vector3 position, Player owner = null)
grenade.Base.gameObject.SetActive(true);
grenade.MinimalDurationEffect = MinimalDurationEffect;
- grenade.AdditionalBlindedEffect = AdditionalBlindedEffect;
+ grenade.AdditionalBlurredEffect = AdditionalBlurredEffect;
grenade.SurfaceDistanceIntensifier = SurfaceDistanceIntensifier;
grenade.FuseTime = FuseTime;
@@ -125,7 +125,7 @@ public FlashbangProjectile SpawnActive(Vector3 position, Player owner = null)
public override Item Clone() => new FlashGrenade()
{
MinimalDurationEffect = MinimalDurationEffect,
- AdditionalBlindedEffect = AdditionalBlindedEffect,
+ AdditionalBlurredEffect = AdditionalBlurredEffect,
SurfaceDistanceIntensifier = SurfaceDistanceIntensifier,
FuseTime = FuseTime,
Repickable = Repickable,
@@ -145,7 +145,7 @@ internal override void ReadPickupInfoBefore(Pickup pickup)
if (pickup is FlashGrenadePickup flashGrenadePickup)
{
MinimalDurationEffect = flashGrenadePickup.MinimalDurationEffect;
- AdditionalBlindedEffect = flashGrenadePickup.AdditionalBlindedEffect;
+ AdditionalBlurredEffect = flashGrenadePickup.AdditionalBlurredEffect;
SurfaceDistanceIntensifier = flashGrenadePickup.SurfaceDistanceIntensifier;
FuseTime = flashGrenadePickup.FuseTime;
}
diff --git a/EXILED/Exiled.API/Features/Pickups/FlashGrenadePickup.cs b/EXILED/Exiled.API/Features/Pickups/FlashGrenadePickup.cs
index 3e4c979b50..3cdb59898e 100644
--- a/EXILED/Exiled.API/Features/Pickups/FlashGrenadePickup.cs
+++ b/EXILED/Exiled.API/Features/Pickups/FlashGrenadePickup.cs
@@ -42,9 +42,9 @@ internal FlashGrenadePickup()
public float MinimalDurationEffect { get; set; }
///
- /// Gets or sets the additional duration of the effect.
+ /// Gets or sets the additional duration of the effect.
///
- public float AdditionalBlindedEffect { get; set; }
+ public float AdditionalBlurredEffect { get; set; }
///
/// Gets or sets the how mush the flash grenade going to be intensified when explode at .
@@ -58,7 +58,7 @@ internal override void ReadItemInfo(Item item)
if (item is FlashGrenade flashGrenadeitem)
{
MinimalDurationEffect = flashGrenadeitem.MinimalDurationEffect;
- AdditionalBlindedEffect = flashGrenadeitem.AdditionalBlindedEffect;
+ AdditionalBlurredEffect = flashGrenadeitem.AdditionalBlurredEffect;
SurfaceDistanceIntensifier = flashGrenadeitem.SurfaceDistanceIntensifier;
FuseTime = flashGrenadeitem.FuseTime;
}
@@ -70,7 +70,7 @@ internal override void WriteProjectileInfo(Projectile projectile)
if (projectile is FlashbangProjectile flashbangProjectile)
{
flashbangProjectile.MinimalDurationEffect = MinimalDurationEffect;
- flashbangProjectile.AdditionalBlindedEffect = AdditionalBlindedEffect;
+ flashbangProjectile.AdditionalBlurredEffect = AdditionalBlurredEffect;
flashbangProjectile.SurfaceDistanceIntensifier = SurfaceDistanceIntensifier;
flashbangProjectile.FuseTime = FuseTime;
}
@@ -83,7 +83,7 @@ protected override void InitializeProperties(ItemBase itemBase)
if (itemBase is ThrowableItem throwable && throwable.Projectile is FlashbangGrenade flashGrenade)
{
MinimalDurationEffect = flashGrenade._minimalEffectDuration;
- AdditionalBlindedEffect = flashGrenade._additionalBlurDuration;
+ AdditionalBlurredEffect = flashGrenade._additionalBlurDuration;
SurfaceDistanceIntensifier = flashGrenade._surfaceZoneDistanceIntensifier;
}
}
diff --git a/EXILED/Exiled.API/Features/Pickups/Projectiles/FlashbangProjectile.cs b/EXILED/Exiled.API/Features/Pickups/Projectiles/FlashbangProjectile.cs
index 8c289c2840..8d30856743 100644
--- a/EXILED/Exiled.API/Features/Pickups/Projectiles/FlashbangProjectile.cs
+++ b/EXILED/Exiled.API/Features/Pickups/Projectiles/FlashbangProjectile.cs
@@ -51,9 +51,9 @@ public float MinimalDurationEffect
}
///
- /// Gets or sets the additional duration of the effect.
+ /// Gets or sets the additional duration of the effect.
///
- public float AdditionalBlindedEffect
+ public float AdditionalBlurredEffect
{
get => Base._additionalBlurDuration;
set => Base._additionalBlurDuration = value;
From d728d1b5737005d10c5bc1e8d96f454ce40131af Mon Sep 17 00:00:00 2001
From: TiBarification
Date: Mon, 16 Feb 2026 16:15:23 +0200
Subject: [PATCH 02/33] refactor!: ahp granting (#415)
* Return AhpStat.AhpProcess instance after adding AHp
* return
---
EXILED/Exiled.API/Features/Player.cs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Player.cs b/EXILED/Exiled.API/Features/Player.cs
index d91e703c01..bfac75fbe0 100644
--- a/EXILED/Exiled.API/Features/Player.cs
+++ b/EXILED/Exiled.API/Features/Player.cs
@@ -3705,9 +3705,10 @@ public void ChangeEffectIntensity(string effectName, byte intensity, float durat
/// Percent of incoming damage absorbed by this stat.
/// The number of seconds to delay the start of the decay.
/// Whether the process is removed when the value hits 0.
- public void AddAhp(float amount, float limit = 75f, float decay = 1.2f, float efficacy = 0.7f, float sustain = 0f, bool persistant = false)
+ /// The instance..
+ public AhpStat.AhpProcess AddAhp(float amount, float limit = 75f, float decay = 1.2f, float efficacy = 0.7f, float sustain = 0f, bool persistant = false)
{
- ReferenceHub.playerStats.GetModule()
+ return ReferenceHub.playerStats.GetModule()
.ServerAddProcess(amount, limit, decay, efficacy, sustain, persistant);
}
From 65a30336d7370b3428b83e20b2518a2b1a1239d1 Mon Sep 17 00:00:00 2001
From: Yamato <66829532+louis1706@users.noreply.github.com>
Date: Mon, 16 Feb 2026 15:15:43 +0100
Subject: [PATCH 03/33] feat: Bump Lib.Harmony from 2.2.2 to 2.4.2 in /EXILED
(#755)
---
EXILED/EXILED.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/EXILED/EXILED.props b/EXILED/EXILED.props
index f5197ff43b..f2b9761486 100644
--- a/EXILED/EXILED.props
+++ b/EXILED/EXILED.props
@@ -19,7 +19,7 @@
false
- 2.2.2
+ 2.4.2
1.1.118
2.0.2
From f313ff933b5c53210309cdd3946d181587d6afff Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Mon, 16 Feb 2026 17:16:46 +0300
Subject: [PATCH 04/33] feat: Speaker Api (#711)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Speaker Toy Api
* .
* chore: trigger CI
* added missing doc
* Change default stream parameter to false in PlayWav
* Improve XML documentation for Channel property
Updated XML documentation for the Channel property to include a reference to Channels.
* Update WavStreamSource.cs
Update PreloadedPcmSource.cs
Update Speaker.cs
Create WavUtility.cs
Update WavStreamSource.cs
Update PreloadedPcmSource.cs
Change default stream parameter to false in PlayWav
Update PlayWav method to default stream to true
Change TargetPlayers from List to HashSet
* Update Speaker.cs
* Update Speaker.cs
* Update performance
* Update Speaker.cs
Update PreloadedPcmSource.cs
Added another constructor for public usages
Update PreloadedPcmSource.cs
* Implemented audio seeking, event system, and precision timing
- Introduced a full Event system to the Speaker class (Started, Finished, Stopped, Paused, Resumed).
- Implemented `Seek()` functionality and added `CurrentTime` / `TotalDuration` properties.
- Utilized `double` precision for all time related calculations to ensure accuracy.
- Updated `WavStreamSource` and `PreloadedPcmSource` to support the new seeking logic.
* Update Speaker.cs
* Update WavUtility.cs
* Update Speaker.cs
* Update Speaker.cs
* Update Speaker.cs
* Update Speaker.cs
* Added OnPlaybackLooped event
* Update Speaker.cs
Refactor: Optimize WavStreamSource with ArrayPool & dynamic buffering
Feat: Add Pitch (it can be reversed if you wish)
Update EXILED.props
* Update to c# lang version 14
* Update Speaker.cs
* Enum 4 byte to 1 byte
* -Replaced 'File.ReadAllBytes' with 'ArrayPool.Shared'
-Optimized WAV header skipping (string comparisons to direct uint32 hex checks)
* Refactor
* Initialize Channel property with ReliableOrdered2
* Refactor Speaker class properties
* Update Speaker.cs
* Update Speaker.cs
* ağhhh
* renamed method
* Added Target Player Play Mode
* .
* added lasttrack
* perf
* Gc halleder onları
* ö
* added lasttrack to finishedTrack
---
EXILED/EXILED.props | 2 +-
EXILED/Exiled.API/Enums/SpeakerPlayMode.cs | 35 ++
.../Features/Audio/PreloadedPcmSource.cs | 114 +++++
.../Features/Audio/WavStreamSource.cs | 144 ++++++
.../Exiled.API/Features/Audio/WavUtility.cs | 116 +++++
.../Features/Core/Generic/EnumClass.cs | 6 +-
.../Core/Generic/UnmanagedEnumClass.cs | 6 +-
EXILED/Exiled.API/Features/Toys/Speaker.cs | 431 +++++++++++++++++-
EXILED/Exiled.API/Interfaces/IPcmSource.cs | 52 +++
9 files changed, 894 insertions(+), 12 deletions(-)
create mode 100644 EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
create mode 100644 EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
create mode 100644 EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
create mode 100644 EXILED/Exiled.API/Features/Audio/WavUtility.cs
create mode 100644 EXILED/Exiled.API/Interfaces/IPcmSource.cs
diff --git a/EXILED/EXILED.props b/EXILED/EXILED.props
index f2b9761486..af916a48b3 100644
--- a/EXILED/EXILED.props
+++ b/EXILED/EXILED.props
@@ -7,7 +7,7 @@
net48
- 13.0
+ 14.0
x64
false
$(MSBuildThisFileDirectory)\bin\$(Configuration)\
diff --git a/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
new file mode 100644
index 0000000000..9bafa513e1
--- /dev/null
+++ b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
@@ -0,0 +1,35 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Enums
+{
+ ///
+ /// Specifies the available modes for playing audio through a speaker.
+ ///
+ public enum SpeakerPlayMode : byte
+ {
+ ///
+ /// Play audio globally to all players.
+ ///
+ Global = 0,
+
+ ///
+ /// Play audio to a specific player.
+ ///
+ Player = 1,
+
+ ///
+ /// Play audio to a specific list of players.
+ ///
+ PlayerList = 2,
+
+ ///
+ /// Play audio to players matching a predicate.
+ ///
+ Predicate = 3,
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
new file mode 100644
index 0000000000..7be9d09a30
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
@@ -0,0 +1,114 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio
+{
+ using System;
+
+ using Exiled.API.Interfaces;
+
+ using VoiceChat;
+
+ ///
+ /// Represents a preloaded PCM audio source.
+ ///
+ public sealed class PreloadedPcmSource : IPcmSource
+ {
+ ///
+ /// The PCM data buffer.
+ ///
+ private readonly float[] data;
+
+ ///
+ /// The current read position in the data buffer.
+ ///
+ private int pos;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The path to the audio file.
+ public PreloadedPcmSource(string path)
+ {
+ data = WavUtility.WavToPcm(path);
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The raw PCM float array.
+ public PreloadedPcmSource(float[] pcmData)
+ {
+ data = pcmData;
+ }
+
+ ///
+ /// 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);
+ }
+
+ ///
+ /// Reads a sequence of PCM samples from the preloaded buffer into the specified array.
+ ///
+ /// The destination array to copy the samples into.
+ /// The zero-based index in at which to begin storing the data.
+ /// The maximum number of samples to read.
+ /// The number of samples read into .
+ 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);
+
+ if (targetIndex < 0)
+ targetIndex = 0;
+
+ if (targetIndex > data.Length)
+ targetIndex = data.Length;
+
+ pos = (int)targetIndex;
+ }
+
+ ///
+ /// Resets the read position to the beginning of the PCM data buffer.
+ ///
+ public void Reset()
+ {
+ pos = 0;
+ }
+
+ ///
+ public void Dispose()
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
new file mode 100644
index 0000000000..d910093f1b
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
@@ -0,0 +1,144 @@
+// -----------------------------------------------------------------------
+//
+// 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.IO;
+ using System.Runtime.InteropServices;
+
+ using Exiled.API.Interfaces;
+
+ using VoiceChat;
+
+ ///
+ /// Provides a PCM audio source from a WAV file stream.
+ ///
+ public sealed class WavStreamSource : IPcmSource
+ {
+ private const float Divide = 1f / 32768f;
+
+ private readonly long endPosition;
+ private readonly long startPosition;
+ private readonly FileStream stream;
+
+ private byte[] internalBuffer;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The path to the audio file.
+ public WavStreamSource(string path)
+ {
+ stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.SequentialScan);
+ WavUtility.SkipHeader(stream);
+ startPosition = stream.Position;
+ endPosition = stream.Length;
+ internalBuffer = ArrayPool.Shared.Rent(VoiceChatSettings.PacketSizePerChannel * 2);
+ }
+
+ ///
+ /// Gets the total duration of the audio in seconds.
+ ///
+ public double TotalDuration => (endPosition - startPosition) / 2.0 / VoiceChatSettings.SampleRate;
+
+ ///
+ /// Gets or sets the current playback position in seconds.
+ ///
+ public double CurrentTime
+ {
+ get => (stream.Position - startPosition) / 2.0 / VoiceChatSettings.SampleRate;
+ set => Seek(value);
+ }
+
+ ///
+ /// Gets a value indicating whether the end of the stream has been reached.
+ ///
+ public bool Ended => stream.Position >= endPosition;
+
+ ///
+ /// 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)
+ {
+ int bytesNeeded = count * 2;
+
+ if (internalBuffer.Length < bytesNeeded)
+ {
+ ArrayPool.Shared.Return(internalBuffer);
+ internalBuffer = ArrayPool.Shared.Rent(bytesNeeded);
+ }
+
+ int bytesRead = stream.Read(internalBuffer, 0, bytesNeeded);
+
+ if (bytesRead == 0)
+ return 0;
+
+ if (bytesRead % 2 != 0)
+ bytesRead--;
+
+ 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++)
+ buffer[offset + i] = shortSpan[i] * Divide;
+
+ return samplesToWrite;
+ }
+
+ ///
+ /// Seeks to the specified position in the stream.
+ ///
+ /// 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;
+
+ if (newPos % 2 != 0)
+ newPos--;
+
+ stream.Position = newPos;
+ }
+
+ ///
+ /// Resets the stream position to the start.
+ ///
+ public void Reset()
+ {
+ stream.Position = startPosition;
+ }
+
+ ///
+ /// Releases all resources used by the .
+ ///
+ public void Dispose()
+ {
+ stream?.Dispose();
+ if (internalBuffer != null)
+ {
+ ArrayPool.Shared.Return(internalBuffer);
+ internalBuffer = null;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
new file mode 100644
index 0000000000..c1b1bc3f73
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
@@ -0,0 +1,116 @@
+// -----------------------------------------------------------------------
+//
+// 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.Buffers.Binary;
+ using System.IO;
+ using System.Runtime.InteropServices;
+
+ using VoiceChat;
+
+ ///
+ /// Provides utility methods for working with WAV audio files.
+ ///
+ public static class WavUtility
+ {
+ private const float Divide = 1f / 32768f;
+
+ ///
+ /// 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)
+ {
+ using FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
+ int length = (int)fs.Length;
+
+ byte[] rentedBuffer = ArrayPool.Shared.Rent(length);
+
+ try
+ {
+ int bytesRead = fs.Read(rentedBuffer, 0, length);
+
+ using MemoryStream ms = new(rentedBuffer, 0, bytesRead);
+
+ SkipHeader(ms);
+
+ int headerOffset = (int)ms.Position;
+ int dataLength = bytesRead - headerOffset;
+
+ Span audioDataSpan = rentedBuffer.AsSpan(headerOffset, dataLength);
+ Span samples = MemoryMarshal.Cast(audioDataSpan);
+
+ float[] pcm = new float[samples.Length];
+
+ for (int i = 0; i < samples.Length; i++)
+ pcm[i] = samples[i] * Divide;
+
+ return pcm;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(rentedBuffer);
+ }
+ }
+
+ ///
+ /// 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)
+ {
+ Span headerBuffer = stackalloc byte[12];
+ stream.Read(headerBuffer);
+
+ Span chunkHeader = stackalloc byte[8];
+ while (true)
+ {
+ int read = stream.Read(chunkHeader);
+ if (read < 8)
+ break;
+
+ uint chunkId = BinaryPrimitives.ReadUInt32LittleEndian(chunkHeader[..4]);
+ int chunkSize = BinaryPrimitives.ReadInt32LittleEndian(chunkHeader.Slice(4, 4));
+
+ // 'fmt ' chunk
+ if (chunkId == 0x20746D66)
+ {
+ Span fmtData = stackalloc byte[16];
+ 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));
+
+ 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.");
+
+ if (chunkSize > 16)
+ stream.Seek(chunkSize - 16, SeekOrigin.Current);
+ }
+
+ // 'data' chunk
+ else if (chunkId == 0x61746164)
+ {
+ return;
+ }
+ else
+ {
+ stream.Seek(chunkSize, SeekOrigin.Current);
+ }
+
+ if (stream.Position >= stream.Length)
+ throw new InvalidDataException("WAV file does not contain a 'data' chunk.");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Core/Generic/EnumClass.cs b/EXILED/Exiled.API/Features/Core/Generic/EnumClass.cs
index 5f883d0bcf..0f3077d874 100644
--- a/EXILED/Exiled.API/Features/Core/Generic/EnumClass.cs
+++ b/EXILED/Exiled.API/Features/Core/Generic/EnumClass.cs
@@ -67,10 +67,10 @@ public string Name
.GetFields(BindingFlags.Static | BindingFlags.GetField | BindingFlags.Public)
.Where(t => t.FieldType == typeof(TObject));
- foreach (FieldInfo field in fields)
+ foreach (FieldInfo @field in fields)
{
- TObject instance = (TObject)field.GetValue(null);
- instance.name = field.Name;
+ TObject instance = (TObject)@field.GetValue(null);
+ instance.name = @field.Name;
}
isDefined = true;
diff --git a/EXILED/Exiled.API/Features/Core/Generic/UnmanagedEnumClass.cs b/EXILED/Exiled.API/Features/Core/Generic/UnmanagedEnumClass.cs
index 7928f38783..ce82315efa 100644
--- a/EXILED/Exiled.API/Features/Core/Generic/UnmanagedEnumClass.cs
+++ b/EXILED/Exiled.API/Features/Core/Generic/UnmanagedEnumClass.cs
@@ -67,10 +67,10 @@ public string Name
.GetFields(BindingFlags.Static | BindingFlags.GetField | BindingFlags.Public)
.Where(t => t.FieldType == typeof(TObject));
- foreach (FieldInfo field in fields)
+ foreach (FieldInfo @field in fields)
{
- TObject instance = (TObject)field.GetValue(null);
- instance.name = field.Name;
+ TObject instance = (TObject)@field.GetValue(null);
+ instance.name = @field.Name;
}
isDefined = true;
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 8b0af6bd79..4478c1143b 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -7,20 +7,53 @@
namespace Exiled.API.Features.Toys
{
+ using System;
using System.Collections.Generic;
+ using System.IO;
using AdminToys;
+
using Enums;
- using Exiled.API.Interfaces;
+
+ using Exiled.API.Features.Audio;
+
+ using Interfaces;
+
+ using MEC;
+
+ using Mirror;
+
using UnityEngine;
+
+ using VoiceChat;
+ using VoiceChat.Codec;
+ using VoiceChat.Codec.Enums;
using VoiceChat.Networking;
- using VoiceChat.Playbacks;
+
+ using Object = UnityEngine.Object;
///
/// A wrapper class for .
///
public class Speaker : AdminToy, IWrapper
{
+ private const int FrameSize = VoiceChatSettings.PacketSizePerChannel;
+ private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate;
+
+ private float[] frame;
+ private byte[] encoded;
+ private float[] resampleBuffer;
+
+ private double resampleTime;
+ private int resampleBufferFilled;
+
+ private IPcmSource source;
+ private OpusEncoder encoder;
+ private CoroutineHandle playBackRoutine;
+
+ private bool isPitchDefault = true;
+ private bool isPlayBackInitialized = false;
+
///
/// Initializes a new instance of the class.
///
@@ -28,6 +61,37 @@ public class Speaker : AdminToy, IWrapper
internal Speaker(SpeakerToy speakerToy)
: base(speakerToy, AdminToyType.Speaker) => Base = speakerToy;
+ ///
+ /// Invoked when the audio playback starts.
+ ///
+ public event Action OnPlaybackStarted;
+
+ ///
+ /// Invoked when the audio playback is paused.
+ ///
+ public event Action OnPlaybackPaused;
+
+ ///
+ /// Invoked when the audio playback is resumed from a paused state.
+ ///
+ public event Action OnPlaybackResumed;
+
+ ///
+ /// Invoked when the audio playback loops back to the beginning.
+ ///
+ public event Action OnPlaybackLooped;
+
+ ///
+ /// Invoked when the audio track finishes playing.
+ /// If looping is enabled, this triggers every time the track finished.
+ ///
+ public event Action OnPlaybackFinished;
+
+ ///
+ /// Invoked when the audio playback stops completely (either manually or end of file).
+ ///
+ public event Action OnPlaybackStopped;
+
///
/// Gets the prefab.
///
@@ -38,6 +102,123 @@ internal Speaker(SpeakerToy speakerToy)
///
public SpeakerToy Base { get; }
+ ///
+ /// Gets or sets the network channel used for sending audio packets from this speaker .
+ ///
+ public int Channel { get; set; } = Channels.ReliableOrdered2;
+
+ ///
+ /// Gets or sets a value indicating whether the audio playback should loop when it reaches the end.
+ ///
+ public bool Loop { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the speaker should be destroyed after playback finishes.
+ ///
+ public bool DestroyAfter { get; set; }
+
+ ///
+ /// Gets or sets the play mode for this speaker, determining how audio is sent to players.
+ ///
+ public SpeakerPlayMode PlayMode { get; set; }
+
+ ///
+ /// Gets or sets the target player who will hear the audio played by this speaker when is set to .
+ ///
+ public Player TargetPlayer { get; set; }
+
+ ///
+ /// Gets or sets the list of target players who will hear the audio played by this speaker when is set to .
+ ///
+ public HashSet TargetPlayers { get; set; }
+
+ ///
+ /// Gets or sets the predicate used to determine which players will hear the audio when is set to .
+ /// The predicate should return true for players who should receive the audio.
+ ///
+ public Func Predicate { get; set; }
+
+ ///
+ /// Gets a value indicating whether gets is a sound playing on this speaker or not.
+ ///
+ public bool IsPlaying => playBackRoutine.IsRunning && !IsPaused;
+
+ ///
+ /// Gets or sets a value indicating whether the playback is paused.
+ ///
+ ///
+ /// A where true means the playback is paused; false means it is not paused.
+ ///
+ public bool IsPaused
+ {
+ get => playBackRoutine.IsAliveAndPaused;
+ set
+ {
+ if (!playBackRoutine.IsRunning)
+ return;
+
+ if (playBackRoutine.IsAliveAndPaused == value)
+ return;
+
+ playBackRoutine.IsAliveAndPaused = value;
+ if (value)
+ OnPlaybackPaused?.Invoke();
+ else
+ OnPlaybackResumed?.Invoke();
+ }
+ }
+
+ ///
+ /// Gets or sets the current playback time in seconds.
+ /// Returns 0 if not playing.
+ ///
+ public double CurrentTime
+ {
+ get => source?.CurrentTime ?? 0.0;
+ set
+ {
+ if (source != null)
+ {
+ source.CurrentTime = value;
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+ }
+ }
+ }
+
+ ///
+ /// Gets the total duration of the current track in seconds.
+ /// Returns 0 if not playing.
+ ///
+ public double TotalDuration => source?.TotalDuration ?? 0.0;
+
+ ///
+ /// Gets the path to the last audio file played on this speaker.
+ ///
+ public string LastTrack { get; private set; }
+
+ ///
+ /// Gets or sets the playback pitch.
+ ///
+ ///
+ /// A representing the pitch level of the audio source,
+ /// where 1.0 is normal pitch, less than 1.0 is lower pitch (slower), and greater than 1.0 is higher pitch (faster).
+ ///
+ public float Pitch
+ {
+ get;
+ set
+ {
+ field = Mathf.Max(0.1f, Mathf.Abs(value));
+ isPitchDefault = Mathf.Abs(field - 1.0f) < 0.0001f;
+ if (isPitchDefault)
+ {
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+ }
+ }
+ }
+
///
/// Gets or sets the volume of the audio source.
///
@@ -109,7 +290,7 @@ public byte ControllerId
/// The new .
public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scale, bool spawn)
{
- Speaker speaker = new(UnityEngine.Object.Instantiate(Prefab))
+ Speaker speaker = new(Object.Instantiate(Prefab))
{
Position = position ?? Vector3.zero,
Rotation = Quaternion.Euler(rotation ?? Vector3.zero),
@@ -138,7 +319,7 @@ public static Speaker Create(Transform transform, bool spawn, bool worldPosition
Scale = transform.localScale.normalized,
};
- if(spawn)
+ if (spawn)
speaker.Spawn();
return speaker;
@@ -162,5 +343,245 @@ public static void Play(AudioMessage message, IEnumerable targets = null
/// 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.)
+ ///
+ /// 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.
+ 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);
+
+ if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
+ throw new NotSupportedException($"The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file.");
+
+ TryInitializePlayBack();
+ Stop();
+
+ Loop = loop;
+ LastTrack = path;
+ DestroyAfter = destroyAfter;
+ source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path);
+ playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject));
+ }
+
+ ///
+ /// Stops playback.
+ ///
+ public void Stop()
+ {
+ if (playBackRoutine.IsRunning)
+ {
+ Timing.KillCoroutines(playBackRoutine);
+ OnPlaybackStopped?.Invoke();
+ }
+
+ source?.Dispose();
+ source = null;
+ }
+
+ private void TryInitializePlayBack()
+ {
+ if (isPlayBackInitialized)
+ return;
+
+ isPlayBackInitialized = true;
+
+ frame = new float[FrameSize];
+ resampleBuffer = Array.Empty();
+ encoder = new(OpusApplicationType.Audio);
+ encoded = new byte[VoiceChatSettings.MaxEncodedSize];
+
+ AdminToyBase.OnRemoved += OnToyRemoved;
+ }
+
+ private IEnumerator PlayBackCoroutine()
+ {
+ OnPlaybackStarted?.Invoke();
+
+ 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(LastTrack);
+
+ if (Loop)
+ {
+ source.Reset();
+ OnPlaybackLooped?.Invoke();
+ resampleTime = resampleBufferFilled = 0;
+ continue;
+ }
+
+ if (DestroyAfter)
+ Destroy();
+ else
+ Stop();
+
+ yield break;
+ }
+
+ yield return Timing.WaitForOneFrame;
+ }
+ }
+
+ private void ResampleFrame()
+ {
+ int requiredSize = (int)(FrameSize * Mathf.Abs(Pitch) * 2) + 10;
+
+ if (resampleBuffer.Length < requiredSize)
+ {
+ resampleBuffer = new float[requiredSize];
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+ }
+
+ int outputIdx = 0;
+
+ while (outputIdx < FrameSize)
+ {
+ if (resampleBufferFilled == 0)
+ {
+ int toRead = resampleBuffer.Length - 4;
+ int actualRead = source.Read(resampleBuffer, 0, toRead);
+
+ if (actualRead == 0)
+ {
+ while (outputIdx < FrameSize)
+ frame[outputIdx++] = 0f;
+ return;
+ }
+
+ resampleBufferFilled = actualRead;
+ resampleTime = 0.0;
+ }
+
+ int currentSample = (int)resampleTime;
+
+ if (currentSample >= resampleBufferFilled - 1)
+ {
+ if (resampleBufferFilled > 0)
+ {
+ resampleBuffer[0] = resampleBuffer[resampleBufferFilled - 1];
+
+ int toRead = resampleBuffer.Length - 5;
+ int actualRead = source.Read(resampleBuffer, 1, toRead);
+
+ if (actualRead == 0)
+ {
+ while (outputIdx < FrameSize)
+ frame[outputIdx++] = 0f;
+ return;
+ }
+
+ resampleBufferFilled = actualRead + 1;
+ resampleTime -= currentSample;
+ }
+ else
+ {
+ resampleBufferFilled = 0;
+ }
+
+ continue;
+ }
+
+ double frac = resampleTime - currentSample;
+ float sample1 = resampleBuffer[currentSample];
+ float sample2 = resampleBuffer[currentSample + 1];
+
+ frame[outputIdx++] = (float)(sample1 + ((sample2 - sample1) * frac));
+
+ resampleTime += Pitch;
+ }
+ }
+
+ 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)
+ return;
+
+ AdminToyBase.OnRemoved -= OnToyRemoved;
+
+ Stop();
+
+ encoder?.Dispose();
+ }
}
-}
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Interfaces/IPcmSource.cs b/EXILED/Exiled.API/Interfaces/IPcmSource.cs
new file mode 100644
index 0000000000..680f568410
--- /dev/null
+++ b/EXILED/Exiled.API/Interfaces/IPcmSource.cs
@@ -0,0 +1,52 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Interfaces
+{
+ using System;
+
+ ///
+ /// Represents a source of PCM audio data.
+ ///
+ public interface IPcmSource : IDisposable
+ {
+ ///
+ /// Gets a value indicating whether the end of the PCM source has been reached.
+ ///
+ bool Ended { get; }
+
+ ///
+ /// Gets the total duration of the audio in seconds.
+ ///
+ double TotalDuration { get; }
+
+ ///
+ /// Gets or sets the current playback position in seconds.
+ ///
+ double CurrentTime { get; set; }
+
+ ///
+ /// Reads a sequence of PCM samples into the specified buffer.
+ ///
+ /// The buffer to read the samples into.
+ /// The zero-based index in the buffer at which to begin storing the data read from the source.
+ /// The maximum number of samples to read.
+ /// The total number of samples read into the buffer.
+ int Read(float[] buffer, int offset, int count);
+
+ ///
+ /// Seeks to the specified position in the PCM source.
+ ///
+ /// The position in seconds to seek to.
+ void Seek(double seconds);
+
+ ///
+ /// Resets the PCM source to its initial state, allowing reading from the beginning.
+ ///
+ void Reset();
+ }
+}
From 1c829c94432723f4c4075bd72efe5249478fb8e3 Mon Sep 17 00:00:00 2001
From: Yamato <66829532+louis1706@users.noreply.github.com>
Date: Mon, 16 Feb 2026 15:28:38 +0100
Subject: [PATCH 05/33] fix: Blinded -> Blindness
---
EXILED/Exiled.CustomItems/API/Features/CustomGoggles.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomGoggles.cs b/EXILED/Exiled.CustomItems/API/Features/CustomGoggles.cs
index 769f5ad936..ca4fbdd32d 100644
--- a/EXILED/Exiled.CustomItems/API/Features/CustomGoggles.cs
+++ b/EXILED/Exiled.CustomItems/API/Features/CustomGoggles.cs
@@ -180,7 +180,7 @@ private void InternalRemove(Player player, Scp1344 goggles)
if (CanBeRemoveSafely)
{
- player.DisableEffect(EffectType.Blinded);
+ player.DisableEffect(EffectType.Blindness);
player.ReferenceHub?.DisableWearables(WearableElements.Scp1344Goggles);
}
From 59645fcec0f7fbe99cb8e190d6e9c88cddb6d10f Mon Sep 17 00:00:00 2001
From: michcio <89903081+michcio15@users.noreply.github.com>
Date: Thu, 19 Feb 2026 12:01:50 +0100
Subject: [PATCH 06/33] feat: add setter `Usable::IsUsing` (#753)
* Adds a event that is called after player stops talking with 1576
* Simplify initialization of TransmissionEnded event.
* should be no more warnings when build
* Apply suggestions from code review
Co-authored-by: @Someone <45270312+Someone-193@users.noreply.github.com>
* added the suggestions to make it more exiled like
* Moved to Player Handler
* Update Scp1576TransmissionEnded.cs
Change comments
* Add `StartUsing` method to force item usage
* adds very professional comment
* Adds stop using
* Refactor `IsUsing` property to include setter and remove redundant usage methods
---------
Co-authored-by: @Someone <45270312+Someone-193@users.noreply.github.com>
Co-authored-by: Yamato <66829532+louis1706@users.noreply.github.com>
---
EXILED/Exiled.API/Features/Items/Usable.cs | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Items/Usable.cs b/EXILED/Exiled.API/Features/Items/Usable.cs
index b58f6b8dd9..efdff0c2ff 100644
--- a/EXILED/Exiled.API/Features/Items/Usable.cs
+++ b/EXILED/Exiled.API/Features/Items/Usable.cs
@@ -67,9 +67,13 @@ internal Usable(ItemType type)
}
///
- /// Gets a value indicating whether the item is currently being used.
+ /// Gets or sets a value indicating whether the item is currently being used.
///
- public bool IsUsing => Base.IsUsing;
+ public bool IsUsing
+ {
+ get => Base.IsUsing;
+ set => UsableItemsController.ServerEmulateMessage(Serial, value ? StatusMessage.StatusType.Start : StatusMessage.StatusType.Cancel);
+ }
///
/// Gets or sets how long it takes to use the item.
From 0ef3c68c8b084d748a11c8f03d77cbcb3ee22069 Mon Sep 17 00:00:00 2001
From: michcio <89903081+michcio15@users.noreply.github.com>
Date: Thu, 19 Feb 2026 12:02:25 +0100
Subject: [PATCH 07/33] feat: buttons array (#752)
* Adds a event that is called after player stops talking with 1576
* Simplify initialization of TransmissionEnded event.
* should be no more warnings when build
* Apply suggestions from code review
Co-authored-by: @Someone <45270312+Someone-193@users.noreply.github.com>
* added the suggestions to make it more exiled like
* Moved to Player Handler
* Update Scp1576TransmissionEnded.cs
Change comments
* Expose Buttons property in Door class to access door button variants
---------
Co-authored-by: @Someone <45270312+Someone-193@users.noreply.github.com>
Co-authored-by: Yamato <66829532+louis1706@users.noreply.github.com>
---
EXILED/Exiled.API/Features/Doors/Door.cs | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/EXILED/Exiled.API/Features/Doors/Door.cs b/EXILED/Exiled.API/Features/Doors/Door.cs
index a8163f207c..81dcb427f4 100644
--- a/EXILED/Exiled.API/Features/Doors/Door.cs
+++ b/EXILED/Exiled.API/Features/Doors/Door.cs
@@ -16,6 +16,7 @@ namespace Exiled.API.Features.Doors
using Exiled.API.Features.Core;
using Exiled.API.Interfaces;
using Interactables.Interobjects;
+ using Interactables.Interobjects.DoorButtons;
using Interactables.Interobjects.DoorUtils;
using MEC;
using Mirror;
@@ -298,6 +299,11 @@ public Vector3 Scale
///
public ZoneType Zone => Room?.Zone ?? ZoneType.Unspecified;
+ ///
+ /// Gets the door's .
+ ///
+ public ButtonVariant[] Buttons => Base.Buttons;
+
///
/// Gets a containing all 's that are connected with .
///
From be6d92d0bc356da0a9334fe93ab7cb4ffbf2a3b2 Mon Sep 17 00:00:00 2001
From: Yamato <66829532+louis1706@users.noreply.github.com>
Date: Fri, 20 Feb 2026 21:56:36 +0100
Subject: [PATCH 08/33] feat!: More more c#14 (#756)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Speaker Toy Api
* .
* chore: trigger CI
* added missing doc
* Change default stream parameter to false in PlayWav
* Improve XML documentation for Channel property
Updated XML documentation for the Channel property to include a reference to Channels.
* Update WavStreamSource.cs
Update PreloadedPcmSource.cs
Update Speaker.cs
Create WavUtility.cs
Update WavStreamSource.cs
Update PreloadedPcmSource.cs
Change default stream parameter to false in PlayWav
Update PlayWav method to default stream to true
Change TargetPlayers from List to HashSet
* Update Speaker.cs
* Update Speaker.cs
* Update performance
* Update Speaker.cs
Update PreloadedPcmSource.cs
Added another constructor for public usages
Update PreloadedPcmSource.cs
* Implemented audio seeking, event system, and precision timing
- Introduced a full Event system to the Speaker class (Started, Finished, Stopped, Paused, Resumed).
- Implemented `Seek()` functionality and added `CurrentTime` / `TotalDuration` properties.
- Utilized `double` precision for all time related calculations to ensure accuracy.
- Updated `WavStreamSource` and `PreloadedPcmSource` to support the new seeking logic.
* Update Speaker.cs
* Update WavUtility.cs
* Update Speaker.cs
* Update Speaker.cs
* Update Speaker.cs
* Update Speaker.cs
* Added OnPlaybackLooped event
* Update Speaker.cs
Refactor: Optimize WavStreamSource with ArrayPool & dynamic buffering
Feat: Add Pitch (it can be reversed if you wish)
Update EXILED.props
* Update to c# lang version 14
* Update Speaker.cs
* Enum 4 byte to 1 byte
* -Replaced 'File.ReadAllBytes' with 'ArrayPool.Shared'
-Optimized WAV header skipping (string comparisons to direct uint32 hex checks)
* Refactor
* Initialize Channel property with ReliableOrdered2
* Refactor Speaker class properties
* Update Speaker.cs
* Update Speaker.cs
* ağhhh
* renamed method
* Added Target Player Play Mode
* C# 14
BreakingChange for LeftPlayerEvent
* i miss that part :3
* Oups
* fix: EffectType.Blinded -> EffectType.Blindness
* Apply suggestions from code review
* Revert "Apply suggestions from code review"
This reverts commit 449bc275bdaa9c0b8333314547bfbe868762e802.
---------
Co-authored-by: MS-crew <100300664+MS-crew@users.noreply.github.com>
Co-authored-by: Mustafa SAVAŞ
---
.../Exiled.API/Extensions/MirrorExtensions.cs | 9 ++----
EXILED/Exiled.API/Features/Camera.cs | 4 +--
EXILED/Exiled.API/Features/Core/EActor.cs | 18 +++++------
.../Core/StateMachine/StateController.cs | 11 ++++---
.../DamageHandlers/DamageHandlerBase.cs | 17 +++++------
EXILED/Exiled.API/Features/Generator.cs | 3 +-
.../Features/Hazards/AmnesticCloudHazard.cs | 8 ++---
.../Features/Hazards/TantrumHazard.cs | 8 ++---
EXILED/Exiled.API/Features/Map.cs | 8 ++---
.../Features/Pickups/BodyArmorPickup.cs | 23 ++++----------
EXILED/Exiled.API/Features/Player.cs | 30 ++++++++-----------
EXILED/Exiled.API/Features/Respawn.cs | 15 ++++------
EXILED/Exiled.API/Features/Roles/FpcRole.cs | 6 ++--
EXILED/Exiled.API/Features/Server.cs | 4 +--
EXILED/Exiled.API/Features/Warhead.cs | 4 +--
.../AggregateExpectationTypeResolver.cs | 2 +-
.../Cassie/SendingCassieMessageEventArgs.cs | 19 +++++-------
.../Map/ExplodingGrenadeEventArgs.cs | 6 ++--
.../EventArgs/Player/BanningEventArgs.cs | 10 +++----
.../EventArgs/Player/ChangingItemEventArgs.cs | 8 ++---
.../EventArgs/Player/ChangingRoleEventArgs.cs | 6 ++--
.../EventArgs/Player/DroppingAmmoEventArgs.cs | 14 ++-------
.../EventArgs/Player/DroppingItemEventArgs.cs | 15 +++-------
.../EventArgs/Player/EscapingEventArgs.cs | 6 ++--
.../InteractingShootingTargetEventArgs.cs | 15 ++++------
.../EventArgs/Player/KickingEventArgs.cs | 29 ++++++++----------
.../EventArgs/Player/LeftEventArgs.cs | 13 ++++----
.../Player/ReservedSlotsCheckEventArgs.cs | 7 ++---
.../EventArgs/Player/RevokingMuteEventArgs.cs | 22 ++++++++++++--
.../Scp914/ChangingKnobSettingEventArgs.cs | 6 ++--
.../Server/RespawningTeamEventArgs.cs | 8 ++---
.../EventArgs/Warhead/StartingEventArgs.cs | 16 ++++++++--
EXILED/Exiled.Events/Events.cs | 6 ++--
.../Patches/Generic/AirlockListAdd.cs | 2 +-
.../Patches/Generic/CoffeeListAdd.cs | 2 +-
.../Patches/Generic/HazardList.cs | 2 +-
.../Exiled.Events/Patches/Generic/LiftList.cs | 2 +-
37 files changed, 166 insertions(+), 218 deletions(-)
diff --git a/EXILED/Exiled.API/Extensions/MirrorExtensions.cs b/EXILED/Exiled.API/Extensions/MirrorExtensions.cs
index 9fb9e47409..20f5b1a376 100644
--- a/EXILED/Exiled.API/Extensions/MirrorExtensions.cs
+++ b/EXILED/Exiled.API/Extensions/MirrorExtensions.cs
@@ -56,9 +56,6 @@ public static class MirrorExtensions
private static readonly ReadOnlyDictionary ReadOnlyWriterExtensionsValue = new(WriterExtensionsValue);
private static readonly ReadOnlyDictionary ReadOnlySyncVarDirtyBitsValue = new(SyncVarDirtyBitsValue);
private static readonly ReadOnlyDictionary ReadOnlyRpcFullNamesValue = new(RpcFullNamesValue);
- private static MethodInfo setDirtyBitsMethodInfoValue;
- private static MethodInfo sendSpawnMessageMethodInfoValue;
- private static string[] adminToyBaseSyncVarsValue;
///
/// Gets corresponding to .
@@ -153,17 +150,17 @@ public static ReadOnlyDictionary RpcFullNames
///
/// Gets a 's .
///
- public static MethodInfo SetDirtyBitsMethodInfo => setDirtyBitsMethodInfoValue ??= typeof(NetworkBehaviour).GetMethod(nameof(NetworkBehaviour.SetSyncVarDirtyBit));
+ public static MethodInfo SetDirtyBitsMethodInfo => field ??= typeof(NetworkBehaviour).GetMethod(nameof(NetworkBehaviour.SetSyncVarDirtyBit));
///
/// Gets a NetworkServer.SendSpawnMessage's .
///
- public static MethodInfo SendSpawnMessageMethodInfo => sendSpawnMessageMethodInfoValue ??= typeof(NetworkServer).GetMethod("SendSpawnMessage", BindingFlags.NonPublic | BindingFlags.Static);
+ public static MethodInfo SendSpawnMessageMethodInfo => field ??= typeof(NetworkServer).GetMethod("SendSpawnMessage", BindingFlags.NonPublic | BindingFlags.Static);
///
/// Gets all sync var names.
///
- public static string[] AdminToyBaseSyncVars => adminToyBaseSyncVarsValue ??= typeof(AdminToyBase).GetProperties().Where(property => property.Name.Contains("Network")).Select(property => property.Name).ToArray();
+ public static string[] AdminToyBaseSyncVars => field ??= typeof(AdminToyBase).GetProperties().Where(property => property.Name.Contains("Network")).Select(property => property.Name).ToArray();
///
/// Plays a beep sound that only the target can hear.
diff --git a/EXILED/Exiled.API/Features/Camera.cs b/EXILED/Exiled.API/Features/Camera.cs
index fa17a77540..4eedc693dd 100644
--- a/EXILED/Exiled.API/Features/Camera.cs
+++ b/EXILED/Exiled.API/Features/Camera.cs
@@ -157,8 +157,6 @@ public class Camera : IWrapper, IWorldSpace
["SZ CAMERA TOY"] = CameraType.SzCameraToy,
};
- private Room room;
-
///
/// Initializes a new instance of the class.
///
@@ -220,7 +218,7 @@ internal Camera(Scp079Camera camera079)
///
/// Gets the camera's .
///
- public Room Room => room ??= Room.Get(Base.Room);
+ public Room Room => field ??= Room.Get(Base.Room);
///
/// Gets the camera's .
diff --git a/EXILED/Exiled.API/Features/Core/EActor.cs b/EXILED/Exiled.API/Features/Core/EActor.cs
index f7f6beca77..b051974788 100644
--- a/EXILED/Exiled.API/Features/Core/EActor.cs
+++ b/EXILED/Exiled.API/Features/Core/EActor.cs
@@ -31,8 +31,6 @@ public abstract class EActor : EObject, IEntity, IWorldSpace
private readonly HashSet componentsInChildren = HashSetPool.Pool.Get();
private CoroutineHandle serverTick;
- private bool canEverTick;
- private float fixedTickRate;
///
/// Initializes a new instance of the class.
@@ -42,10 +40,10 @@ protected EActor()
{
IsEditable = true;
CanEverTick = true;
- fixedTickRate = DefaultFixedTickRate;
+ FixedTickRate = DefaultFixedTickRate;
PostInitialize();
- Timing.CallDelayed(fixedTickRate, () => OnBeginPlay());
- Timing.CallDelayed(fixedTickRate * 2, () => serverTick = Timing.RunCoroutine(ServerTick()));
+ Timing.CallDelayed(FixedTickRate, OnBeginPlay);
+ Timing.CallDelayed(FixedTickRate * 2, () => serverTick = Timing.RunCoroutine(ServerTick()));
}
///
@@ -99,15 +97,15 @@ public virtual Vector3 Scale
///
public virtual bool CanEverTick
{
- get => canEverTick;
+ get;
set
{
if (!IsEditable)
return;
- canEverTick = value;
+ field = value;
- if (canEverTick)
+ if (field)
{
Timing.ResumeCoroutines(serverTick);
return;
@@ -122,13 +120,13 @@ public virtual bool CanEverTick
///
public virtual float FixedTickRate
{
- get => fixedTickRate;
+ get;
set
{
if (!IsEditable)
return;
- fixedTickRate = value;
+ field = value;
}
}
diff --git a/EXILED/Exiled.API/Features/Core/StateMachine/StateController.cs b/EXILED/Exiled.API/Features/Core/StateMachine/StateController.cs
index bce10ed688..ce193ec7a5 100644
--- a/EXILED/Exiled.API/Features/Core/StateMachine/StateController.cs
+++ b/EXILED/Exiled.API/Features/Core/StateMachine/StateController.cs
@@ -19,7 +19,6 @@ namespace Exiled.API.Features.Core.StateMachine
public abstract class StateController : EActor
{
private readonly List states = new();
- private State currentState;
///
/// Gets all handled states.
@@ -31,14 +30,14 @@ public abstract class StateController : EActor
///
public State CurrentState
{
- get => currentState;
+ get;
set
{
- if (currentState.Id == value.Id)
+ if (field.Id == value.Id)
return;
- (PreviousState = currentState).OnExit(this);
- (currentState = value).OnEnter(this);
+ (PreviousState = field).OnExit(this);
+ (field = value).OnEnter(this);
OnStateChanged();
}
@@ -75,7 +74,7 @@ public virtual void StateUpdate(State state)
protected virtual void OnStateChanged()
{
EndStateMulticastDispatcher.InvokeAll(PreviousState);
- BeginStateMulticastDispatcher.InvokeAll(currentState);
+ BeginStateMulticastDispatcher.InvokeAll(CurrentState);
}
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/DamageHandlers/DamageHandlerBase.cs b/EXILED/Exiled.API/Features/DamageHandlers/DamageHandlerBase.cs
index 248e89452b..6a9b8f4562 100644
--- a/EXILED/Exiled.API/Features/DamageHandlers/DamageHandlerBase.cs
+++ b/EXILED/Exiled.API/Features/DamageHandlers/DamageHandlerBase.cs
@@ -28,9 +28,6 @@ namespace Exiled.API.Features.DamageHandlers
///
public abstract class DamageHandlerBase
{
- private DamageType damageType;
- private CassieAnnouncement cassieAnnouncement;
-
///
/// Initializes a new instance of the class.
///
@@ -78,8 +75,8 @@ public enum Action : byte
///
public virtual CassieAnnouncement CassieDeathAnnouncement
{
- get => cassieAnnouncement ?? Base.CassieDeathAnnouncement;
- protected set => cassieAnnouncement = value;
+ get => field ?? Base.CassieDeathAnnouncement;
+ protected set;
}
///
@@ -94,11 +91,11 @@ public virtual DamageType Type
{
get
{
- if (damageType != DamageType.Unknown)
- return damageType;
+ if (field != DamageType.Unknown)
+ return field;
- damageType = GetDamageType();
- return damageType;
+ field = GetDamageType();
+ return field;
}
protected set
@@ -106,7 +103,7 @@ protected set
if (!Enum.IsDefined(typeof(DamageType), value))
throw new InvalidEnumArgumentException(nameof(value), (int)value, typeof(DamageType));
- damageType = value;
+ field = value;
}
}
diff --git a/EXILED/Exiled.API/Features/Generator.cs b/EXILED/Exiled.API/Features/Generator.cs
index 125bdd347b..8f3d6f41be 100644
--- a/EXILED/Exiled.API/Features/Generator.cs
+++ b/EXILED/Exiled.API/Features/Generator.cs
@@ -27,7 +27,6 @@ public class Generator : IWrapper, IWorldSpace, IStructureSync
/// A of on the map.
///
internal static readonly Dictionary Scp079GeneratorToGenerator = new(new ComponentsEqualityComparer());
- private Room room;
///
/// Initializes a new instance of the class.
@@ -63,7 +62,7 @@ internal Generator(Scp079Generator scp079Generator)
///
/// Gets the generator's .
///
- public Room Room => room ??= Room.FindParentRoom(GameObject);
+ public Room Room => field ??= Room.FindParentRoom(GameObject);
///
/// Gets or sets the generator' state.
diff --git a/EXILED/Exiled.API/Features/Hazards/AmnesticCloudHazard.cs b/EXILED/Exiled.API/Features/Hazards/AmnesticCloudHazard.cs
index cc9bff8d67..3c2a982ab7 100644
--- a/EXILED/Exiled.API/Features/Hazards/AmnesticCloudHazard.cs
+++ b/EXILED/Exiled.API/Features/Hazards/AmnesticCloudHazard.cs
@@ -15,8 +15,6 @@ namespace Exiled.API.Features.Hazards
///
public class AmnesticCloudHazard : TemporaryHazard
{
- private static Scp939AmnesticCloudInstance amnesticCloudPrefab;
-
///
/// Initializes a new instance of the class.
///
@@ -36,10 +34,10 @@ public static Scp939AmnesticCloudInstance AmnesticCloudPrefab
{
get
{
- if (amnesticCloudPrefab == null)
- amnesticCloudPrefab = PrefabHelper.GetPrefab(PrefabType.AmnesticCloudHazard);
+ if (field == null)
+ field = PrefabHelper.GetPrefab(PrefabType.AmnesticCloudHazard);
- return amnesticCloudPrefab;
+ return field;
}
}
diff --git a/EXILED/Exiled.API/Features/Hazards/TantrumHazard.cs b/EXILED/Exiled.API/Features/Hazards/TantrumHazard.cs
index 2e36627d8b..1660ba4dc9 100644
--- a/EXILED/Exiled.API/Features/Hazards/TantrumHazard.cs
+++ b/EXILED/Exiled.API/Features/Hazards/TantrumHazard.cs
@@ -18,8 +18,6 @@ namespace Exiled.API.Features.Hazards
///
public class TantrumHazard : TemporaryHazard
{
- private static TantrumEnvironmentalHazard tantrumPrefab;
-
///
/// Initializes a new instance of the class.
///
@@ -37,10 +35,10 @@ public static TantrumEnvironmentalHazard TantrumPrefab
{
get
{
- if (tantrumPrefab == null)
- tantrumPrefab = PrefabHelper.GetPrefab(PrefabType.TantrumObj);
+ if (field == null)
+ field = PrefabHelper.GetPrefab(PrefabType.TantrumObj);
- return tantrumPrefab;
+ return field;
}
}
diff --git a/EXILED/Exiled.API/Features/Map.cs b/EXILED/Exiled.API/Features/Map.cs
index c4feacd0cb..4e8b3547ed 100644
--- a/EXILED/Exiled.API/Features/Map.cs
+++ b/EXILED/Exiled.API/Features/Map.cs
@@ -46,10 +46,6 @@ public static class Map
///
internal static List TeleportsValue = new();
- private static AmbientSoundPlayer ambientSoundPlayer;
-
- private static SqueakSpawner squeakSpawner;
-
///
/// Gets a value indicating whether decontamination has begun in the light containment zone.
///
@@ -123,12 +119,12 @@ public static bool IsDecontaminationEnabled
///
/// Gets the .
///
- public static AmbientSoundPlayer AmbientSoundPlayer => ambientSoundPlayer ??= ReferenceHub._hostHub.GetComponent();
+ public static AmbientSoundPlayer AmbientSoundPlayer => field ??= ReferenceHub._hostHub.GetComponent();
///
/// Gets the .
///
- public static SqueakSpawner SqueakSpawner => squeakSpawner ??= Object.FindFirstObjectByType();
+ public static SqueakSpawner SqueakSpawner => field ??= Object.FindFirstObjectByType();
///
/// Sends a staff message to all players online with permission.
diff --git a/EXILED/Exiled.API/Features/Pickups/BodyArmorPickup.cs b/EXILED/Exiled.API/Features/Pickups/BodyArmorPickup.cs
index 1b63763531..3de6897413 100644
--- a/EXILED/Exiled.API/Features/Pickups/BodyArmorPickup.cs
+++ b/EXILED/Exiled.API/Features/Pickups/BodyArmorPickup.cs
@@ -27,9 +27,6 @@ namespace Exiled.API.Features.Pickups
///
public class BodyArmorPickup : Pickup, IWrapper
{
- private int helmetEfficacy;
- private int vestEfficacy;
-
///
/// Initializes a new instance of the class.
///
@@ -79,20 +76,12 @@ internal BodyArmorPickup(ItemType type)
///
/// Gets or sets how strong the helmet on the armor is.
///
- public int HelmetEfficacy
- {
- get => helmetEfficacy;
- set => helmetEfficacy = value;
- }
+ public int HelmetEfficacy { get; set; }
///
/// Gets or sets how strong the vest on the armor is.
///
- public int VestEfficacy
- {
- get => vestEfficacy;
- set => vestEfficacy = value;
- }
+ public int VestEfficacy { get; set; }
///
/// Gets or sets how much faster stamina will drain when wearing this armor.
@@ -131,8 +120,8 @@ internal override void ReadItemInfo(Item item)
base.ReadItemInfo(item);
if (item is Armor armoritem)
{
- helmetEfficacy = armoritem.HelmetEfficacy;
- vestEfficacy = armoritem.VestEfficacy;
+ HelmetEfficacy = armoritem.HelmetEfficacy;
+ VestEfficacy = armoritem.VestEfficacy;
StaminaUseMultiplier = armoritem.StaminaUseMultiplier;
StaminaRegenMultiplier = armoritem.StaminaRegenMultiplier;
AmmoLimits = armoritem.AmmoLimits;
@@ -146,8 +135,8 @@ protected override void InitializeProperties(ItemBase itemBase)
base.InitializeProperties(itemBase);
if (itemBase is BodyArmor armoritem)
{
- helmetEfficacy = armoritem.HelmetEfficacy;
- vestEfficacy = armoritem.VestEfficacy;
+ HelmetEfficacy = armoritem.HelmetEfficacy;
+ VestEfficacy = armoritem.VestEfficacy;
StaminaUseMultiplier = armoritem._staminaUseMultiplier;
StaminaRegenMultiplier = armoritem.StaminaRegenMultiplier;
AmmoLimits = armoritem.AmmoLimits.Select(limit => (ArmorAmmoLimit)limit);
diff --git a/EXILED/Exiled.API/Features/Player.cs b/EXILED/Exiled.API/Features/Player.cs
index bfac75fbe0..522a0a2113 100644
--- a/EXILED/Exiled.API/Features/Player.cs
+++ b/EXILED/Exiled.API/Features/Player.cs
@@ -95,10 +95,6 @@ public class Player : TypeCastObject, IEntity, IWorldSpace
private readonly HashSet componentsInChildren = new();
- private ReferenceHub referenceHub;
-
- private Role role;
-
///
/// Initializes a new instance of the class.
///
@@ -192,10 +188,10 @@ public Player(GameObject gameObject)
///
public ReferenceHub ReferenceHub
{
- get => referenceHub;
+ get;
private set
{
- referenceHub = value ?? throw new NullReferenceException("Player's ReferenceHub cannot be null!");
+ field = value ?? throw new NullReferenceException("Player's ReferenceHub cannot be null!");
GameObject = value.gameObject;
HintDisplay = value.hints;
Inventory = value.inventory;
@@ -278,7 +274,7 @@ public int Id
///
/// Gets the player's user id.
///
- public string UserId => referenceHub.authManager.UserId;
+ public string UserId => ReferenceHub.authManager.UserId;
///
/// Gets the player's user id without the authentication.
@@ -599,11 +595,11 @@ public PlayerPermissions RemoteAdminPermissions
///
public Role Role
{
- get => role ??= Role.Create(RoleManager.CurrentRole);
+ get => field ??= Role.Create(RoleManager.CurrentRole);
internal set
{
- PreviousRole = role?.Type ?? RoleTypeId.None;
- role = value;
+ PreviousRole = field?.Type ?? RoleTypeId.None;
+ field = value;
}
}
@@ -654,7 +650,7 @@ public ScpSpawnPreferences.SpawnPreferences ScpPreferences
public bool IsJumping
{
get => Role is FpcRole fpc && fpc.FirstPersonController.FpcModule.Motor.JumpController.IsJumping;
- set => _ = Role is FpcRole fpc ? fpc.FirstPersonController.FpcModule.Motor.JumpController.IsJumping = value : _ = value;
+ set => (Role as FpcRole)?.FirstPersonController.FpcModule.Motor.JumpController.IsJumping = value;
}
///
@@ -863,7 +859,7 @@ public bool IsGodModeEnabled
public byte UnitId
{
get => Role.Base is PlayerRoles.HumanRole humanRole ? humanRole.UnitNameId : byte.MinValue;
- set => _ = Role.Base is PlayerRoles.HumanRole humanRole ? humanRole.UnitNameId = value : _ = value;
+ set => (Role.Base as PlayerRoles.HumanRole)?.UnitNameId = value;
}
///
@@ -1074,7 +1070,7 @@ public string GroupName
///
///
///
- public IEnumerable ActiveEffects => referenceHub.playerEffectsController.AllEffects.Where(effect => effect.Intensity > 0);
+ public IEnumerable ActiveEffects => ReferenceHub.playerEffectsController.AllEffects.Where(effect => effect.Intensity > 0);
///
/// Gets or sets the player's group.
@@ -1962,7 +1958,7 @@ public void Handcuff()
{
ReferenceHub.inventory.SetDisarmedStatus(null);
- DisarmedPlayers.Entries.Add(new DisarmedPlayers.DisarmedEntry(referenceHub.networkIdentity.netId, 0U));
+ DisarmedPlayers.Entries.Add(new DisarmedPlayers.DisarmedEntry(ReferenceHub.networkIdentity.netId, 0U));
new DisarmedPlayersListMessage(DisarmedPlayers.Entries).SendToAuthenticated(0);
}
@@ -2183,7 +2179,7 @@ public int RemoveItem(Func- predicate, bool destroy = true)
///
/// The message to be sent.
/// The message color.
- public void SendConsoleMessage(string message, string color) => referenceHub.gameConsoleTransmission.SendToClient(message, color);
+ public void SendConsoleMessage(string message, string color) => ReferenceHub.gameConsoleTransmission.SendToClient(message, color);
///
/// Disconnects the player.
@@ -2590,7 +2586,7 @@ public ushort GetAmmoLimit(AmmoType type, bool ignoreArmor = false)
return ServerConfigSynchronizer.Singleton.AmmoLimitsSync.FirstOrDefault(x => x.AmmoType == itemType).Limit;
}
- return InventorySystem.Configs.InventoryLimits.GetAmmoLimit(type.GetItemType(), referenceHub);
+ return InventorySystem.Configs.InventoryLimits.GetAmmoLimit(type.GetItemType(), ReferenceHub);
}
///
@@ -2666,7 +2662,7 @@ public sbyte GetCategoryLimit(ItemCategory category, bool ignoreArmor = false)
return ServerConfigSynchronizer.Singleton.CategoryLimits[index];
}
- sbyte limit = InventorySystem.Configs.InventoryLimits.GetCategoryLimit(category, referenceHub);
+ sbyte limit = InventorySystem.Configs.InventoryLimits.GetCategoryLimit(category, ReferenceHub);
return limit == -1 ? (sbyte)1 : limit;
}
diff --git a/EXILED/Exiled.API/Features/Respawn.cs b/EXILED/Exiled.API/Features/Respawn.cs
index 0f34495520..fba56ca96d 100644
--- a/EXILED/Exiled.API/Features/Respawn.cs
+++ b/EXILED/Exiled.API/Features/Respawn.cs
@@ -25,9 +25,6 @@ namespace Exiled.API.Features
///
public static class Respawn
{
- private static GameObject ntfHelicopterGameObject;
- private static GameObject chaosCarGameObject;
-
///
/// Gets the of paused 's.
///
@@ -45,10 +42,10 @@ public static GameObject NtfHelicopter
{
get
{
- if (ntfHelicopterGameObject == null)
- ntfHelicopterGameObject = GameObject.Find("Chopper");
+ if (field == null)
+ field = GameObject.Find("Chopper");
- return ntfHelicopterGameObject;
+ return field;
}
}
@@ -59,10 +56,10 @@ public static GameObject ChaosVan
{
get
{
- if (chaosCarGameObject == null)
- chaosCarGameObject = GameObject.Find("CIVanArrive");
+ if (field == null)
+ field = GameObject.Find("CIVanArrive");
- return chaosCarGameObject;
+ return field;
}
}
diff --git a/EXILED/Exiled.API/Features/Roles/FpcRole.cs b/EXILED/Exiled.API/Features/Roles/FpcRole.cs
index 9a1156833e..cf0cdae5c1 100644
--- a/EXILED/Exiled.API/Features/Roles/FpcRole.cs
+++ b/EXILED/Exiled.API/Features/Roles/FpcRole.cs
@@ -27,8 +27,6 @@ namespace Exiled.API.Features.Roles
///
public abstract class FpcRole : Role, IVoiceRole
{
- private bool isUsingStamina = true;
-
///
/// Initializes a new instance of the class.
///
@@ -190,12 +188,12 @@ public bool MovementDetected
///
public bool IsUsingStamina
{
- get => isUsingStamina;
+ get;
set
{
if (!value)
Owner.ResetStamina();
- isUsingStamina = value;
+ field = value;
}
}
diff --git a/EXILED/Exiled.API/Features/Server.cs b/EXILED/Exiled.API/Features/Server.cs
index 825cfb3566..0d5809912e 100644
--- a/EXILED/Exiled.API/Features/Server.cs
+++ b/EXILED/Exiled.API/Features/Server.cs
@@ -32,8 +32,6 @@ namespace Exiled.API.Features
///
public static class Server
{
- private static MethodInfo sendSpawnMessage;
-
///
/// Gets a dictionary that pairs assemblies with their associated plugins.
///
@@ -53,7 +51,7 @@ public static class Server
///
/// Gets the cached .
///
- public static MethodInfo SendSpawnMessage => sendSpawnMessage ??= typeof(NetworkServer).GetMethod("SendSpawnMessage", BindingFlags.NonPublic | BindingFlags.Static);
+ public static MethodInfo SendSpawnMessage => field ??= typeof(NetworkServer).GetMethod("SendSpawnMessage", BindingFlags.NonPublic | BindingFlags.Static);
///
/// Gets or sets the name of the server.
diff --git a/EXILED/Exiled.API/Features/Warhead.cs b/EXILED/Exiled.API/Features/Warhead.cs
index f261577770..3f1e12c516 100644
--- a/EXILED/Exiled.API/Features/Warhead.cs
+++ b/EXILED/Exiled.API/Features/Warhead.cs
@@ -20,8 +20,6 @@ namespace Exiled.API.Features
///
public static class Warhead
{
- private static AlphaWarheadOutsitePanel alphaWarheadOutsitePanel;
-
///
/// Gets the cached component.
///
@@ -35,7 +33,7 @@ public static class Warhead
///
/// Gets the cached component.
///
- public static AlphaWarheadOutsitePanel OutsitePanel => alphaWarheadOutsitePanel != null ? alphaWarheadOutsitePanel : (alphaWarheadOutsitePanel = UnityEngine.Object.FindFirstObjectByType());
+ public static AlphaWarheadOutsitePanel OutsitePanel => field != null ? field : (field = Object.FindFirstObjectByType());
///
/// Gets the of the warhead lever.
diff --git a/EXILED/Exiled.CustomRoles/API/Features/Parsers/AggregateExpectationTypeResolver.cs b/EXILED/Exiled.CustomRoles/API/Features/Parsers/AggregateExpectationTypeResolver.cs
index 646c34bb50..8f701628a3 100644
--- a/EXILED/Exiled.CustomRoles/API/Features/Parsers/AggregateExpectationTypeResolver.cs
+++ b/EXILED/Exiled.CustomRoles/API/Features/Parsers/AggregateExpectationTypeResolver.cs
@@ -52,7 +52,7 @@ public AggregateExpectationTypeResolver(INamingConvention namingConvention)
}
///
- public Type BaseType => typeof(CustomAbility);
+ public Type BaseType => field ??= typeof(CustomAbility);
///
public bool TryResolve(ParsingEventBuffer buffer, out Type? suggestedType)
diff --git a/EXILED/Exiled.Events/EventArgs/Cassie/SendingCassieMessageEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Cassie/SendingCassieMessageEventArgs.cs
index abd2f4877f..d5dbbef859 100644
--- a/EXILED/Exiled.Events/EventArgs/Cassie/SendingCassieMessageEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Cassie/SendingCassieMessageEventArgs.cs
@@ -20,12 +20,8 @@ namespace Exiled.Events.EventArgs.Cassie
///
public class SendingCassieMessageEventArgs : IDeniableEvent
{
- private readonly CassieAnnouncement announcement;
private readonly CassieTtsPayload payload;
- private string customSubtitles;
- private float glitchScale;
-
///
/// Initializes a new instance of the class.
///
@@ -35,7 +31,7 @@ public class SendingCassieMessageEventArgs : IDeniableEvent
///
public SendingCassieMessageEventArgs(CassieAnnouncement annc, bool isAllowed = true)
{
- announcement = annc;
+ Announcement = annc;
payload = annc.Payload;
Words = payload.Content;
@@ -84,13 +80,13 @@ public SendingCassieMessageEventArgs(CassieAnnouncement annc, bool isAllowed = t
///
public string CustomSubtitles
{
- get => customSubtitles;
+ get;
set
{
- if (customSubtitles != value)
+ if (field != value)
SubtitleSource = CassieTtsPayload.SubtitleMode.Custom;
- customSubtitles = value;
+ field = value;
}
}
@@ -104,13 +100,13 @@ public string CustomSubtitles
///
public float GlitchScale
{
- get => glitchScale;
+ get;
set
{
if (!MakeNoise && value is not 0)
MakeNoise = true;
- glitchScale = value;
+ field = value;
}
}
@@ -157,7 +153,7 @@ public CassieAnnouncement Announcement
newPayload = new CassieTtsPayload(Words, CustomSubtitles, MakeHold);
}
- return announcement switch
+ return field switch
{
CassieScpTerminationAnnouncement =>
@@ -169,6 +165,7 @@ public CassieAnnouncement Announcement
_ => new CassieAnnouncement(newPayload, 0, GlitchScale / (API.Features.Warhead.IsDetonated ? 2F : 1F) * (MakeNoise ? 1F : 0F)),
};
}
+ private set;
}
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.Events/EventArgs/Map/ExplodingGrenadeEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Map/ExplodingGrenadeEventArgs.cs
index a0bba7e674..474995b3b8 100644
--- a/EXILED/Exiled.Events/EventArgs/Map/ExplodingGrenadeEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Map/ExplodingGrenadeEventArgs.cs
@@ -27,8 +27,6 @@ namespace Exiled.Events.EventArgs.Map
///
public class ExplodingGrenadeEventArgs : IPlayerEvent, IDeniableEvent, IPickupEvent
{
- private ExplosionType explosionType;
-
///
/// Initializes a new instance of the class.
///
@@ -125,8 +123,8 @@ public ExplodingGrenadeEventArgs(Player thrower, EffectGrenade grenade, HashSet<
/// Explosion that are not from will return and can't be modified.
public ExplosionType ExplosionType
{
- get => explosionType;
- set => explosionType = Projectile is ExplosionGrenadeProjectile ? value : ExplosionType.Custom;
+ get;
+ set => field = Projectile is ExplosionGrenadeProjectile ? value : ExplosionType.Custom;
}
///
diff --git a/EXILED/Exiled.Events/EventArgs/Player/BanningEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/BanningEventArgs.cs
index 86d15bf8d8..5679b15f76 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/BanningEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/BanningEventArgs.cs
@@ -17,8 +17,6 @@ namespace Exiled.Events.EventArgs.Player
///
public class BanningEventArgs : KickingEventArgs
{
- private long duration;
-
///
/// Initializes a new instance of the class.
///
@@ -40,16 +38,16 @@ public BanningEventArgs(Player target, Player issuer, ICommandSender commandSend
///
public long Duration
{
- get => duration;
+ get;
set
{
- if (duration == value)
+ if (field == value)
return;
if (Events.Instance.Config.ShouldLogBans)
- LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed Ban duration: {duration} to {value} for ID: {Target.UserId}");
+ LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed Ban duration: {field} to {value} for ID: {Target.UserId}");
- duration = value;
+ field = value;
}
}
}
diff --git a/EXILED/Exiled.Events/EventArgs/Player/ChangingItemEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/ChangingItemEventArgs.cs
index 6978172b54..ee305c7ada 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/ChangingItemEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/ChangingItemEventArgs.cs
@@ -21,8 +21,6 @@ namespace Exiled.Events.EventArgs.Player
///
public class ChangingItemEventArgs : IPlayerEvent, IDeniableEvent, IItemEvent
{
- private Item newItem;
-
///
/// Initializes a new instance of the class.
///
@@ -35,7 +33,7 @@ public class ChangingItemEventArgs : IPlayerEvent, IDeniableEvent, IItemEvent
public ChangingItemEventArgs(Player player, ItemBase newItem)
{
Player = player;
- this.newItem = Item.Get(newItem);
+ Item = Item.Get(newItem);
}
///
@@ -43,13 +41,13 @@ public ChangingItemEventArgs(Player player, ItemBase newItem)
///
public Item Item
{
- get => newItem;
+ get;
set
{
if (value != null && !Player.Inventory.UserInventory.Items.TryGetValue(value.Serial, out _))
throw new InvalidOperationException("ev.NewItem cannot be set to an item they do not have.");
- newItem = value;
+ field = value;
}
}
diff --git a/EXILED/Exiled.Events/EventArgs/Player/ChangingRoleEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/ChangingRoleEventArgs.cs
index 898ab9fcd6..052fff48c6 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/ChangingRoleEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/ChangingRoleEventArgs.cs
@@ -22,8 +22,6 @@ namespace Exiled.Events.EventArgs.Player
///
public class ChangingRoleEventArgs : IPlayerEvent, IDeniableEvent
{
- private RoleTypeId newRole;
-
///
/// Initializes a new instance of the class.
///
@@ -66,7 +64,7 @@ public ChangingRoleEventArgs(Player player, RoleTypeId newRole, RoleChangeReason
///
public RoleTypeId NewRole
{
- get => newRole;
+ get;
set
{
InventoryRoleInfo inventory = value.GetInventory();
@@ -80,7 +78,7 @@ public RoleTypeId NewRole
foreach (KeyValuePair ammoPair in inventory.Ammo)
Ammo.Add(ammoPair.Key, ammoPair.Value);
- newRole = value;
+ field = value;
}
}
diff --git a/EXILED/Exiled.Events/EventArgs/Player/DroppingAmmoEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/DroppingAmmoEventArgs.cs
index 0b379c903a..999516aef2 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/DroppingAmmoEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/DroppingAmmoEventArgs.cs
@@ -19,8 +19,6 @@ namespace Exiled.Events.EventArgs.Player
///
public class DroppingAmmoEventArgs : IPlayerEvent, IDeniableEvent
{
- private bool isAllowed = true;
-
///
/// Initializes a new instance of the class.
///
@@ -66,18 +64,12 @@ public DroppingAmmoEventArgs(Player player, ItemType itemType, ushort amount, bo
///
public bool IsAllowed
{
- get
- {
- if (Player.Role == RoleTypeId.Spectator)
- isAllowed = true;
- return isAllowed;
- }
-
+ get;
set
{
if (Player.Role == RoleTypeId.Spectator)
- value = true;
- isAllowed = value;
+ return;
+ field = value;
}
}
diff --git a/EXILED/Exiled.Events/EventArgs/Player/DroppingItemEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/DroppingItemEventArgs.cs
index cf15df90d3..16006c8164 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/DroppingItemEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/DroppingItemEventArgs.cs
@@ -21,8 +21,6 @@ namespace Exiled.Events.EventArgs.Player
///
public class DroppingItemEventArgs : IItemEvent, IDeniableEvent
{
- private bool isAllowed = true;
-
///
/// Initializes a new instance of the class.
///
@@ -44,6 +42,7 @@ public DroppingItemEventArgs(Player player, ItemBase item, bool isThrown, bool i
Item = Item.Get(item);
IsAllowed = isAllowed;
IsThrown = isThrown;
+ IsAllowed = isAllowed;
}
///
@@ -56,18 +55,12 @@ public DroppingItemEventArgs(Player player, ItemBase item, bool isThrown, bool i
///
public bool IsAllowed
{
- get
- {
- if (Player.Role == RoleTypeId.Spectator)
- isAllowed = true;
- return isAllowed;
- }
-
+ get;
set
{
if (Player.Role == RoleTypeId.Spectator)
- value = true;
- isAllowed = value;
+ return;
+ field = value;
}
}
diff --git a/EXILED/Exiled.Events/EventArgs/Player/EscapingEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/EscapingEventArgs.cs
index 2e94fd0e6d..7333b3b3f8 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/EscapingEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/EscapingEventArgs.cs
@@ -18,8 +18,6 @@ namespace Exiled.Events.EventArgs.Player
///
public class EscapingEventArgs : IPlayerEvent, IDeniableEvent
{
- private EscapeScenario escapeScenario;
-
///
/// Initializes a new instance of the class.
///
@@ -55,8 +53,8 @@ public EscapingEventArgs(ReferenceHub referenceHub, RoleTypeId newRole, EscapeSc
///
public EscapeScenario EscapeScenario
{
- get => (escapeScenario is EscapeScenario.None && IsAllowed) ? EscapeScenario.CustomEscape : escapeScenario;
- set => escapeScenario = value;
+ get => (field is EscapeScenario.None && IsAllowed) ? EscapeScenario.CustomEscape : field;
+ set;
}
///
diff --git a/EXILED/Exiled.Events/EventArgs/Player/InteractingShootingTargetEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/InteractingShootingTargetEventArgs.cs
index 086dd409bc..9555a12861 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/InteractingShootingTargetEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/InteractingShootingTargetEventArgs.cs
@@ -24,9 +24,6 @@ namespace Exiled.Events.EventArgs.Player
///
public class InteractingShootingTargetEventArgs : IPlayerEvent, IDeniableEvent
{
- private int autoResetTime;
- private int maxHp;
-
///
/// Initializes a new instance of the class.
///
@@ -54,8 +51,8 @@ public InteractingShootingTargetEventArgs(Player player, ShootingTarget shooting
ShootingTarget = ShootingTargetToy.Get(shootingTarget);
TargetButton = targetButton;
IsAllowed = isAllowed;
- this.maxHp = maxHp;
- this.autoResetTime = autoResetTime;
+ NewMaxHp = maxHp;
+ NewAutoResetTime = autoResetTime;
}
///
@@ -73,12 +70,12 @@ public InteractingShootingTargetEventArgs(Player player, ShootingTarget shooting
///
public int NewMaxHp
{
- get => maxHp;
+ get;
set
{
if (!ShootingTarget.IsSynced)
throw new InvalidOperationException("Attempted to set MaxHp while target is in local mode. Set target's IsSynced to true before setting IsAllowed.");
- maxHp = Mathf.Clamp(value, 1, 256);
+ field = Mathf.Clamp(value, 1, 256);
}
}
@@ -87,12 +84,12 @@ public int NewMaxHp
///
public int NewAutoResetTime
{
- get => autoResetTime;
+ get;
set
{
if (!ShootingTarget.IsSynced)
throw new InvalidOperationException("Attempted to set AutoResetTime while target is in local mode. Set target's IsSynced to true before setting IsAllowed.");
- autoResetTime = Mathf.Clamp(value, 0, 10);
+ field = Mathf.Clamp(value, 0, 10);
}
}
diff --git a/EXILED/Exiled.Events/EventArgs/Player/KickingEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/KickingEventArgs.cs
index cfa2864b85..3fc6c1221d 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/KickingEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/KickingEventArgs.cs
@@ -19,9 +19,6 @@ namespace Exiled.Events.EventArgs.Player
public class KickingEventArgs : IPlayerEvent, IDeniableEvent
{
private readonly string startkickmessage;
- private bool isAllowed;
- private Player issuer;
- private Player target;
///
/// Initializes a new instance of the class.
@@ -59,16 +56,16 @@ public KickingEventArgs(Player target, Player issuer, ICommandSender commandSend
///
public Player Target
{
- get => target;
+ get;
set
{
- if (value is null || target == value)
+ if (value is null || field == value)
return;
- if (Events.Instance.Config.ShouldLogBans && target is not null)
- LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed the banned player from user {target.Nickname} ({target.UserId}) to {value.Nickname} ({value.UserId})");
+ if (Events.Instance.Config.ShouldLogBans && field is not null)
+ LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed the banned player from user {field.Nickname} ({field.UserId}) to {value.Nickname} ({value.UserId})");
- target = value;
+ field = value;
}
}
@@ -87,16 +84,16 @@ public Player Target
///
public bool IsAllowed
{
- get => isAllowed;
+ get;
set
{
- if (isAllowed == value)
+ if (field == value)
return;
if (Events.Instance.Config.ShouldLogBans)
LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" {(value ? "allowed" : "denied")} banning user with ID: {Target.UserId}");
- isAllowed = value;
+ field = value;
}
}
@@ -105,16 +102,16 @@ public bool IsAllowed
///
public Player Player
{
- get => issuer;
+ get;
set
{
- if (value is null || issuer == value)
+ if (value is null || field == value)
return;
- if (Events.Instance.Config.ShouldLogBans && issuer is not null)
- LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed the ban issuer from user {issuer.Nickname} ({issuer.UserId}) to {value.Nickname} ({value.UserId})");
+ if (Events.Instance.Config.ShouldLogBans && field is not null)
+ LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed the ban issuer from user {field.Nickname} ({field.UserId}) to {value.Nickname} ({value.UserId})");
- issuer = value;
+ field = value;
}
}
diff --git a/EXILED/Exiled.Events/EventArgs/Player/LeftEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/LeftEventArgs.cs
index 419a185713..84e5e8735a 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/LeftEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/LeftEventArgs.cs
@@ -8,19 +8,22 @@
namespace Exiled.Events.EventArgs.Player
{
using API.Features;
+ using Exiled.Events.EventArgs.Interfaces;
///
/// Contains all information after a disconnects from the server.
///
- public class LeftEventArgs : JoinedEventArgs
+ public class LeftEventArgs : IPlayerEvent
{
///
/// Initializes a new instance of the class.
///
/// The player who left the server.
- public LeftEventArgs(Player player)
- : base(player)
- {
- }
+ public LeftEventArgs(Player player) => Player = player;
+
+ ///
+ /// Gets the left player.
+ ///
+ public Player Player { get; }
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.Events/EventArgs/Player/ReservedSlotsCheckEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/ReservedSlotsCheckEventArgs.cs
index ebb22cf345..cb949391ca 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/ReservedSlotsCheckEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/ReservedSlotsCheckEventArgs.cs
@@ -15,8 +15,6 @@ namespace Exiled.Events.EventArgs.Player
///
public class ReservedSlotsCheckEventArgs : IExiledEvent, IDeniableEvent
{
- private ReservedSlotEventResult reservedSlotEventResult = ReservedSlotEventResult.UseBaseGameSystem;
-
///
/// Initializes a new instance of the class.
///
@@ -31,6 +29,7 @@ public ReservedSlotsCheckEventArgs(bool hasReservedSlot, string userId)
UserId = userId;
HasReservedSlot = hasReservedSlot;
IsAllowed = hasReservedSlot;
+ Result = ReservedSlotEventResult.UseBaseGameSystem;
}
///
@@ -53,7 +52,7 @@ public ReservedSlotsCheckEventArgs(bool hasReservedSlot, string userId)
///
public ReservedSlotEventResult Result
{
- get => reservedSlotEventResult;
+ get;
set
{
switch (value)
@@ -71,7 +70,7 @@ public ReservedSlotEventResult Result
return;
}
- reservedSlotEventResult = value;
+ field = value;
}
}
}
diff --git a/EXILED/Exiled.Events/EventArgs/Player/RevokingMuteEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/RevokingMuteEventArgs.cs
index eb3251599a..599ac864bf 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/RevokingMuteEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/RevokingMuteEventArgs.cs
@@ -8,11 +8,12 @@
namespace Exiled.Events.EventArgs.Player
{
using API.Features;
+ using Exiled.Events.EventArgs.Interfaces;
///
/// Contains all information before unmuting a player.
///
- public class RevokingMuteEventArgs : IssuingMuteEventArgs
+ public class RevokingMuteEventArgs : IPlayerEvent, IDeniableEvent
{
///
/// Initializes a new instance of the class.
@@ -27,8 +28,25 @@ public class RevokingMuteEventArgs : IssuingMuteEventArgs
/// Indicates whether the player can be unmuted.
///
public RevokingMuteEventArgs(Player player, bool isIntercom, bool isAllowed = true)
- : base(player, isIntercom, isAllowed)
{
+ Player = player;
+ IsIntercom = isIntercom;
+ IsAllowed = isAllowed;
}
+
+ ///
+ /// Gets the player who's being revoking the mute.
+ ///
+ public Player Player { get; }
+
+ ///
+ /// Gets or sets a value indicating whether the player is being revoking intercom muted.
+ ///
+ public bool IsIntercom { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the player can be revoked muted.
+ ///
+ public bool IsAllowed { get; set; }
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.Events/EventArgs/Scp914/ChangingKnobSettingEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Scp914/ChangingKnobSettingEventArgs.cs
index b7af91e658..4874f9de7b 100644
--- a/EXILED/Exiled.Events/EventArgs/Scp914/ChangingKnobSettingEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Scp914/ChangingKnobSettingEventArgs.cs
@@ -18,8 +18,6 @@ namespace Exiled.Events.EventArgs.Scp914
///
public class ChangingKnobSettingEventArgs : IPlayerEvent, IDeniableEvent
{
- private Scp914KnobSetting knobSetting;
-
///
/// Initializes a new instance of the class.
///
@@ -44,8 +42,8 @@ public ChangingKnobSettingEventArgs(Player player, Scp914KnobSetting knobSetting
///
public Scp914KnobSetting KnobSetting
{
- get => knobSetting;
- set => knobSetting = value > Scp914KnobSetting.VeryFine ? Scp914KnobSetting.Coarse : value;
+ get;
+ set => field = value > Scp914KnobSetting.VeryFine ? Scp914KnobSetting.Coarse : value;
}
///
diff --git a/EXILED/Exiled.Events/EventArgs/Server/RespawningTeamEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Server/RespawningTeamEventArgs.cs
index df999d90dd..6e017b2816 100644
--- a/EXILED/Exiled.Events/EventArgs/Server/RespawningTeamEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Server/RespawningTeamEventArgs.cs
@@ -24,8 +24,6 @@ namespace Exiled.Events.EventArgs.Server
///
public class RespawningTeamEventArgs : IDeniableEvent
{
- private int maximumRespawnAmount;
-
///
/// Initializes a new instance of the class.
///
@@ -69,16 +67,16 @@ public RespawningTeamEventArgs(List players, int maxRespawn, SpawnableWa
///
public int MaximumRespawnAmount
{
- get => maximumRespawnAmount;
+ get;
set
{
- if (value < maximumRespawnAmount)
+ if (value < field)
{
if (Players.Count > value)
Players.RemoveRange(value, Players.Count - value);
}
- maximumRespawnAmount = value;
+ field = value;
}
}
diff --git a/EXILED/Exiled.Events/EventArgs/Warhead/StartingEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Warhead/StartingEventArgs.cs
index 6f006c5299..214c8f8c2b 100644
--- a/EXILED/Exiled.Events/EventArgs/Warhead/StartingEventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Warhead/StartingEventArgs.cs
@@ -8,11 +8,12 @@
namespace Exiled.Events.EventArgs.Warhead
{
using Exiled.API.Features;
+ using Exiled.Events.EventArgs.Interfaces;
///
/// Contains all information before starting the warhead.
///
- public class StartingEventArgs : StoppingEventArgs
+ public class StartingEventArgs : IPlayerEvent, IDeniableEvent
{
///
/// Initializes a new instance of the class.
@@ -21,14 +22,25 @@ public class StartingEventArgs : StoppingEventArgs
/// Indicating whether the nuke was set off automatically.
/// Indicating whether the event can be executed.
public StartingEventArgs(Player player, bool isAuto, bool isAllowed = true)
- : base(player, isAllowed)
{
IsAuto = isAuto;
+ Player = player ?? Server.Host;
+ IsAllowed = isAllowed;
}
///
/// Gets or sets a value indicating whether the nuke was set off automatically.
///
public bool IsAuto { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the warhead can be started.
+ ///
+ public bool IsAllowed { get; set; }
+
+ ///
+ /// Gets the player who's going to start the warhead.
+ ///
+ public Player Player { get; }
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.Events/Events.cs b/EXILED/Exiled.Events/Events.cs
index c941abd7ca..f01919aff5 100644
--- a/EXILED/Exiled.Events/Events.cs
+++ b/EXILED/Exiled.Events/Events.cs
@@ -30,12 +30,10 @@ namespace Exiled.Events
///
public sealed class Events : Plugin
{
- private static Events instance;
-
///
/// Gets the plugin instance.
///
- public static Events Instance => instance;
+ public static Events Instance { get; private set; }
///
public override PluginPriority Priority { get; } = PluginPriority.First;
@@ -48,7 +46,7 @@ public sealed class Events : Plugin
///
public override void OnEnabled()
{
- instance = this;
+ Instance = this;
base.OnEnabled();
Stopwatch watch = Stopwatch.StartNew();
diff --git a/EXILED/Exiled.Events/Patches/Generic/AirlockListAdd.cs b/EXILED/Exiled.Events/Patches/Generic/AirlockListAdd.cs
index d8cab19b53..6da34a65eb 100644
--- a/EXILED/Exiled.Events/Patches/Generic/AirlockListAdd.cs
+++ b/EXILED/Exiled.Events/Patches/Generic/AirlockListAdd.cs
@@ -21,7 +21,7 @@ internal class AirlockListAdd
{
private static void Postfix(AirlockController __instance)
{
- _ = new API.Features.Doors.AirlockController(__instance);
+ new API.Features.Doors.AirlockController(__instance);
}
}
diff --git a/EXILED/Exiled.Events/Patches/Generic/CoffeeListAdd.cs b/EXILED/Exiled.Events/Patches/Generic/CoffeeListAdd.cs
index 1761e27416..d7eb560618 100644
--- a/EXILED/Exiled.Events/Patches/Generic/CoffeeListAdd.cs
+++ b/EXILED/Exiled.Events/Patches/Generic/CoffeeListAdd.cs
@@ -19,7 +19,7 @@ internal class CoffeeListAdd
{
private static void Postfix(global::Coffee __instance)
{
- _ = new Coffee(__instance);
+ new Coffee(__instance);
}
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.Events/Patches/Generic/HazardList.cs b/EXILED/Exiled.Events/Patches/Generic/HazardList.cs
index 34aacf25d1..db16697730 100644
--- a/EXILED/Exiled.Events/Patches/Generic/HazardList.cs
+++ b/EXILED/Exiled.Events/Patches/Generic/HazardList.cs
@@ -21,7 +21,7 @@ internal class HazardList
{
[HarmonyPatch(typeof(EnvironmentalHazard), nameof(EnvironmentalHazard.Start))]
[HarmonyPostfix]
- private static void Adding(EnvironmentalHazard __instance) => _ = Hazard.Get(__instance);
+ private static void Adding(EnvironmentalHazard __instance) => Hazard.Get(__instance);
[HarmonyPatch(typeof(EnvironmentalHazard), nameof(EnvironmentalHazard.OnDestroy))]
[HarmonyPostfix]
diff --git a/EXILED/Exiled.Events/Patches/Generic/LiftList.cs b/EXILED/Exiled.Events/Patches/Generic/LiftList.cs
index d4b212dcd5..d088dbe3ab 100644
--- a/EXILED/Exiled.Events/Patches/Generic/LiftList.cs
+++ b/EXILED/Exiled.Events/Patches/Generic/LiftList.cs
@@ -21,7 +21,7 @@ internal class LiftList
{
[HarmonyPatch(typeof(ElevatorChamber), nameof(ElevatorChamber.Start))]
[HarmonyPostfix]
- private static void Adding(ElevatorChamber __instance) => _ = new Lift(__instance);
+ private static void Adding(ElevatorChamber __instance) => new Lift(__instance);
[HarmonyPatch(typeof(ElevatorChamber), nameof(ElevatorChamber.OnDestroy))]
[HarmonyPostfix]
From 690222436b05e763ba6fda34d1984cfad83b1f24 Mon Sep 17 00:00:00 2001
From: Yamato <66829532+louis1706@users.noreply.github.com>
Date: Thu, 5 Mar 2026 21:11:10 +0100
Subject: [PATCH 09/33] fix: IsUsingStamina missing default set to True
---
EXILED/Exiled.API/Features/Roles/FpcRole.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/EXILED/Exiled.API/Features/Roles/FpcRole.cs b/EXILED/Exiled.API/Features/Roles/FpcRole.cs
index cf0cdae5c1..e61e866ea7 100644
--- a/EXILED/Exiled.API/Features/Roles/FpcRole.cs
+++ b/EXILED/Exiled.API/Features/Roles/FpcRole.cs
@@ -35,6 +35,7 @@ protected FpcRole(FpcStandardRoleBase baseRole)
: base(baseRole)
{
FirstPersonController = baseRole;
+ IsUsingStamina = true;
}
///
From adfa2fb27b9f5f111f64861a5c31c24543f53dba Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Sat, 7 Mar 2026 00:03:24 +0300
Subject: [PATCH 10/33] fix: Players base Category Limits (#764)
* fix
* someonefix
* set reset validaiton
---
EXILED/Exiled.API/Features/Player.cs | 20 +++++++++-----------
1 file changed, 9 insertions(+), 11 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Player.cs b/EXILED/Exiled.API/Features/Player.cs
index 522a0a2113..6f4f500e65 100644
--- a/EXILED/Exiled.API/Features/Player.cs
+++ b/EXILED/Exiled.API/Features/Player.cs
@@ -2652,9 +2652,9 @@ public void ResetAmmoLimit(AmmoType ammoType)
/// The maximum amount of items in the category that the player can hold.
public sbyte GetCategoryLimit(ItemCategory category, bool ignoreArmor = false)
{
- int index = InventorySystem.Configs.InventoryLimits.StandardCategoryLimits.Where(x => x.Value >= 0).OrderBy(x => x.Key).ToList().FindIndex(x => x.Key == category);
+ int index = (int)category;
- if (ignoreArmor && index != -1)
+ if (ignoreArmor)
{
if (CustomCategoryLimits.TryGetValue(category, out sbyte customLimit))
return customLimit;
@@ -2676,11 +2676,11 @@ public sbyte GetCategoryLimit(ItemCategory category, bool ignoreArmor = false)
/// The number that will define the new limit.
public void SetCategoryLimit(ItemCategory category, sbyte limit)
{
- int index = InventorySystem.Configs.InventoryLimits.StandardCategoryLimits.Where(x => x.Value >= 0).OrderBy(x => x.Key).ToList().FindIndex(x => x.Key == category);
+ int index = (int)category;
- if (index == -1)
+ if (index < 0 || index >= ServerConfigSynchronizer.Singleton.CategoryLimits.Count)
{
- Log.Error($"{nameof(Player)}.{nameof(SetCategoryLimit)}(ItemCategory, sbyte): Cannot set category limit for ItemCategory.{category}.");
+ Log.Error($"{nameof(Player)}.{nameof(SetCategoryLimit)}(ItemCategory, sbyte): Cannot set category limit for ItemCategory.{category}. Index out of bounds.");
return;
}
@@ -2702,18 +2702,16 @@ public void SetCategoryLimit(ItemCategory category, sbyte limit)
/// The of the category to reset.
public void ResetCategoryLimit(ItemCategory category)
{
- int index = InventorySystem.Configs.InventoryLimits.StandardCategoryLimits.Where(x => x.Value >= 0).OrderBy(x => x.Key).ToList().FindIndex(x => x.Key == category);
+ int index = (int)category;
- if (index == -1)
+ if (index < 0 || index >= ServerConfigSynchronizer.Singleton.CategoryLimits.Count)
{
- Log.Error($"{nameof(Player)}.{nameof(ResetCategoryLimit)}(ItemCategory, sbyte): Cannot reset category limit for ItemCategory.{category}.");
+ Log.Error($"{nameof(Player)}.{nameof(ResetCategoryLimit)}(ItemCategory, sbyte): Cannot reset category limit for ItemCategory.{category}. Index out of bounds.");
return;
}
if (!HasCustomCategoryLimit(category))
- {
return;
- }
CustomCategoryLimits.Remove(category);
@@ -4051,4 +4049,4 @@ public override int GetHashCode()
/// A string containing Player-related data.
public override string ToString() => $"{Id} ({Nickname}) [{UserId}] *{(Role is null ? "No role" : Role)}*";
}
-}
+}
\ No newline at end of file
From ab1f433e26fd4c72f575b43764b1f30ac480c9b5 Mon Sep 17 00:00:00 2001
From: Unbistrackted <112902220+Unbistrackted@users.noreply.github.com>
Date: Mon, 23 Feb 2026 02:45:15 -0300
Subject: [PATCH 11/33] feat(warhead): Add ``IsOnCooldown`` property (#757)
---
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 3f1e12c516..d26cf33e84 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 bec65a3de67671d95cd586183112b3e8df9cfce2 Mon Sep 17 00:00:00 2001
From: Yamato <66829532+louis1706@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:16:46 +0100
Subject: [PATCH 12/33] AllowsScp106 setter use C# 14
---
EXILED/Exiled.API/Features/Doors/Door.cs | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Doors/Door.cs b/EXILED/Exiled.API/Features/Doors/Door.cs
index 81dcb427f4..e1e09eb659 100644
--- a/EXILED/Exiled.API/Features/Doors/Door.cs
+++ b/EXILED/Exiled.API/Features/Doors/Door.cs
@@ -203,11 +203,7 @@ public Vector3 Position
public bool AllowsScp106
{
get => Base is IScp106PassableDoor door && door.IsScp106Passable;
- set
- {
- if (Base is IScp106PassableDoor door)
- door.IsScp106Passable = value;
- }
+ set => (Base as IScp106PassableDoor)?.IsScp106Passable = value;
}
///
From 41e9132874354b3533c9cb6595f632d6a975df17 Mon Sep 17 00:00:00 2001
From: Yamato <66829532+louis1706@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:17:54 +0100
Subject: [PATCH 13/33] fix: `Door.AllowsScp106`
---
EXILED/Exiled.API/Features/Doors/Door.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Features/Doors/Door.cs b/EXILED/Exiled.API/Features/Doors/Door.cs
index e1e09eb659..09865ab206 100644
--- a/EXILED/Exiled.API/Features/Doors/Door.cs
+++ b/EXILED/Exiled.API/Features/Doors/Door.cs
@@ -202,7 +202,7 @@ public Vector3 Position
///
public bool AllowsScp106
{
- get => Base is IScp106PassableDoor door && door.IsScp106Passable;
+ get => Base is not IScp106PassableDoor door || door.IsScp106Passable;
set => (Base as IScp106PassableDoor)?.IsScp106Passable = value;
}
From 0b968a75633720f7d640c4ebbe4bd1deb65f7090 Mon Sep 17 00:00:00 2001
From: Banalny-Banan <133122450+Banalny-Banan@users.noreply.github.com>
Date: Mon, 16 Mar 2026 22:07:21 +0200
Subject: [PATCH 14/33] fix: AdminToy.Position & Rotation ignoring parenting
(#687)
fix AdminToy.Position & Rotation ignoring parenting
---
EXILED/Exiled.API/Features/Toys/AdminToy.cs | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Toys/AdminToy.cs b/EXILED/Exiled.API/Features/Toys/AdminToy.cs
index c93cee110e..15cbc0ab72 100644
--- a/EXILED/Exiled.API/Features/Toys/AdminToy.cs
+++ b/EXILED/Exiled.API/Features/Toys/AdminToy.cs
@@ -79,11 +79,11 @@ public Footprint Footprint
///
public Vector3 Position
{
- get => AdminToyBase.transform.position;
+ get => Transform.position;
set
{
- AdminToyBase.transform.position = value;
- AdminToyBase.NetworkPosition = value;
+ Transform.position = value;
+ AdminToyBase.NetworkPosition = Transform.localPosition;
}
}
@@ -92,11 +92,11 @@ public Vector3 Position
///
public Quaternion Rotation
{
- get => AdminToyBase.transform.rotation;
+ get => Transform.rotation;
set
{
- AdminToyBase.transform.rotation = value;
- AdminToyBase.NetworkRotation = value;
+ Transform.rotation = value;
+ AdminToyBase.NetworkRotation = Transform.localRotation;
}
}
@@ -105,10 +105,10 @@ public Quaternion Rotation
///
public Vector3 Scale
{
- get => AdminToyBase.transform.localScale;
+ get => Transform.localScale;
set
{
- AdminToyBase.transform.localScale = value;
+ Transform.localScale = value;
AdminToyBase.NetworkScale = value;
}
}
From 3eea13e49b1e97051cde8bf9efb9b54a23b5a220 Mon Sep 17 00:00:00 2001
From: Yamato <66829532+louis1706@users.noreply.github.com>
Date: Wed, 18 Mar 2026 19:38:47 +0100
Subject: [PATCH 15/33] nothing: Is this check better ? (#777)
is it better ?
---
EXILED/Exiled.API/Features/Doors/Door.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Features/Doors/Door.cs b/EXILED/Exiled.API/Features/Doors/Door.cs
index 09865ab206..960c9c48c0 100644
--- a/EXILED/Exiled.API/Features/Doors/Door.cs
+++ b/EXILED/Exiled.API/Features/Doors/Door.cs
@@ -638,7 +638,7 @@ private DoorType GetDoorType()
},
"Cargo Elevator Door" => DoorType.ElevatorServerRoom,
"Nuke Elevator Door" => DoorType.ElevatorNuke,
- "Elevator Door" or "Elevator Door 02" or "Elevator Door 01" => (Base as Interactables.Interobjects.ElevatorDoor)?.Group switch
+ not null when Base is Interactables.Interobjects.ElevatorDoor elevatorGroup => elevatorGroup?.Group switch
{
ElevatorGroup.Scp049 => DoorType.ElevatorScp049,
ElevatorGroup.GateB => DoorType.ElevatorGateB,
From d82093d3b80b1aa377fd8918163b385450377aab Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Wed, 18 Mar 2026 21:39:53 +0300
Subject: [PATCH 16/33] fix: Replace new Locker instance creation with Get
method (#779)
Replace new Locker instance creation with Get method
---
EXILED/Exiled.Events/Patches/Generic/LockerList.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/EXILED/Exiled.Events/Patches/Generic/LockerList.cs b/EXILED/Exiled.Events/Patches/Generic/LockerList.cs
index f76ac47c51..b0bd8af1de 100644
--- a/EXILED/Exiled.Events/Patches/Generic/LockerList.cs
+++ b/EXILED/Exiled.Events/Patches/Generic/LockerList.cs
@@ -40,7 +40,7 @@ private static IEnumerable Transpiler(IEnumerable Transpiler(IEnumerable.Pool.Return(newInstructions);
}
}
-}
\ No newline at end of file
+}
From 053e328c51c62666069b19e4adb1245b379c6527 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Wed, 18 Mar 2026 21:40:12 +0300
Subject: [PATCH 17/33] feat: Add New Gate Type & Fix Some door not Gate (#776)
* f'x
* Update DoorTypeExtensions.cs
* ordering
---
EXILED/Exiled.API/Enums/DoorType.cs | 5 +++++
EXILED/Exiled.API/Extensions/DoorTypeExtensions.cs | 2 +-
EXILED/Exiled.API/Features/Doors/Door.cs | 1 +
3 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Enums/DoorType.cs b/EXILED/Exiled.API/Enums/DoorType.cs
index 5863d6e79a..5e99c32a22 100644
--- a/EXILED/Exiled.API/Enums/DoorType.cs
+++ b/EXILED/Exiled.API/Enums/DoorType.cs
@@ -374,5 +374,10 @@ public enum DoorType
/// Represents the door in .
///
HczLoadingBay,
+
+ ///
+ /// Represents a spawnable unsecured gate.
+ ///
+ SpawnableUnsecuredGate,
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Extensions/DoorTypeExtensions.cs b/EXILED/Exiled.API/Extensions/DoorTypeExtensions.cs
index a4380d81cf..1124771480 100644
--- a/EXILED/Exiled.API/Extensions/DoorTypeExtensions.cs
+++ b/EXILED/Exiled.API/Extensions/DoorTypeExtensions.cs
@@ -20,7 +20,7 @@ public static class DoorTypeExtensions
/// The door to be checked.
/// Returns whether the is a gate.
public static bool IsGate(this DoorType door) => door is DoorType.GateA or DoorType.GateB or DoorType.Scp914Gate or
- DoorType.Scp049Gate or DoorType.GR18Gate or DoorType.SurfaceGate or DoorType.Scp173Gate;
+ DoorType.Scp049Gate or DoorType.GR18Gate or DoorType.SurfaceGate or DoorType.Scp173Gate or DoorType.Scp173NewGate or DoorType.CheckpointGateA or DoorType.CheckpointGateB or DoorType.SpawnableUnsecuredGate or DoorType.UnknownGate;
///
/// Checks if a door type is a checkpoint.
diff --git a/EXILED/Exiled.API/Features/Doors/Door.cs b/EXILED/Exiled.API/Features/Doors/Door.cs
index 960c9c48c0..0e27b6daae 100644
--- a/EXILED/Exiled.API/Features/Doors/Door.cs
+++ b/EXILED/Exiled.API/Features/Doors/Door.cs
@@ -647,6 +647,7 @@ private DoorType GetDoorType()
ElevatorGroup.LczB01 or ElevatorGroup.LczB02 => DoorType.ElevatorLczB,
_ => DoorType.UnknownElevator,
},
+ "Spawnable Unsecured Pryable GateDoor" => DoorType.SpawnableUnsecuredGate,
_ => DoorType.UnknownDoor,
};
}
From eca6029bd4834004bd59eb52ae0a52d70d5f4a47 Mon Sep 17 00:00:00 2001
From: Unbistrackted <112902220+Unbistrackted@users.noreply.github.com>
Date: Sat, 28 Mar 2026 18:03:11 -0300
Subject: [PATCH 18/33] feat(warhead)!: ``Warhead.RemainingCooldown`` and
``WarheadStatus.OnCooldown`` (#759)
* feat(warhead): Add ``IsOnCooldown`` property (#757)
* feat(warhead): Add WarheadStatus.OnCooldown
* feat(warhead): Add ``RemainingCooldown`` property
Change from ternary operators for readability
Add case for ``WarheadStatus.OnCooldown``
* fix(warhead): Use ``Warhead.RemainingCooldown`` instead
* feat(warhead): ``WarheadStatus`` as Flag
As per @louis1706 recommendation
* fix(warhead): Add the byte values cause my visual studio didn't saved
* fix(warhead): change status setter to use flags
* fix(warhead): add more logic to the nuke status setter
* fix: Build Error
---------
Co-authored-by: Yamato <66829532+louis1706@users.noreply.github.com>
---
EXILED/Exiled.API/Enums/WarheadStatus.cs | 16 ++++--
EXILED/Exiled.API/Features/Warhead.cs | 67 +++++++++++++++++-------
2 files changed, 59 insertions(+), 24 deletions(-)
diff --git a/EXILED/Exiled.API/Enums/WarheadStatus.cs b/EXILED/Exiled.API/Enums/WarheadStatus.cs
index e839c0b8dc..ed6f7d5a6d 100644
--- a/EXILED/Exiled.API/Enums/WarheadStatus.cs
+++ b/EXILED/Exiled.API/Enums/WarheadStatus.cs
@@ -7,30 +7,38 @@
namespace Exiled.API.Enums
{
+ using System;
+
///
/// All the available warhead statuses.
///
///
+ [Flags]
public enum WarheadStatus
{
///
/// The warhead is not armed.
///
- NotArmed,
+ NotArmed = 0,
///
/// The warhead is armed.
///
- Armed,
+ Armed = 1,
///
/// The warhead detonation is in progress.
///
- InProgress,
+ InProgress = 2,
///
/// The warhead has detonated.
///
- Detonated,
+ Detonated = 4,
+
+ ///
+ /// The warhead is on cooldown.
+ ///
+ OnCooldown = 8,
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Warhead.cs b/EXILED/Exiled.API/Features/Warhead.cs
index d26cf33e84..f765905410 100644
--- a/EXILED/Exiled.API/Features/Warhead.cs
+++ b/EXILED/Exiled.API/Features/Warhead.cs
@@ -7,12 +7,13 @@
namespace Exiled.API.Features
{
+ using System;
using System.Collections.Generic;
using Enums;
+ using Exiled.API.Extensions;
using Interactables.Interobjects.DoorUtils;
using Mirror;
-
using UnityEngine;
///
@@ -33,7 +34,7 @@ public static class Warhead
///
/// Gets the cached component.
///
- public static AlphaWarheadOutsitePanel OutsitePanel => field != null ? field : (field = Object.FindFirstObjectByType());
+ public static AlphaWarheadOutsitePanel OutsitePanel => field != null ? field : (field = UnityEngine.Object.FindFirstObjectByType());
///
/// Gets the of the warhead lever.
@@ -67,6 +68,15 @@ public static bool OpenDoors
set => Controller._openDoors = value;
}
+ ///
+ /// Gets or sets the remaining cooldown before the nuke can be triggered again.
+ ///
+ public static double RemainingCooldown
+ {
+ get => Math.Max(0, Controller.NetworkCooldownEndTime - NetworkTime.time);
+ set => Controller.NetworkCooldownEndTime = NetworkTime.time + Math.Max(0, value);
+ }
+
///
/// Gets all of the warhead blast doors.
///
@@ -95,25 +105,42 @@ public static bool IsKeycardActivated
///
public static WarheadStatus Status
{
- get => IsInProgress ? IsDetonated ? WarheadStatus.Detonated : WarheadStatus.InProgress : LeverStatus ? WarheadStatus.Armed : WarheadStatus.NotArmed;
+ get
+ {
+ WarheadStatus status = WarheadStatus.NotArmed;
+
+ if (IsDetonated)
+ status |= WarheadStatus.Detonated;
+
+ if (IsInProgress)
+ status |= WarheadStatus.InProgress;
+
+ if (IsOnCooldown)
+ status |= WarheadStatus.OnCooldown;
+
+ if (LeverStatus)
+ status |= WarheadStatus.Armed;
+
+ return status;
+ }
+
set
{
- switch (value)
- {
- case WarheadStatus.NotArmed:
- case WarheadStatus.Armed:
- Stop();
- LeverStatus = value is WarheadStatus.Armed;
- break;
-
- case WarheadStatus.InProgress:
- Start();
- break;
-
- case WarheadStatus.Detonated:
- Detonate();
- break;
- }
+ if (IsDetonated)
+ return;
+
+ LeverStatus = value.HasFlagFast(WarheadStatus.Armed);
+
+ if (!IsInProgress && value.HasFlagFast(WarheadStatus.InProgress))
+ Start();
+ else if (!value.HasFlagFast(WarheadStatus.InProgress))
+ Stop();
+
+ if (value.HasFlagFast(WarheadStatus.Detonated))
+ Detonate();
+
+ if (!IsOnCooldown && value.HasFlagFast(WarheadStatus.OnCooldown))
+ RemainingCooldown = Controller._cooldown;
}
}
@@ -130,7 +157,7 @@ public static WarheadStatus Status
///
/// Gets a value indicating whether the warhead detonation is on cooldown.
///
- public static bool IsOnCooldown => Controller.CooldownEndTime > NetworkTime.time;
+ public static bool IsOnCooldown => RemainingCooldown > 0;
///
/// Gets or sets the warhead detonation timer.
From 63c4f69dd90deba86a20039cce22aa76e50ab726 Mon Sep 17 00:00:00 2001
From: Yamato <66829532+louis1706@users.noreply.github.com>
Date: Sat, 28 Mar 2026 22:07:31 +0100
Subject: [PATCH 19/33] fix: PrefabHelper HczOneSided / HczTwoSided /
HczBreakableDoor (#767)
feat: PrefabHelper HczOneSided / HczTwoSided / HczBreakableDoor
---
EXILED/Exiled.API/Enums/PrefabType.cs | 1 +
EXILED/Exiled.API/Features/PrefabHelper.cs | 18 +++++++++++++++++-
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Enums/PrefabType.cs b/EXILED/Exiled.API/Enums/PrefabType.cs
index 43936f1d80..c3ce00898d 100644
--- a/EXILED/Exiled.API/Enums/PrefabType.cs
+++ b/EXILED/Exiled.API/Enums/PrefabType.cs
@@ -12,6 +12,7 @@ namespace Exiled.API.Enums
///
/// Type of prefab.
///
+ ///
public enum PrefabType
{
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
diff --git a/EXILED/Exiled.API/Features/PrefabHelper.cs b/EXILED/Exiled.API/Features/PrefabHelper.cs
index 179991e635..22579651ac 100644
--- a/EXILED/Exiled.API/Features/PrefabHelper.cs
+++ b/EXILED/Exiled.API/Features/PrefabHelper.cs
@@ -14,8 +14,8 @@ namespace Exiled.API.Features
using Exiled.API.Enums;
using Exiled.API.Features.Attributes;
-
using MapGeneration.Distributors;
+ using MapGeneration.RoomConnectors;
using Mirror;
using UnityEngine;
@@ -57,6 +57,11 @@ public static PrefabAttribute GetPrefabAttribute(this PrefabType prefabType)
/// Returns the .
public static GameObject GetPrefab(PrefabType prefabType)
{
+ if (prefabType is PrefabType.HCZOneSided or PrefabType.HCZTwoSided)
+ {
+ prefabType = PrefabType.HCZBreakableDoor;
+ }
+
if (Prefabs.TryGetValue(prefabType, out (GameObject, Component) prefab))
return prefab.Item1;
@@ -112,6 +117,17 @@ public static GameObject Spawn(PrefabType prefabType, Vector3 position = default
positionSync.Network_rotationY = (sbyte)Mathf.RoundToInt(rotation.Value.eulerAngles.y / 5.625F);
}
+ if (prefabType is PrefabType.HCZOneSided or PrefabType.HCZTwoSided or PrefabType.HCZBreakableDoor)
+ {
+ newGameObject.GetComponent().Network_syncBitmask = prefabType switch
+ {
+ PrefabType.HCZTwoSided => 0b00000000,
+ PrefabType.HCZOneSided => 0b00000001,
+ PrefabType.HCZBreakableDoor => 0b00000011,
+ _ => 0
+ };
+ }
+
NetworkServer.Spawn(newGameObject);
return newGameObject;
From 8f544a0f6e588ce045ece1c21f54d8e9ddc9d0bd Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Sun, 29 Mar 2026 00:10:07 +0300
Subject: [PATCH 20/33] fix: Item leaks (#763)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Fix major memory leaks in Items and optimize custom item spawning
* fix armors to
* more
* ,.
* f'x f'x
* ö
* ,
* someonefix
* fix someone
* someonesafe
* somefix
* Update EXILED/Exiled.CustomItems/API/Features/CustomWeapon.cs
Co-authored-by: Yamato <66829532+louis1706@users.noreply.github.com>
* Update EXILED/Exiled.API/Features/Player.cs
Yeah right
Co-authored-by: Yamato <66829532+louis1706@users.noreply.github.com>
* fix: naming broke because of Yamato Code Review
---------
Co-authored-by: Yamato <66829532+louis1706@users.noreply.github.com>
---
.../DamageHandlers/GenericDamageHandler.cs | 86 ++++++++++++-------
EXILED/Exiled.API/Features/Items/Item.cs | 14 ++-
EXILED/Exiled.API/Features/Player.cs | 15 +++-
.../API/Features/CustomArmor.cs | 2 +-
.../API/Features/CustomGrenade.cs | 36 +++++---
.../API/Features/CustomItem.cs | 3 +-
.../API/Features/CustomWeapon.cs | 23 +++--
.../Patches/Generic/StaminaRegenArmor.cs | 7 +-
8 files changed, 132 insertions(+), 54 deletions(-)
diff --git a/EXILED/Exiled.API/Features/DamageHandlers/GenericDamageHandler.cs b/EXILED/Exiled.API/Features/DamageHandlers/GenericDamageHandler.cs
index d55324798e..4ea1909b72 100644
--- a/EXILED/Exiled.API/Features/DamageHandlers/GenericDamageHandler.cs
+++ b/EXILED/Exiled.API/Features/DamageHandlers/GenericDamageHandler.cs
@@ -10,19 +10,33 @@ namespace Exiled.API.Features.DamageHandlers
using System;
using Enums;
+
+ using Exiled.API.Extensions;
using Exiled.API.Features.Pickups.Projectiles;
using Footprinting;
+
+ using InventorySystem;
+ using InventorySystem.Items;
+ using InventorySystem.Items.Firearms;
+ using InventorySystem.Items.Firearms.Modules;
+ using InventorySystem.Items.Firearms.ShotEvents;
using InventorySystem.Items.Scp1509;
+
using Items;
+
using PlayerRoles;
using PlayerRoles.PlayableScps.Scp096;
using PlayerRoles.PlayableScps.Scp1507;
using PlayerRoles.PlayableScps.Scp3114;
using PlayerRoles.PlayableScps.Scp939;
+
using PlayerStatsSystem;
+
using UnityEngine;
+ using Object = UnityEngine.Object;
+
///
/// Allows generic damage to a player.
///
@@ -59,7 +73,7 @@ public GenericDamageHandler(Player player, Player attacker, float damage, Damage
if (customCassieAnnouncement is not null)
customCassieAnnouncement.Announcement ??= $"{player.Nickname} killed by {attacker.Nickname} utilizing {damageType}";
- Attacker = attacker.Footprint;
+ Attacker = attacker != null ? attacker.Footprint : Server.Host.Footprint;
AllowSelfDamage = true;
Damage = damage;
ServerLogsText = $"GenericDamageHandler damage processing";
@@ -123,55 +137,57 @@ public GenericDamageHandler(Player player, Player attacker, float damage, Damage
Base = new GrayCandyDamageHandler(Attacker.Hub, damage);
break;
case DamageType.MicroHid:
- InventorySystem.Items.MicroHID.MicroHIDItem microHidOwner = new();
- microHidOwner.Owner = attacker.ReferenceHub;
+ InventorySystem.Items.MicroHID.MicroHIDItem microHidOwner = new()
+ {
+ Owner = attacker.ReferenceHub,
+ };
Base = new MicroHidDamageHandler(damage, microHidOwner);
break;
case DamageType.Explosion:
- Base = new ExplosionDamageHandler(attacker.Footprint, UnityEngine.Vector3.zero, damage, 0, ExplosionType.Grenade);
+ Base = new ExplosionDamageHandler(attacker.Footprint, Vector3.zero, damage, 0, ExplosionType.Grenade);
break;
case DamageType.Firearm:
case DamageType.AK:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunAK);
+ GenericFirearm(damage, ItemType.GunAK);
break;
case DamageType.Crossvec:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunCrossvec);
+ GenericFirearm(damage, ItemType.GunCrossvec);
break;
case DamageType.Logicer:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunLogicer);
+ GenericFirearm(damage, ItemType.GunLogicer);
break;
case DamageType.Revolver:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunRevolver);
+ GenericFirearm(damage, ItemType.GunRevolver);
break;
case DamageType.Shotgun:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunShotgun);
+ GenericFirearm(damage, ItemType.GunShotgun);
break;
case DamageType.Com15:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunCOM15);
+ GenericFirearm(damage, ItemType.GunCOM15);
break;
case DamageType.Com18:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunCOM18);
+ GenericFirearm(damage, ItemType.GunCOM18);
break;
case DamageType.Fsp9:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunFSP9);
+ GenericFirearm(damage, ItemType.GunFSP9);
break;
case DamageType.E11Sr:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunE11SR);
+ GenericFirearm(damage, ItemType.GunE11SR);
break;
case DamageType.Com45:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunCom45);
+ GenericFirearm(damage, ItemType.GunCom45);
break;
case DamageType.Frmg0:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunFRMG0);
+ GenericFirearm(damage, ItemType.GunFRMG0);
break;
case DamageType.A7:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunA7);
+ GenericFirearm(damage, ItemType.GunA7);
break;
case DamageType.Scp127:
- GenericFirearm(player, attacker, damage, damageType, ItemType.GunSCP127);
+ GenericFirearm(damage, ItemType.GunSCP127);
break;
case DamageType.ParticleDisruptor:
- Base = new DisruptorDamageHandler(new (Item.Create(ItemType.ParticleDisruptor, attacker).Base as InventorySystem.Items.Firearms.Firearm, InventorySystem.Items.Firearms.Modules.DisruptorActionModule.FiringState.FiringSingle), Vector3.up, damage);
+ Base = new DisruptorDamageHandler(new DisruptorShotEvent(default, Attacker, InventorySystem.Items.Firearms.Modules.DisruptorActionModule.FiringState.FiringSingle), Vector3.up, damage);
break;
case DamageType.Scp096:
Scp096Role curr096 = attacker.ReferenceHub.roleManager.CurrentRole as Scp096Role ?? new Scp096Role();
@@ -193,9 +209,12 @@ public GenericDamageHandler(Player player, Player attacker, float damage, Damage
Base = new PlayerStatsSystem.ScpDamageHandler(attacker.ReferenceHub, damage, DeathTranslations.Unknown);
break;
case DamageType.Scp018:
- Scp018Projectile scp018Projectile = Projectile.Create(ProjectileType.Scp018);
- scp018Projectile.PreviousOwner = attacker;
- Base = new Scp018DamageHandler(scp018Projectile.Base, damage, true);
+ InventorySystem.Items.ThrowableProjectiles.Scp018Projectile dummy018 = new()
+ {
+ PreviousOwner = Attacker,
+ };
+
+ Base = new Scp018DamageHandler(dummy018, damage, true);
break;
case DamageType.Scp207:
Base = new PlayerStatsSystem.ScpDamageHandler(attacker.ReferenceHub, damage, DeathTranslations.Scp207);
@@ -303,21 +322,26 @@ public override HandlerOutput ApplyDamage(ReferenceHub ply)
///
/// Generic firearm path for handle type.
///
- /// Current player.
- /// Current attacker.
/// Damage amount.
- /// Damage type.
/// ItemType.
- private void GenericFirearm(Player player, Player attacker, float amount, DamageType damageType, ItemType itemType)
+ private void GenericFirearm(float amount, ItemType itemType)
{
- Firearm firearm = new(itemType)
+ ItemType ammoType = ItemType.None;
+
+ if (InventoryItemLoader.TryGetItem(itemType, out InventorySystem.Items.Firearms.Firearm firearmTemplate))
{
- Base =
- {
- Owner = attacker.ReferenceHub,
- },
+ Items.Firearm firearm = new(firearmTemplate);
+ ammoType = firearm.AmmoType.GetItemType();
+ }
+
+ Base = new PlayerStatsSystem.FirearmDamageHandler
+ {
+ Damage = amount,
+ Attacker = Attacker,
+ AmmoType = ammoType,
+ WeaponType = itemType,
+ Firearm = firearmTemplate,
};
- Base = new PlayerStatsSystem.FirearmDamageHandler() { Firearm = firearm.Base, Damage = amount };
}
}
}
diff --git a/EXILED/Exiled.API/Features/Items/Item.cs b/EXILED/Exiled.API/Features/Items/Item.cs
index 89bb189c42..6e9a20d66f 100644
--- a/EXILED/Exiled.API/Features/Items/Item.cs
+++ b/EXILED/Exiled.API/Features/Items/Item.cs
@@ -414,7 +414,19 @@ public static T Create(ItemType type, Player owner = null)
///
/// Destroy this item.
///
- public void Destroy() => Owner.RemoveItem(this);
+ public void Destroy()
+ {
+ if (Owner.RemoveItem(this))
+ return;
+
+ if (Base != null)
+ {
+ BaseToItem.Remove(Base);
+
+ if (Base.gameObject != null)
+ Object.Destroy(Base.gameObject);
+ }
+ }
///
/// Creates the that based on this .
diff --git a/EXILED/Exiled.API/Features/Player.cs b/EXILED/Exiled.API/Features/Player.cs
index 6f4f500e65..88c0693232 100644
--- a/EXILED/Exiled.API/Features/Player.cs
+++ b/EXILED/Exiled.API/Features/Player.cs
@@ -2324,7 +2324,17 @@ public void Heal(float amount, bool overrideMaxHealth = false)
///
/// The ItemType to be used.
/// if item was used successfully. Otherwise, .
- public bool UseItem(ItemType usableItem) => UseItem(Item.Create(usableItem));
+ public bool UseItem(ItemType usableItem)
+ {
+ if (usableItem.GetTemplate() is not UsableItem)
+ return false;
+
+ Item usable = Item.Create(usableItem);
+
+ UseItem(usable);
+ usable.Destroy();
+ return true;
+ }
///
/// Forces the player to use an item.
@@ -2388,7 +2398,8 @@ public void Vaporize(Player attacker = null, string cassieAnnouncement = "")
if ((Role.Side != Side.Scp) && !string.IsNullOrEmpty(cassieAnnouncement))
Cassie.Message(cassieAnnouncement);
- Kill(new DisruptorDamageHandler(new DisruptorShotEvent(Item.Create(ItemType.ParticleDisruptor, attacker).Base as InventorySystem.Items.Firearms.Firearm, DisruptorActionModule.FiringState.FiringSingle), Vector3.up, -1));
+ Footprint footprint = attacker != null ? attacker.Footprint : Server.Host.Footprint;
+ Kill(new DisruptorDamageHandler(new DisruptorShotEvent(default, footprint, DisruptorActionModule.FiringState.FiringSingle), Vector3.up, -1));
}
///
diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomArmor.cs b/EXILED/Exiled.CustomItems/API/Features/CustomArmor.cs
index e6f1932905..c1a77f6351 100644
--- a/EXILED/Exiled.CustomItems/API/Features/CustomArmor.cs
+++ b/EXILED/Exiled.CustomItems/API/Features/CustomArmor.cs
@@ -82,7 +82,7 @@ public override void Give(Player player, bool displayMessage = true)
if (AmmoLimits.Count != 0)
armor.AmmoLimits = AmmoLimits;
- if (AmmoLimits.Count != 0)
+ if (CategoryLimits.Count != 0)
armor.CategoryLimits = CategoryLimits;
player.AddItem(armor);
diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomGrenade.cs b/EXILED/Exiled.CustomItems/API/Features/CustomGrenade.cs
index 48d725ce7e..feafdae9c2 100644
--- a/EXILED/Exiled.CustomItems/API/Features/CustomGrenade.cs
+++ b/EXILED/Exiled.CustomItems/API/Features/CustomGrenade.cs
@@ -19,10 +19,14 @@ namespace Exiled.CustomItems.API.Features
using Exiled.Events.EventArgs.Player;
using Footprinting;
+
+ using InventorySystem;
using InventorySystem.Items;
using InventorySystem.Items.Pickups;
using InventorySystem.Items.ThrowableProjectiles;
+
using Mirror;
+
using UnityEngine;
using Object = UnityEngine.Object;
@@ -70,31 +74,43 @@ public override ItemType Type
/// The spawned.
public virtual Pickup Throw(Vector3 position, float force, float weight, float fuseTime = 3f, ItemType grenadeType = ItemType.GrenadeHE, Player? player = null)
{
- if (player is null)
- player = Server.Host;
+ player ??= Server.Host;
- player.Role.Is(out FpcRole fpcRole);
- Vector3 velocity = fpcRole.FirstPersonController.FpcModule.Motor.Velocity;
+ Vector3 velocity = Vector3.zero;
+ Quaternion rotation = Quaternion.identity;
- Throwable throwable = (Throwable)Item.Create(grenadeType, player);
+ if (player != Server.Host)
+ {
+ if (player.Role.Is(out FpcRole fpcRole))
+ velocity = fpcRole.FirstPersonController.FpcModule.Motor.Velocity;
+
+ if (player.CameraTransform != null)
+ rotation = player.CameraTransform.rotation;
+ }
- ThrownProjectile thrownProjectile = Object.Instantiate(throwable.Base.Projectile, position, throwable.Owner.CameraTransform.rotation);
+ InventoryItemLoader.TryGetItem(grenadeType, out ThrowableItem template);
+
+ ThrownProjectile thrownProjectile = Object.Instantiate(template.Projectile, position, rotation);
PickupSyncInfo newInfo = new()
{
- ItemId = throwable.Type,
- Locked = !throwable.Base._repickupable,
+ ItemId = grenadeType,
+ Locked = !template._repickupable,
Serial = ItemSerialGenerator.GenerateNext(),
WeightKg = weight,
};
+
if (thrownProjectile is TimeGrenade time)
time._fuseTime = fuseTime;
+
thrownProjectile.NetworkInfo = newInfo;
- thrownProjectile.PreviousOwner = new Footprint(throwable.Owner.ReferenceHub);
+ thrownProjectile.PreviousOwner = player.Footprint;
NetworkServer.Spawn(thrownProjectile.gameObject);
thrownProjectile.InfoReceivedHook(default, newInfo);
+
if (thrownProjectile.TryGetComponent(out Rigidbody component))
- throwable.Base.PropelBody(component, throwable.Base.FullThrowSettings.StartTorque, ThrowableNetworkHandler.GetLimitedVelocity(velocity));
+ template.PropelBody(component, template.FullThrowSettings.StartTorque, ThrowableNetworkHandler.GetLimitedVelocity(velocity));
+
thrownProjectile.ServerActivate();
return Pickup.Get(thrownProjectile);
diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomItem.cs b/EXILED/Exiled.CustomItems/API/Features/CustomItem.cs
index 789fd25ceb..e131db7086 100644
--- a/EXILED/Exiled.CustomItems/API/Features/CustomItem.cs
+++ b/EXILED/Exiled.CustomItems/API/Features/CustomItem.cs
@@ -540,7 +540,7 @@ public static IEnumerable UnregisterItems(IEnumerable targetTy
Pickup? pickup = Spawn(position, item, previousOwner);
- UnityEngine.Object.Destroy(item.Base);
+ item.Destroy();
return pickup;
}
@@ -554,6 +554,7 @@ public static IEnumerable UnregisterItems(IEnumerable targetTy
public virtual Pickup? Spawn(Vector3 position, Item item, Player? previousOwner = null)
{
Pickup? pickup = item.CreatePickup(position);
+
pickup.Scale = Scale;
pickup.Weight = Weight;
diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomWeapon.cs b/EXILED/Exiled.CustomItems/API/Features/CustomWeapon.cs
index 9053cf2d66..cc2e7f25bc 100644
--- a/EXILED/Exiled.CustomItems/API/Features/CustomWeapon.cs
+++ b/EXILED/Exiled.CustomItems/API/Features/CustomWeapon.cs
@@ -18,9 +18,11 @@ namespace Exiled.CustomItems.API.Features
using Exiled.API.Features.Pickups;
using Exiled.Events.EventArgs.Item;
using Exiled.Events.EventArgs.Player;
+
using InventorySystem.Items.Firearms.Attachments;
using InventorySystem.Items.Firearms.Attachments.Components;
using InventorySystem.Items.Firearms.Modules;
+
using UnityEngine;
using Firearm = Exiled.API.Features.Items.Firearm;
@@ -69,16 +71,22 @@ public override ItemType Type
///
public override Pickup? Spawn(Vector3 position, Player? previousOwner = null)
{
- if (Item.Create(Type) is not Firearm firearm)
+ if (Type.IsWeapon(false))
{
- Log.Debug($"{nameof(Spawn)}: Item is not Firearm.");
+ Log.Warn($"{nameof(Spawn)}: Item is not Firearm.");
return null;
}
+ Firearm firearm = Item.Create(Type);
+
if (!Attachments.IsEmpty())
firearm.AddAttachment(Attachments);
- Pickup? pickup = firearm.CreatePickup(position);
+ if (ClipSize > 0)
+ firearm.MagazineAmmo = ClipSize;
+
+ FirearmPickup? pickup = (FirearmPickup?)firearm.CreatePickup(position, spawn: false);
+ firearm.Destroy();
if (pickup is null)
{
@@ -86,15 +94,16 @@ public override ItemType Type
return null;
}
- if (ClipSize > 0)
- firearm.MagazineAmmo = ClipSize;
-
pickup.Weight = Weight;
pickup.Scale = Scale;
+
if (previousOwner is not null)
pickup.PreviousOwner = previousOwner;
+ pickup.Spawn();
+
TrackedSerials.Add(pickup.Serial);
+
return pickup;
}
@@ -108,9 +117,11 @@ public override ItemType Type
if (ClipSize > 0)
firearm.MagazineAmmo = ClipSize;
+
int ammo = firearm.MagazineAmmo;
Log.Debug($"{nameof(Name)}.{nameof(Spawn)}: Spawning weapon with {ammo} ammo.");
Pickup? pickup = firearm.CreatePickup(position);
+
pickup.Scale = Scale;
if (previousOwner is not null)
diff --git a/EXILED/Exiled.Events/Patches/Generic/StaminaRegenArmor.cs b/EXILED/Exiled.Events/Patches/Generic/StaminaRegenArmor.cs
index 96441e17e9..38ad7536e2 100644
--- a/EXILED/Exiled.Events/Patches/Generic/StaminaRegenArmor.cs
+++ b/EXILED/Exiled.Events/Patches/Generic/StaminaRegenArmor.cs
@@ -16,14 +16,17 @@ namespace Exiled.Events.Patches.Generic
///
/// Patches .
- /// Implements .
+ /// Implements .
///
[HarmonyPatch(typeof(BodyArmor), nameof(BodyArmor.StaminaRegenMultiplier), MethodType.Getter)]
internal class StaminaRegenArmor
{
private static void Postfix(BodyArmor __instance, ref float __result)
{
- if(Item.Get(__instance) is Armor armor)
+ if (__instance.ItemSerial == 0)
+ return;
+
+ if (Item.Get(__instance) is Armor armor)
__result *= armor.StaminaRegenMultiplier;
}
}
From 1cf9683468ab3a0cd2626d30eac9a146a7a57255 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Sun, 29 Mar 2026 05:27:10 +0300
Subject: [PATCH 21/33] fix: SavingByAntiScp207 refactor the current game code
& event not saving (#771)
* fix & refactor
* removed denied damage multiplayer prop
* hj
* cmmon get fixed git
---
.../Player/SavingByAntiScp207EventArgs.cs | 13 +++--------
.../Events/Player/SavingByAntiScp207.cs | 22 +++++++++----------
2 files changed, 14 insertions(+), 21 deletions(-)
diff --git a/EXILED/Exiled.Events/EventArgs/Player/SavingByAntiScp207EventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/SavingByAntiScp207EventArgs.cs
index 71cd25905b..47b568d667 100644
--- a/EXILED/Exiled.Events/EventArgs/Player/SavingByAntiScp207EventArgs.cs
+++ b/EXILED/Exiled.Events/EventArgs/Player/SavingByAntiScp207EventArgs.cs
@@ -31,8 +31,6 @@ public SavingByAntiScp207EventArgs(ReferenceHub player, float damageAmount, Dama
Handler = handler;
HitboxType = hitboxType;
DamageAmount = damageAmount;
- DamageMultiplier = (Player.Health + Player.ArtificialHealth - AntiScp207.DeathSaveHealth) / damageAmount;
- IsAllowed = true;
}
///
@@ -46,14 +44,9 @@ public SavingByAntiScp207EventArgs(ReferenceHub player, float damageAmount, Dama
public float DamageAmount { get; }
///
- /// Gets or sets the multiplier for the damage that is applied when the event is allowed.
+ /// Gets or sets the health amount the player will have after being saved from death.
///
- public float DamageMultiplier { get; set; }
-
- ///
- /// Gets or sets the multiplier for the damage that if event denied.
- ///
- public float DeniedDamageMultiplier { get; set; } = 1;
+ public float DeathSaveHealth { get; set; } = AntiScp207.DeathSaveHealth;
///
/// Gets the damage handler that describes the incoming damage.
@@ -69,6 +62,6 @@ public SavingByAntiScp207EventArgs(ReferenceHub player, float damageAmount, Dama
/// Gets or sets a value indicating whether the event is allowed.
/// If set to false, the event will be denied.
///
- public bool IsAllowed { get; set; }
+ public bool IsAllowed { get; set; } = true;
}
}
diff --git a/EXILED/Exiled.Events/Patches/Events/Player/SavingByAntiScp207.cs b/EXILED/Exiled.Events/Patches/Events/Player/SavingByAntiScp207.cs
index ac4157b7ce..daa9f0c6fb 100644
--- a/EXILED/Exiled.Events/Patches/Events/Player/SavingByAntiScp207.cs
+++ b/EXILED/Exiled.Events/Patches/Events/Player/SavingByAntiScp207.cs
@@ -31,19 +31,19 @@ private static IEnumerable Transpiler(IEnumerable newInstructions = ListPool.Pool.Get(instructions);
+ Label skipLabel = generator.DefineLabel();
LocalBuilder ev = generator.DeclareLocal(typeof(SavingByAntiScp207EventArgs));
int index = newInstructions.FindLastIndex(x => x.opcode == OpCodes.Ldloc_1);
- Label skipLabel = generator.DefineLabel();
- Label gotoEventLabel = newInstructions[index].labels[0];
+ List
public static void OnWaitingForPlayers() => WaitingForPlayers.InvokeSafely();
+ ///
+ /// Called before the start of a new round.
+ ///
+ /// The instance.
+ public static void OnRoundStarting(RoundStartingEventArgs ev) => RoundStarting.InvokeSafely(ev);
+
///
/// Called after the start of a new round.
///
diff --git a/EXILED/Exiled.Events/Patches/Events/Server/RoundStarting.cs b/EXILED/Exiled.Events/Patches/Events/Server/RoundStarting.cs
new file mode 100644
index 0000000000..ec5e68b953
--- /dev/null
+++ b/EXILED/Exiled.Events/Patches/Events/Server/RoundStarting.cs
@@ -0,0 +1,118 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.Events.Patches.Events.Server
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Reflection;
+ using System.Reflection.Emit;
+
+ using Exiled.API.Features;
+ using Exiled.API.Features.Pools;
+ using Exiled.Events.EventArgs.Server;
+ using HarmonyLib;
+
+ using static HarmonyLib.AccessTools;
+
+ ///
+ /// Patches .
+ /// Adds the event.
+ ///
+ [HarmonyPatch]
+ internal class RoundStarting
+ {
+ #pragma warning disable SA1600 // Elements should be documented
+ public static Type PrivateType { get; internal set; }
+
+ private static MethodInfo TargetMethod()
+ {
+ PrivateType = typeof(CharacterClassManager).GetNestedTypes(all)
+ .FirstOrDefault(currentType => currentType.Name.Contains("Init"));
+ if (PrivateType == null)
+ throw new Exception("State machine type for Init not found.");
+ MethodInfo moveNextMethod = PrivateType.GetMethod("MoveNext", all);
+
+ if (moveNextMethod == null)
+ throw new Exception("MoveNext method not found in the state machine type.");
+ return moveNextMethod;
+ }
+
+ private static IEnumerable Transpiler(IEnumerable instructions, ILGenerator generator)
+ {
+ List newInstructions = ListPool.Pool.Get(instructions);
+
+ const string TimeLeft = "5__3";
+ const string OriginalTimeLeft = "5__2";
+ const string TopPlayer = "5__4";
+
+ LocalBuilder ev = generator.DeclareLocal(typeof(RoundStartingEventArgs));
+ int offset = -4;
+ int index = newInstructions.FindLastIndex(x => x.Calls(Method(typeof(CharacterClassManager), nameof(CharacterClassManager.ForceRoundStart)))) + offset;
+
+ List labels = newInstructions[index].ExtractLabels();
+ Label skip = (Label)newInstructions[index + 3].operand;
+ newInstructions.RemoveRange(index, 4);
+
+ newInstructions.InsertRange(index, new[]
+ {
+ // this.TimeLeft
+ new CodeInstruction(OpCodes.Ldarg_0).WithLabels(labels),
+ new(OpCodes.Ldfld, Field(PrivateType, TimeLeft)),
+
+ // this.OriginalTimeLeft
+ new(OpCodes.Ldarg_0),
+ new(OpCodes.Ldfld, Field(PrivateType, OriginalTimeLeft)),
+
+ // this.TopPlayer
+ new(OpCodes.Ldarg_0),
+ new(OpCodes.Ldfld, Field(PrivateType, TopPlayer)),
+
+ // playerCount
+ new(OpCodes.Ldloc_2),
+
+ // RoundStartingEventArgs ev = new(short, short, int, int)
+ new(OpCodes.Newobj, GetDeclaredConstructors(typeof(RoundStartingEventArgs))[0]),
+ new(OpCodes.Dup),
+ new(OpCodes.Stloc_S, ev.LocalIndex),
+
+ // Handlers.Server.OnRoundStarting(ev)
+ new(OpCodes.Call, Method(typeof(Handlers.Server), nameof(Handlers.Server.OnRoundStarting))),
+
+ // this.TimeLeft = ev.TimeLeft
+ new(OpCodes.Ldarg_0),
+ new(OpCodes.Ldloc_S, ev.LocalIndex),
+ new(OpCodes.Callvirt, PropertyGetter(typeof(RoundStartingEventArgs), nameof(RoundStartingEventArgs.TimeLeft))),
+ new(OpCodes.Stfld, Field(PrivateType, TimeLeft)),
+
+ // this.OriginalTimeLeft = ev.OriginalTimeLeft
+ new(OpCodes.Ldarg_0),
+ new(OpCodes.Ldloc_S, ev.LocalIndex),
+ new(OpCodes.Callvirt, PropertyGetter(typeof(RoundStartingEventArgs), nameof(RoundStartingEventArgs.OriginalTimeLeft))),
+ new(OpCodes.Stfld, Field(PrivateType, OriginalTimeLeft)),
+
+ // this.TopPlayer = ev.TopPlayer
+ new(OpCodes.Ldarg_0),
+ new(OpCodes.Ldloc_S, ev.LocalIndex),
+ new(OpCodes.Callvirt, PropertyGetter(typeof(RoundStartingEventArgs), nameof(RoundStartingEventArgs.TopPlayer))),
+ new(OpCodes.Stfld, Field(PrivateType, TopPlayer)),
+
+ // if (!ev.IsAllowed)
+ // skip;
+ new(OpCodes.Ldloc_S, ev.LocalIndex),
+ new(OpCodes.Callvirt, PropertyGetter(typeof(RoundStartingEventArgs), nameof(RoundStartingEventArgs.IsAllowed))),
+ new(OpCodes.Brfalse_S, skip),
+ });
+
+ for (int z = 0; z < newInstructions.Count; z++)
+ yield return newInstructions[z];
+
+ ListPool.Pool.Return(newInstructions);
+ }
+ }
+ }
From b7698cda033f2a5d330f4986b5e21f4a336afd60 Mon Sep 17 00:00:00 2001
From: Yamato <66829532+louis1706@users.noreply.github.com>
Date: Wed, 1 Apr 2026 22:01:11 +0200
Subject: [PATCH 29/33] fix: nw bug 1560 and 1816 (#774)
* fix: https://git.scpslgame.com/northwood-qa/scpsl-bug-reporting/-/issues/1560
* fix: SpawnProtect
* Fix 1560
* update Using
* fix: build error of using
* fix: 2816
* Revert "fix: SpawnProtect"
This reverts commit d938486db635cc57cd9cc2979258a6c43e2524c2.
* removed fix by NW
* fix: this wasn't fixed
.
---
EXILED/Exiled.API/Features/Pickups/JailbirdPickup.cs | 3 +++
EXILED/Exiled.Events/Handlers/Internal/Round.cs | 10 ++++++----
.../Exiled.Events/Patches/Fixes/Fix106ItemManager.cs | 2 +-
3 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Pickups/JailbirdPickup.cs b/EXILED/Exiled.API/Features/Pickups/JailbirdPickup.cs
index eaaf77cb7a..93c18ff7bf 100644
--- a/EXILED/Exiled.API/Features/Pickups/JailbirdPickup.cs
+++ b/EXILED/Exiled.API/Features/Pickups/JailbirdPickup.cs
@@ -109,6 +109,9 @@ internal override void ReadItemInfo(Item item)
if (item is Jailbird jailBirditem)
{
+ // TODO: Remove if this is fixed https://git.scpslgame.com/northwood-qa/scpsl-bug-reporting/-/issues/2816
+ jailBirditem.Base._deterioration.RecheckUsage();
+
MeleeDamage = jailBirditem.MeleeDamage;
ChargeDamage = jailBirditem.ChargeDamage;
FlashDuration = jailBirditem.FlashDuration;
diff --git a/EXILED/Exiled.Events/Handlers/Internal/Round.cs b/EXILED/Exiled.Events/Handlers/Internal/Round.cs
index 4ec235db18..ac058b4629 100644
--- a/EXILED/Exiled.Events/Handlers/Internal/Round.cs
+++ b/EXILED/Exiled.Events/Handlers/Internal/Round.cs
@@ -15,6 +15,7 @@ namespace Exiled.Events.Handlers.Internal
using Exiled.API.Extensions;
using Exiled.API.Features;
using Exiled.API.Features.Core.UserSettings;
+ using Exiled.API.Features.Doors;
using Exiled.API.Features.Items;
using Exiled.API.Features.Pools;
using Exiled.API.Features.Roles;
@@ -23,17 +24,14 @@ namespace Exiled.Events.Handlers.Internal
using Exiled.Events.EventArgs.Scp049;
using Exiled.Loader;
using Exiled.Loader.Features;
+ using Interactables.Interobjects.DoorUtils;
using InventorySystem;
using InventorySystem.Items.Firearms.Attachments;
using InventorySystem.Items.Firearms.Attachments.Components;
using InventorySystem.Items.Usables;
- using InventorySystem.Items.Usables.Scp244.Hypothermia;
using InventorySystem.Items.Usables.Scp330;
using PlayerRoles;
- using PlayerRoles.FirstPersonControl;
using PlayerRoles.RoleAssign;
- using UnityEngine;
- using Utils.Networking;
using Utils.NonAllocLINQ;
///
@@ -60,6 +58,10 @@ public static void OnWaitingForPlayers()
if (Events.Instance.Config.Debug)
Patches.Events.Map.Generating.Benchmark();
+
+ // TODO: Remove when this has been fixed https://git.scpslgame.com/northwood-qa/scpsl-bug-reporting/-/issues/1560
+ Door door = Door.Get(DoorType.Scp079Armory);
+ door.AllowsScp106 = false;
}
///
diff --git a/EXILED/Exiled.Events/Patches/Fixes/Fix106ItemManager.cs b/EXILED/Exiled.Events/Patches/Fixes/Fix106ItemManager.cs
index c082edbedb..53ed989ed9 100644
--- a/EXILED/Exiled.Events/Patches/Fixes/Fix106ItemManager.cs
+++ b/EXILED/Exiled.Events/Patches/Fixes/Fix106ItemManager.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (c) ExMod Team. All rights reserved.
// Licensed under the CC BY-SA 3.0 license.
From a1f8bf27ed870b23db56b283d5a1d43dcb67851c Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Tue, 14 Apr 2026 16:23:05 +0300
Subject: [PATCH 30/33] feat: Consuming Item Event (#805)
* one shot
* fix doc
* d
---
.../EventArgs/Interfaces/IConsumableEvent.cs | 22 ++++++
.../Player/ConsumingItemEventArgs.cs | 49 ++++++++++++
EXILED/Exiled.Events/Handlers/Player.cs | 11 +++
.../Patches/Events/Player/ConsumingItem.cs | 78 +++++++++++++++++++
4 files changed, 160 insertions(+)
create mode 100644 EXILED/Exiled.Events/EventArgs/Interfaces/IConsumableEvent.cs
create mode 100644 EXILED/Exiled.Events/EventArgs/Player/ConsumingItemEventArgs.cs
create mode 100644 EXILED/Exiled.Events/Patches/Events/Player/ConsumingItem.cs
diff --git a/EXILED/Exiled.Events/EventArgs/Interfaces/IConsumableEvent.cs b/EXILED/Exiled.Events/EventArgs/Interfaces/IConsumableEvent.cs
new file mode 100644
index 0000000000..eaa1babdcc
--- /dev/null
+++ b/EXILED/Exiled.Events/EventArgs/Interfaces/IConsumableEvent.cs
@@ -0,0 +1,22 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.Events.EventArgs.Interfaces
+{
+ using Exiled.API.Features.Items;
+
+ ///
+ /// Event args used for all related events.
+ ///
+ public interface IConsumableEvent : IItemEvent
+ {
+ ///
+ /// Gets the triggering the event.
+ ///
+ public Consumable Consumable { get; }
+ }
+}
diff --git a/EXILED/Exiled.Events/EventArgs/Player/ConsumingItemEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/ConsumingItemEventArgs.cs
new file mode 100644
index 0000000000..0e48f83a8b
--- /dev/null
+++ b/EXILED/Exiled.Events/EventArgs/Player/ConsumingItemEventArgs.cs
@@ -0,0 +1,49 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.Events.EventArgs.Player
+{
+ using API.Features;
+ using API.Features.Items;
+
+ using Exiled.Events.EventArgs.Interfaces;
+
+ ///
+ /// Contains all information before a player's consumable item effects are applied.
+ ///
+ public class ConsumingItemEventArgs : IPlayerEvent, IDeniableEvent, IConsumableEvent
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The player who is consuming the item.
+ /// The consumable item to be consumed.
+ public ConsumingItemEventArgs(ReferenceHub hub, InventorySystem.Items.Usables.Consumable item)
+ {
+ Player = Player.Get(hub);
+ Consumable = Item.Get(item) as Consumable;
+ }
+
+ ///
+ /// Gets the consumable item to be consumed.
+ ///
+ public Consumable Consumable { get; }
+
+ ///
+ public Item Item => Consumable;
+
+ ///
+ /// Gets the player who is consuming the item.
+ ///
+ public Player Player { get; }
+
+ ///
+ /// Gets or sets a value indicating whether the item's being consumed should be allowed or not.
+ ///
+ public bool IsAllowed { get; set; } = true;
+ }
+}
diff --git a/EXILED/Exiled.Events/Handlers/Player.cs b/EXILED/Exiled.Events/Handlers/Player.cs
index c03bcebebf..9ef0015750 100644
--- a/EXILED/Exiled.Events/Handlers/Player.cs
+++ b/EXILED/Exiled.Events/Handlers/Player.cs
@@ -98,6 +98,11 @@ public class Player
///
public static Event UsedItem { get; set; } = new();
+ ///
+ /// Invoked before a consumes an . In other words, it is invoked before the consumable item logic are applied.
+ ///
+ public static Event ConsumingItem { get; set; } = new();
+
///
/// Invoked before a has stopped the use of a .
///
@@ -723,6 +728,12 @@ public class Player
/// The instance.
public static void OnUsedItem(UsedItemEventArgs ev) => UsedItem.InvokeSafely(ev);
+ ///
+ /// Called before a consumes a item.
+ ///
+ /// The instance.
+ public static void OnConsumingItem(ConsumingItemEventArgs ev) => ConsumingItem.InvokeSafely(ev);
+
///
/// Called before a has stopped the use of a item.
///
diff --git a/EXILED/Exiled.Events/Patches/Events/Player/ConsumingItem.cs b/EXILED/Exiled.Events/Patches/Events/Player/ConsumingItem.cs
new file mode 100644
index 0000000000..85e9f6698f
--- /dev/null
+++ b/EXILED/Exiled.Events/Patches/Events/Player/ConsumingItem.cs
@@ -0,0 +1,78 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.Events.Patches.Events.Player
+{
+ using System.Collections.Generic;
+ using System.Reflection.Emit;
+
+ using Exiled.API.Features.Pools;
+ using Exiled.Events.Attributes;
+ using Exiled.Events.EventArgs.Player;
+
+ using HarmonyLib;
+
+ using InventorySystem.Items.Usables;
+
+ using static HarmonyLib.AccessTools;
+
+ ///
+ /// Patches .
+ /// Adds the event.
+ ///
+ [EventPatch(typeof(Handlers.Player), nameof(Handlers.Player.ConsumingItem))]
+ [HarmonyPatch(typeof(Consumable), nameof(Consumable.ActivateEffects))]
+ internal static class ConsumingItem
+ {
+ private static IEnumerable Transpiler(IEnumerable instructions, ILGenerator generator)
+ {
+ List newInstructions = ListPool.Pool.Get(instructions);
+
+ Label skip = generator.DefineLabel();
+
+ int offset = -1;
+ int index = newInstructions.FindIndex(instruction => instruction.Calls(Method(typeof(Consumable), nameof(Consumable.OnEffectsActivated)))) + offset;
+
+ List mainLabels = newInstructions[index].ExtractLabels();
+ newInstructions[index].WithLabels(skip);
+
+ newInstructions.InsertRange(0, new CodeInstruction[]
+ {
+ // this.Owner;
+ new CodeInstruction(OpCodes.Ldarg_0).WithLabels(mainLabels),
+ new(OpCodes.Callvirt, PropertyGetter(typeof(Consumable), nameof(Consumable.Owner))),
+
+ // this;
+ new(OpCodes.Ldarg_0),
+
+ // ConsumingItemEventArgs ev = new(this.Owner, this);
+ new(OpCodes.Newobj, GetDeclaredConstructors(typeof(ConsumingItemEventArgs))[0]),
+ new(OpCodes.Dup),
+
+ // Player.OnConsumingItem(ev);
+ new(OpCodes.Call, Method(typeof(Handlers.Player), nameof(Handlers.Player.OnConsumingItem))),
+
+ // if (!ev.IsAllowed)
+ // this._alreadyActivated = true;
+ // return;
+ new(OpCodes.Callvirt, PropertyGetter(typeof(ConsumingItemEventArgs), nameof(ConsumingItemEventArgs.IsAllowed))),
+ new(OpCodes.Brtrue_S, skip),
+
+ new(OpCodes.Ldarg_0),
+ new(OpCodes.Ldc_I4_1),
+ new(OpCodes.Stfld, Field(typeof(Consumable), nameof(Consumable._alreadyActivated))),
+
+ new(OpCodes.Ret),
+ });
+
+ for (int z = 0; z < newInstructions.Count; z++)
+ yield return newInstructions[z];
+
+ ListPool.Pool.Return(newInstructions);
+ }
+ }
+}
From 696013de03629a3cf6426f8df6e92ffd1ae30d63 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Wed, 29 Apr 2026 20:18:19 +0300
Subject: [PATCH 31/33] feat: add generic singleton Instance pattern for
CustomItem and CustomRole (#775)
* generic singleton instance pattern
* Update CustomItem{T}.cs
* new for someone
---
.../API/Features/CustomItem{T}.cs | 180 ++++++++++++++++++
.../API/Features/CustomRole{T}.cs | 39 ++++
2 files changed, 219 insertions(+)
create mode 100644 EXILED/Exiled.CustomItems/API/Features/CustomItem{T}.cs
create mode 100644 EXILED/Exiled.CustomRoles/API/Features/CustomRole{T}.cs
diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomItem{T}.cs b/EXILED/Exiled.CustomItems/API/Features/CustomItem{T}.cs
new file mode 100644
index 0000000000..65fd3bd07f
--- /dev/null
+++ b/EXILED/Exiled.CustomItems/API/Features/CustomItem{T}.cs
@@ -0,0 +1,180 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+#pragma warning disable SA1402 // File may only contain a single type
+namespace Exiled.CustomItems.API.Features
+{
+ using YamlDotNet.Serialization;
+
+ ///
+ /// A generic base class for that provides a typed singleton .
+ ///
+ /// The concrete type.
+ public abstract class CustomItem : CustomItem
+ where T : CustomItem, new()
+ {
+ ///
+ /// Gets the singleton instance of this .
+ ///
+ [YamlIgnore]
+ public static T? Instance { get; private set; }
+
+ ///
+ public override void Init()
+ {
+ base.Init();
+ Instance = this as T;
+ }
+
+ ///
+ public override void Destroy()
+ {
+ Instance = null;
+ base.Destroy();
+ }
+ }
+
+ ///
+ /// A generic base class for that provides a typed singleton .
+ ///
+ /// The concrete type.
+ public abstract class CustomWeapon : CustomWeapon
+ where T : CustomWeapon, new()
+ {
+ ///
+ /// Gets the singleton instance of this .
+ ///
+ [YamlIgnore]
+ public static T? Instance { get; private set; }
+
+ ///
+ public override void Init()
+ {
+ base.Init();
+ Instance = this as T;
+ }
+
+ ///
+ public override void Destroy()
+ {
+ Instance = null;
+ base.Destroy();
+ }
+ }
+
+ ///
+ /// A generic base class for that provides a typed singleton .
+ ///
+ /// The concrete type.
+ public abstract class CustomKeycard : CustomKeycard
+ where T : CustomKeycard, new()
+ {
+ ///
+ /// Gets the singleton instance of this .
+ ///
+ [YamlIgnore]
+ public static T? Instance { get; private set; }
+
+ ///
+ public override void Init()
+ {
+ base.Init();
+ Instance = this as T;
+ }
+
+ ///
+ public override void Destroy()
+ {
+ Instance = null;
+ base.Destroy();
+ }
+ }
+
+ ///
+ /// A generic base class for that provides a typed singleton .
+ ///
+ /// The concrete type.
+ public abstract class CustomGrenade : CustomGrenade
+ where T : CustomGrenade, new()
+ {
+ ///
+ /// Gets the singleton instance of this .
+ ///
+ [YamlIgnore]
+ public static T? Instance { get; private set; }
+
+ ///
+ public override void Init()
+ {
+ base.Init();
+ Instance = this as T;
+ }
+
+ ///
+ public override void Destroy()
+ {
+ Instance = null;
+ base.Destroy();
+ }
+ }
+
+ ///
+ /// A generic base class for that provides a typed singleton .
+ ///
+ /// The concrete type.
+ public abstract class CustomArmor : CustomArmor
+ where T : CustomArmor, new()
+ {
+ ///
+ /// Gets the singleton instance of this .
+ ///
+ [YamlIgnore]
+ public static T? Instance { get; private set; }
+
+ ///
+ public override void Init()
+ {
+ base.Init();
+ Instance = this as T;
+ }
+
+ ///
+ public override void Destroy()
+ {
+ Instance = null;
+ base.Destroy();
+ }
+ }
+
+ ///
+ /// A generic base class for that provides a typed singleton .
+ ///
+ /// The concrete type.
+ public abstract class CustomGoggles : CustomGoggles
+ where T : CustomGoggles, new()
+ {
+ ///
+ /// Gets the singleton instance of this .
+ ///
+ [YamlIgnore]
+ public static T? Instance { get; private set; }
+
+ ///
+ public override void Init()
+ {
+ base.Init();
+ Instance = this as T;
+ }
+
+ ///
+ public override void Destroy()
+ {
+ Instance = null;
+ base.Destroy();
+ }
+ }
+}
+#pragma warning restore SA1402 // File may only contain a single type
\ No newline at end of file
diff --git a/EXILED/Exiled.CustomRoles/API/Features/CustomRole{T}.cs b/EXILED/Exiled.CustomRoles/API/Features/CustomRole{T}.cs
new file mode 100644
index 0000000000..5d4149e8c5
--- /dev/null
+++ b/EXILED/Exiled.CustomRoles/API/Features/CustomRole{T}.cs
@@ -0,0 +1,39 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.CustomRoles.API.Features
+{
+ using YamlDotNet.Serialization;
+
+ ///
+ /// A generic base class for that provides a typed singleton .
+ ///
+ /// The concrete type.
+ public abstract class CustomRole : CustomRole
+ where T : CustomRole, new()
+ {
+ ///
+ /// Gets the singleton instance of this .
+ ///
+ [YamlIgnore]
+ public static T? Instance { get; private set; }
+
+ ///
+ public override void Init()
+ {
+ base.Init();
+ Instance = this as T;
+ }
+
+ ///
+ public override void Destroy()
+ {
+ Instance = null;
+ base.Destroy();
+ }
+ }
+}
\ No newline at end of file
From 5ad144e1f70f222a220331754eec8bf4031fdb36 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Wed, 29 Apr 2026 20:23:12 +0300
Subject: [PATCH 32/33] feat: Speaker Api: Part Two (#762)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Automatic id / throwing errors changed to log error
* f
* .
* s
* gf
* eğH
* added new özellik
* update
* update log
* color on wrong order
* wwait i will fix
* finished
* fix
* pool clear
* fAHH!
* Fahh
* better and more functionally
* Return to pool doc
* Cleanup & Breaking Changes for exiled 10
Fck this old useless things
* Update Speaker.cs
* Update Speaker.cs
* Update Speaker.cs
* little refactor
* Performance improvement & fix Architectural problems in pooling
* wth
* .
* fix return pool
* .
* d
* removed Hard Coded values
* Update Speaker.cs
* fAHHHHH
* fix
* f
* izabel
* Release controller ID on pool return to prevent ID exhaustion
* remove network chechks
* for another pr
* locals
* fix: prevent controller ID conflicts by removing pooled speakers from AllInstances
* s
* base check for return pool
* ffff
* NOT MY CHANGES
* Added Fade Volume Method, Time left property & setter for PlaybackProgress
* fade in for play
* TrackData & fade in for Play from pool
* encoder clean for current time setter
* Queue Track
* added Static Events
* Audio Time Events
* fix Action errors break corrutine
* Action id
* more more C#18 YOLOOOOOOO
* reorder
* lazy instance for lists
* shuffle
* give credit to Fisher-Yates
* i forgot to delete this
* natural Fade
* add linear fade option
* Fix after fade volume stuck 0
* fixes
* squash
fix
* fade corrutine fix maybe, im drowing help me
is it OverApi idk?
* Update Speaker.cs
* Update Speaker.cs
* simplify api & null safety & fix fade & add stop fade function
* Remove Fade out because its doing api dirty and complicated
* Clean Api & new Event
* remove thing which im added
* remeove old arg
* doc
* f
* renameing
* Standalone System
* remove useles using which is i added
* bool return
* Add Filter
* Open Modular Api + Url play + Player play
* \
* add filter samples
* nh
* .
* fix: 14.2.6 update (#781)
* 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>
* Bump to v9.13.3
* t
* directorys
magic struct to struct
* cache logic#
* fix file name
* pcm method
* helper for url
* refactor cache & add Playbacksettings class for too long param
* It seems to get more complicated each time.
* d
* change ai filter to more optimized ai filter
* simplfy playervoicesource
* Update Speaker.cs
* old way tasks maybe later
* filter reset + change useless Concurrent
* class to struct
* fix slient crash probablty, memory leak, neww sources
* add multiple key support for bypass free usage limit
* renames
* update,more task , more fix
* i forgot this
* i forgıt it
* fixed index out of
* task error protection & audio delaying 1 frame for nothing
* private pool + get
* delete summary
* shit
* you cant see me
* Last touchs of mr selim
* Change channel
* new prop for asyncpcmsoruce
* j
---------
Co-authored-by: Yamato <66829532+louis1706@users.noreply.github.com>
Co-authored-by: @Someone <45270312+Someone-193@users.noreply.github.com>
---
EXILED/Exiled.API/Exiled.API.csproj | 1 +
.../Features/Audio/AudioDataStorage.cs | 213 +++
.../Features/Audio/Filters/EchoFilter.cs | 181 +++
.../Audio/Filters/PitchShiftFilter.cs | 300 ++++
.../Audio/PcmSources/CachedPcmSource.cs | 176 +++
.../Features/Audio/PcmSources/MixerSource.cs | 168 +++
.../Audio/PcmSources/PlayerVoiceSource.cs | 152 ++
.../{ => PcmSources}/PreloadedPcmSource.cs | 81 +-
.../Audio/PcmSources/VoiceRssTtsSource.cs | 268 ++++
.../Audio/{ => PcmSources}/WavStreamSource.cs | 37 +-
.../Audio/PcmSources/WebWavPcmSource.cs | 184 +++
.../Features/Audio/PlaybackSettings.cs | 98 ++
.../Features/Audio/ScheduledEvent.cs | 52 +
.../Features/Audio/SpeakerEvents.cs | 99 ++
.../Exiled.API/Features/Audio/WavUtility.cs | 215 ++-
EXILED/Exiled.API/Features/Toys/AdminToy.cs | 28 +-
EXILED/Exiled.API/Features/Toys/Light.cs | 3 +-
EXILED/Exiled.API/Features/Toys/Speaker.cs | 1240 ++++++++++++++---
.../Interfaces/Audio/IAsyncPcmSource.cs | 25 +
.../Interfaces/Audio/IAudioFilter.cs | 26 +
.../Interfaces/Audio/ILiveSource.cs | 16 +
.../Interfaces/{ => Audio}/IPcmSource.cs | 9 +-
EXILED/Exiled.API/Structs/Audio/AudioData.cs | 36 +
.../Exiled.API/Structs/Audio/QueuedTrack.cs | 40 +
EXILED/Exiled.API/Structs/Audio/TrackData.cs | 74 +
25 files changed, 3467 insertions(+), 255 deletions(-)
create mode 100644 EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs
create mode 100644 EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs
create mode 100644 EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs
create mode 100644 EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs
create mode 100644 EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs
create mode 100644 EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs
rename EXILED/Exiled.API/Features/Audio/{ => PcmSources}/PreloadedPcmSource.cs (58%)
create mode 100644 EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs
rename EXILED/Exiled.API/Features/Audio/{ => PcmSources}/WavStreamSource.cs (84%)
create mode 100644 EXILED/Exiled.API/Features/Audio/PcmSources/WebWavPcmSource.cs
create mode 100644 EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs
create mode 100644 EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs
create mode 100644 EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs
create mode 100644 EXILED/Exiled.API/Interfaces/Audio/IAsyncPcmSource.cs
create mode 100644 EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs
create mode 100644 EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs
rename EXILED/Exiled.API/Interfaces/{ => Audio}/IPcmSource.cs (89%)
create mode 100644 EXILED/Exiled.API/Structs/Audio/AudioData.cs
create mode 100644 EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs
create mode 100644 EXILED/Exiled.API/Structs/Audio/TrackData.cs
diff --git a/EXILED/Exiled.API/Exiled.API.csproj b/EXILED/Exiled.API/Exiled.API.csproj
index 70f81bf0d1..6dea6426ae 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/AudioDataStorage.cs b/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs
new file mode 100644
index 0000000000..17ea91b02f
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs
@@ -0,0 +1,213 @@
+// -----------------------------------------------------------------------
+//
+// 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 storage of decoded PCM audio data. Once stored, audio can be played using .
+ ///
+ public static class AudioDataStorage
+ {
+ static AudioDataStorage()
+ {
+ AudioStorage = new();
+ RoundRestart.OnRestartTriggered += OnRoundRestart;
+ }
+
+ ///
+ /// Gets the underlying storage, keyed by name.
+ ///
+ public static Dictionary AudioStorage { get; }
+
+ ///
+ /// 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 stores a local .wav file under the specified name.
+ ///
+ /// 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 stored; otherwise, false.
+ public static bool AddWav(string name, string path)
+ {
+ if (!ValidateName(name))
+ return false;
+
+ if (AudioStorage.ContainsKey(name))
+ {
+ Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping add.");
+ return false;
+ }
+
+ if (path.StartsWith("http"))
+ {
+ Log.Error($"[AudioDataStorage] '{path}' is a URL. Use AudioDataStorage.AddUrl() for web sources.");
+ return false;
+ }
+
+ if (!File.Exists(path))
+ {
+ Log.Error($"[AudioDataStorage] Local file not found: '{path}'");
+ return false;
+ }
+
+ try
+ {
+ AudioData parsed = WavUtility.WavToPcm(path);
+ return AudioStorage.TryAdd(name, parsed);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[AudioDataStorage] Failed to load '{path}' into storage:\n{ex}");
+ return false;
+ }
+ }
+
+ ///
+ /// Stores raw PCM audio samples under the specified name.
+ ///
+ /// 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)
+ {
+ Log.Error($"[AudioDataStorage] Cannot store null 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));
+ }
+
+ ///
+ /// Stores a fully constructed under the specified name.
+ ///
+ /// The unique storage 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($"[AudioDataStorage] AudioData for key '{name}' has null or empty PCM.");
+ return false;
+ }
+
+ if (AudioStorage.ContainsKey(name))
+ {
+ Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping add.");
+ return false;
+ }
+
+ return AudioStorage.TryAdd(name, audioData);
+ }
+
+ ///
+ /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the storage.
+ ///
+ /// 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 AddWavUrl(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 storage.
+ ///
+ /// 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)
+ {
+ if (!ValidateName(name))
+ yield break;
+
+ if (string.IsNullOrEmpty(url) || !url.StartsWith("http"))
+ {
+ Log.Error($"[AudioDataStorage] Invalid URL for key '{name}': '{url}'. Must start with http/https.");
+ yield break;
+ }
+
+ if (AudioStorage.ContainsKey(name))
+ {
+ Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping download.");
+ yield break;
+ }
+
+ using UnityWebRequest www = UnityWebRequest.Get(url);
+ yield return Timing.WaitUntilDone(www.SendWebRequest());
+
+ if (www.result != UnityWebRequest.Result.Success)
+ {
+ Log.Error($"[AudioDataStorage] Download failed for '{url}': {www.error}");
+ yield break;
+ }
+
+ try
+ {
+ AudioData parsed = WavUtility.WavToPcm(www.downloadHandler.data);
+ parsed.TrackInfo.Path = url;
+ AudioStorage.TryAdd(name, parsed);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[AudioDataStorage] Failed to parse downloaded WAV from '{url}':\n{ex}");
+ }
+ }
+
+ ///
+ /// Removes a stored audio entry by name.
+ ///
+ /// The storage name/key to remove.
+ /// true if the entry was found and removed; otherwise, false.
+ public static bool Remove(string name) => AudioStorage.Remove(name, out _);
+
+ ///
+ /// Clears all entries from the audio storage, freeing all associated memory.
+ ///
+ public static void Clear() => AudioStorage.Clear();
+
+ private static bool ValidateName(string name)
+ {
+ if (!string.IsNullOrEmpty(name))
+ return true;
+
+ Log.Error("[AudioDataStorage] Storage name (key) cannot be null or empty.");
+ return false;
+ }
+
+ private static void OnRoundRestart()
+ {
+ if (ClearOnRoundRestart)
+ Clear();
+ }
+ }
+}
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 0000000000..6cdcee5c0f
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs
@@ -0,0 +1,181 @@
+// -----------------------------------------------------------------------
+//
+// 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.Audio;
+
+ 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;
+ 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;
+ set => field = Mathf.Clamp01(value);
+ }
+
+ ///
+ /// Gets or sets the volume of the original (dry) unaffected sound.
+ ///
+ public float DryMix
+ {
+ get;
+ set => field = Mathf.Clamp01(value);
+ }
+
+ ///
+ /// Gets or sets the volume of the delayed (wet) echoed sound.
+ ///
+ public float WetMix
+ {
+ get;
+ set => field = Mathf.Clamp01(value);
+ }
+
+ ///
+ /// Gets or sets the damping coefficient. Automatically recalculates the RBJ Biquad coefficients.
+ ///
+ public float Damping
+ {
+ get;
+ 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));
+ }
+ }
+
+ ///
+ 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.
+ ///
+ 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 0000000000..e013d9fae0
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs
@@ -0,0 +1,300 @@
+// -----------------------------------------------------------------------
+//
+// 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.Audio;
+
+ using UnityEngine;
+
+ ///
+ /// A true DSP Granular Pitch Shifter based on the smbPitchShift algorithm.
+ ///
+ 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 * 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 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 applied during playback.
+ /// Values above 1.0 produce a higher (thinner) pitch; values below 1.0 produce a lower (deeper) pitch.
+ ///
+ public float Pitch
+ {
+ get;
+ set => field = Mathf.Clamp(value, 0.1f, 4.0f);
+ }
+
+ ///
+ /// 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).
+ ///
+ public int Oversample
+ {
+ get;
+ set
+ {
+ field = Mathf.Clamp(value, 2, 32);
+ cachedOversample = -1;
+ }
+ }
+
+ ///
+ public void Process(float[] frame)
+ {
+ if (Mathf.Abs(Pitch - 1.0f) < 0.001f)
+ return;
+
+ EnsureOversampleConstants();
+ SmbPitchShift(Pitch, frame.Length, frame, outputBuffer);
+
+ 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;
+ cachedOversample = -1;
+ }
+
+ private static float[] BuildHannWindow()
+ {
+ 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));
+
+ 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 (int i = 0; i < numSampsToProcess; i++)
+ {
+ gInFIFO[gRover] = indata[i];
+ outdata[i] = gOutFIFO[gRover - inFifoLatency];
+ gRover++;
+
+ if (gRover < FftFrameSize)
+ continue;
+
+ gRover = inFifoLatency;
+
+ for (int k = 0; k < FftFrameSize; k++)
+ {
+ gFFTworksp[2 * k] = gInFIFO[k] * HannWindow[k];
+ gFFTworksp[(2 * k) + 1] = 0.0f;
+ }
+
+ SmbFft(gFFTworksp, -1);
+
+ for (int k = 0; k <= FftFrameSize2; k++)
+ {
+ float real = gFFTworksp[2 * k];
+ float imag = gFFTworksp[(2 * k) + 1];
+
+ float magn = 2.0f * Mathf.Sqrt((real * real) + (imag * imag));
+ float phase = Mathf.Atan2(imag, real);
+
+ float tmp = phase - gLastPhase[k];
+ gLastPhase[k] = phase;
+
+ tmp -= k * expct;
+
+ long qpd = (long)(tmp / Mathf.PI);
+ if (qpd >= 0)
+ qpd += qpd & 1;
+ else
+ qpd -= qpd & 1;
+
+ tmp -= Mathf.PI * qpd;
+
+ tmp = Oversample * tmp / (2.0f * Mathf.PI);
+ tmp = (k * freqPerBin) + (tmp * freqPerBin);
+
+ gAnaMagn[k] = magn;
+ gAnaFreq[k] = tmp;
+ }
+
+ Array.Clear(gSynMagn, 0, FftFrameSize);
+ Array.Clear(gSynFreq, 0, FftFrameSize);
+
+ for (int k = 0; k <= FftFrameSize2; k++)
+ {
+ long index = (long)(k * pitchShift);
+ if (index <= FftFrameSize2)
+ {
+ gSynMagn[index] += gAnaMagn[k];
+ gSynFreq[index] = gAnaFreq[k] * pitchShift;
+ }
+ }
+
+ for (int k = 0; k <= FftFrameSize2; k++)
+ {
+ float magn = gSynMagn[k];
+ float tmp = gSynFreq[k];
+
+ tmp -= k * freqPerBin;
+ tmp /= freqPerBin;
+ tmp = 2.0f * Mathf.PI * tmp / Oversample;
+ tmp += k * expct;
+
+ gSumPhase[k] += tmp;
+
+ gFFTworksp[2 * k] = magn * Mathf.Cos(gSumPhase[k]);
+ gFFTworksp[(2 * k) + 1] = magn * Mathf.Sin(gSumPhase[k]);
+ }
+
+ Array.Clear(gFFTworksp, FftFrameSize + 2, FftFrameSize - 2);
+
+ SmbFft(gFFTworksp, 1);
+
+ for (int k = 0; k < FftFrameSize; k++)
+ gOutputAccum[k] += 2.0f * HannWindow[k] * gFFTworksp[2 * k] / (FftFrameSize2 * Oversample);
+
+ for (int k = 0; k < stepSize; k++)
+ gOutFIFO[k] = gOutputAccum[k];
+
+ 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, int sign)
+ {
+ for (int i = 2; i < (2 * FftFrameSize) - 2; i += 2)
+ {
+ int j = 0;
+ for (int bitm = 2; bitm < 2 * FftFrameSize; bitm <<= 1)
+ {
+ if ((i & bitm) != 0)
+ j++;
+ j <<= 1;
+ }
+
+ if (i < j)
+ {
+ (fftBuffer[j], fftBuffer[i]) = (fftBuffer[i], fftBuffer[j]);
+ (fftBuffer[j + 1], fftBuffer[i + 1]) = (fftBuffer[i + 1], fftBuffer[j + 1]);
+ }
+ }
+
+ int stageIndex = 0;
+ for (int le = 4; le <= FftFrameSize * 2; le <<= 1, stageIndex++)
+ {
+ 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 (int i = j; i < 2 * FftFrameSize; i += le)
+ {
+ 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;
+ }
+
+ float newUr = (ur * wr) - (ui * wi);
+ ui = (ur * wi) + (ui * wr);
+ ur = newUr;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
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 0000000000..c9b8c94430
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs
@@ -0,0 +1,176 @@
+// -----------------------------------------------------------------------
+//
+// 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.IO;
+
+ using Exiled.API.Features.Audio;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
+
+ using VoiceChat;
+
+ ///
+ /// Provides an that plays audio data directly from the for optimized, repeated playback.
+ ///
+ public sealed class CachedPcmSource : IPcmSource
+ {
+ private readonly float[] data;
+ private int pos;
+
+ ///
+ /// 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 (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio))
+ {
+ 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;
+ }
+
+ ///
+ /// Initializes a new instance of the class. Fetches cached audio or loads a local WAV file into the cache if not present.
+ ///
+ /// 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 or path cannot be null or empty.");
+ }
+
+ if (!AudioDataStorage.AudioStorage.ContainsKey(name))
+ {
+ if (!AudioDataStorage.AddWav(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 (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio))
+ {
+ 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;
+ }
+
+ ///
+ /// 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).
+ 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 or PCM data cannot be null.");
+ }
+
+ if (!AudioDataStorage.AudioStorage.ContainsKey(name))
+ {
+ 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 (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio))
+ {
+ 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;
+ }
+
+ ///
+ /// 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);
+ }
+
+ ///
+ /// 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()
+ {
+ }
+ }
+}
\ No newline at end of file
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 0000000000..b97d3181a3
--- /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(IEnumerable 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/PlayerVoiceSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs
new file mode 100644
index 0000000000..ae70e5a103
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs
@@ -0,0 +1,152 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio.PcmSources
+{
+ using System.Buffers;
+ using System.Collections.Generic;
+
+ using Exiled.API.Features;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
+
+ 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, ILiveSource
+ {
+ private readonly Player sourcePlayer;
+ private readonly OpusDecoder decoder;
+ private readonly Queue pcmQueue;
+
+ private float[] decodeBuffer;
+
+ ///
+ /// 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.
+ public PlayerVoiceSource(Player player, bool blockOriginalVoice = false)
+ {
+ sourcePlayer = player;
+ BlockOriginalVoice = blockOriginalVoice;
+
+ decoder = new OpusDecoder();
+ pcmQueue = new Queue();
+ decodeBuffer = ArrayPool.Shared.Rent(VoiceChatSettings.PacketSizePerChannel);
+
+ TrackInfo = new TrackData
+ {
+ Path = $"{player.Nickname}-Mic",
+ Duration = double.PositiveInfinity,
+ };
+
+ LabApi.Events.Handlers.PlayerEvents.SendingVoiceMessage += OnVoiceChatting;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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?.GameObject == null;
+
+ ///
+ /// 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 read = 0;
+ while (read < count && pcmQueue.TryDequeue(out float sample))
+ {
+ buffer[offset + read] = sample;
+ read++;
+ }
+
+ return read;
+ }
+
+ ///
+ 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 OnVoiceChatting(PlayerSendingVoiceMessageEventArgs ev)
+ {
+ if (ev.Player != sourcePlayer)
+ return;
+
+ if (ev.Message.DataLength <= 2)
+ return;
+
+ if (BlockOriginalVoice)
+ ev.IsAllowed = false;
+
+ 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/PcmSources/PreloadedPcmSource.cs
similarity index 58%
rename from EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
rename to EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs
index 7be9d09a30..ea1855a9ef 100644
--- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs
@@ -5,36 +5,51 @@
//
// -----------------------------------------------------------------------
-namespace Exiled.API.Features.Audio
+namespace Exiled.API.Features.Audio.PcmSources
{
using System;
+ using System.Threading.Tasks;
- using Exiled.API.Interfaces;
+ using Exiled.API.Features.Audio;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
using VoiceChat;
///
- /// Represents a preloaded PCM audio source.
+ /// Provides a preloaded with Pcm data or file.
///
- public sealed class PreloadedPcmSource : IPcmSource
+ public sealed class PreloadedPcmSource : IPcmSource, IAsyncPcmSource
{
- ///
- /// The PCM data buffer.
- ///
- private readonly float[] data;
-
- ///
- /// The current read position in the data buffer.
- ///
+ private float[] data;
private int pos;
+ private volatile bool isReady = false;
+ private volatile bool isFailed = false;
+
///
/// Initializes a new instance of the class.
///
/// The path to the audio file.
public PreloadedPcmSource(string path)
{
- data = WavUtility.WavToPcm(path);
+ TrackInfo = new TrackData { Path = path, Duration = 0.0 };
+
+ Task.Run(() =>
+ {
+ try
+ {
+ AudioData result = WavUtility.WavToPcm(path);
+ data = result.Pcm;
+ TrackInfo = result.TrackInfo;
+ isReady = true;
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[PreloadedPcmSource] Failed to load audio from path: {path} | Error: {ex.Message}");
+ isFailed = true;
+ }
+ });
}
///
@@ -44,27 +59,40 @@ public PreloadedPcmSource(string path)
public PreloadedPcmSource(float[] pcmData)
{
data = pcmData;
+ isReady = true;
+ TrackInfo = new TrackData { Duration = TotalDuration };
}
+ ///
+ /// Gets the metadata of the loaded track.
+ ///
+ public TrackData TrackInfo { get; private set; }
+
///
/// Gets a value indicating whether the end of the PCM data buffer has been reached.
///
- public bool Ended => pos >= data.Length;
+ public bool Ended => isFailed || (isReady && pos >= data.Length);
///
/// Gets the total duration of the audio in seconds.
///
- public double TotalDuration => (double)data.Length / VoiceChatSettings.SampleRate;
+ public double TotalDuration => isReady && data != null ? (double)data.Length / VoiceChatSettings.SampleRate : 0.0;
///
/// Gets or sets the current playback position in seconds.
///
public double CurrentTime
{
- get => (double)pos / VoiceChatSettings.SampleRate;
+ get => isReady ? (double)pos / VoiceChatSettings.SampleRate : 0.0;
set => Seek(value);
}
+ ///
+ public bool IsFailed => isFailed;
+
+ ///
+ public bool IsReady => isReady;
+
///
/// Reads a sequence of PCM samples from the preloaded buffer into the specified array.
///
@@ -74,6 +102,15 @@ public double CurrentTime
/// The number of samples read into .
public int Read(float[] buffer, int offset, int count)
{
+ if (isFailed)
+ return 0;
+
+ if (!isReady || data == null)
+ {
+ Array.Clear(buffer, offset, count);
+ return count;
+ }
+
int read = Math.Min(count, data.Length - pos);
Array.Copy(data, pos, buffer, offset, read);
pos += read;
@@ -87,15 +124,11 @@ public int Read(float[] buffer, int offset, int count)
/// The target position in seconds.
public void Seek(double seconds)
{
- long targetIndex = (long)(seconds * VoiceChatSettings.SampleRate);
-
- if (targetIndex < 0)
- targetIndex = 0;
+ if (!isReady || data == null)
+ return;
- if (targetIndex > data.Length)
- targetIndex = data.Length;
-
- pos = (int)targetIndex;
+ long targetIndex = (long)(seconds * VoiceChatSettings.SampleRate);
+ pos = (int)Math.Max(0, Math.Min(targetIndex, data.Length));
}
///
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 0000000000..e6a26803d8
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs
@@ -0,0 +1,268 @@
+// -----------------------------------------------------------------------
+//
+// 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 System.Threading.Tasks;
+
+ 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, IAsyncPcmSource
+ {
+ private const string ApiEndpoint = "https://api.voicerss.org/";
+ private const string AudioFormat = "48khz_16bit_mono";
+
+ private static readonly Dictionary BlacklistKeys = new();
+
+ private IPcmSource internalSource;
+ private UnityWebRequest webRequest;
+ private CoroutineHandle downloadRoutine;
+
+ private volatile bool isReady = false;
+ private volatile 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)
+ : this(text, new[] { apiKey }, language, voice, rate)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The text to convert to speech.(Length limited by 100KB).
+ /// Your VoiceRSS API keys. 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, IEnumerable apiKeys, 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 (apiKeys == null || !apiKeys.Any())
+ {
+ isFailed = true;
+ Log.Error("[VoiceRssTtsSource] At least one API key must be provided.");
+ throw new ArgumentException("API key collection cannot be null or empty.", nameof(apiKeys));
+ }
+
+ TrackInfo = new TrackData { Path = $"VoiceRssTts: {text}", Duration = 0.0 };
+ downloadRoutine = Timing.RunCoroutine(DownloadRoutine(text, apiKeys, 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);
+
+ ///
+ public bool IsFailed => isFailed;
+
+ ///
+ public bool IsReady => isReady;
+
+ ///
+ /// 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, IEnumerable apiKeys, string language, string voice, int rate)
+ {
+ string clampedRate = Math.Clamp(rate, -10, 10).ToString();
+ string textEscaped = Uri.EscapeDataString(text);
+ string langEscaped = Uri.EscapeDataString(language);
+ string voiceEscaped = string.IsNullOrEmpty(voice) ? string.Empty : $"&v={Uri.EscapeDataString(voice)}";
+
+ bool successfulDownload = false;
+
+ foreach (string apiKey in apiKeys)
+ {
+ if (string.IsNullOrWhiteSpace(apiKey))
+ continue;
+
+ if (BlacklistKeys.TryGetValue(apiKey, out DateTime exhaustedAt))
+ {
+ if (DateTime.UtcNow.Day == exhaustedAt.Day)
+ continue;
+
+ BlacklistKeys.Remove(apiKey);
+ }
+
+ string url = $"{ApiEndpoint}?key={Uri.EscapeDataString(apiKey)}&hl={langEscaped}&c=WAV&f={AudioFormat}&r={clampedRate}&src={textEscaped}{voiceEscaped}";
+
+ webRequest?.Dispose();
+ try
+ {
+ webRequest = UnityWebRequest.Get(url);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[VoiceRssTtsSource] Failed to get Url '{url}. Error: {ex.Message}");
+ break;
+ }
+
+ yield return Timing.WaitUntilDone(webRequest.SendWebRequest());
+
+ if (webRequest.result != UnityWebRequest.Result.Success)
+ {
+ Log.Error($"[VoiceRssTtsSource] Network Error: {webRequest.error}.");
+ break;
+ }
+
+ string responseText = webRequest.downloadHandler.text;
+ if (!string.IsNullOrEmpty(responseText) && responseText.StartsWith("ERROR: "))
+ {
+ string errorMessage = responseText[7..].Trim();
+
+ if (errorMessage.Contains("limit") || errorMessage.Contains("expired") || errorMessage.Contains("inactive") || errorMessage.Contains("API key"))
+ {
+ Log.Warn($"[VoiceRssTtsSource] Key issue, key: '{apiKey}', Error : {errorMessage}. Switching to another key...");
+ BlacklistKeys[apiKey] = DateTime.UtcNow;
+ continue;
+ }
+ else
+ {
+ Log.Error($"[VoiceRssTtsSource] API Error: {errorMessage}");
+ break;
+ }
+ }
+
+ successfulDownload = true;
+ break;
+ }
+
+ if (!successfulDownload)
+ {
+ isFailed = true;
+ webRequest?.Dispose();
+ webRequest = null;
+ yield break;
+ }
+
+ byte[] rawBytes = webRequest.downloadHandler.data;
+ webRequest.Dispose();
+ webRequest = null;
+
+ Task toPcmTask = Task.Run(() => WavUtility.WavToPcm(rawBytes));
+
+ yield return Timing.WaitUntilTrue(() => toPcmTask.IsCompleted);
+
+ if (toPcmTask.IsFaulted)
+ {
+ Log.Error($"[VoiceRssTtsSource] Error read the downloaded file! \nError: {toPcmTask.Exception?.InnerException?.Message ?? toPcmTask.Exception?.Message}");
+ isFailed = true;
+ yield break;
+ }
+
+ AudioData audioData = toPcmTask.Result;
+ audioData.TrackInfo.Path = $"VoiceRSS: {text}";
+
+ try
+ {
+ internalSource = new PreloadedPcmSource(audioData.Pcm);
+ TrackInfo = audioData.TrackInfo;
+ isReady = true;
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[VoiceRssTtsSource] Failed to create internal source! \nError: {ex.InnerException?.Message ?? ex.Message}");
+ isFailed = true;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs
similarity index 84%
rename from EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
rename to EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs
index d910093f1b..32a219a2b8 100644
--- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs
@@ -5,19 +5,21 @@
//
// -----------------------------------------------------------------------
-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.Interfaces;
+ using Exiled.API.Features.Audio;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.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
{
@@ -36,12 +38,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.
///
@@ -70,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)
@@ -89,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;
}
///
@@ -104,15 +113,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/PcmSources/WebWavPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/WebWavPcmSource.cs
new file mode 100644
index 0000000000..64f6fff699
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/WebWavPcmSource.cs
@@ -0,0 +1,184 @@
+// -----------------------------------------------------------------------
+//
+// 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.Threading.Tasks;
+
+ using Exiled.API.Features;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
+
+ using MEC;
+
+ using UnityEngine.Networking;
+
+ ///
+ /// Provides a that downloads a .wav file from a URL and preloads it for playback.
+ ///
+ public sealed class WebWavPcmSource : IPcmSource, IAsyncPcmSource
+ {
+ private IPcmSource internalSource;
+ private UnityWebRequest webRequest;
+ private CoroutineHandle downloadRoutine;
+
+ private volatile bool isReady = false;
+ private volatile bool isFailed = false;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The direct URL to the .wav file.
+ public WebWavPcmSource(string url)
+ {
+ TrackInfo = default;
+ downloadRoutine = 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);
+
+ ///
+ public bool IsFailed => isFailed;
+
+ ///
+ public bool IsReady => isReady;
+
+ ///
+ /// 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()
+ {
+ if (downloadRoutine.IsRunning)
+ downloadRoutine.IsRunning = false;
+
+ webRequest?.Abort();
+ webRequest?.Dispose();
+ internalSource?.Dispose();
+ }
+
+ private IEnumerator Download(string url)
+ {
+ try
+ {
+ webRequest = UnityWebRequest.Get(url);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[WebWavPcmSource] Failed to download audio! URL: {url} | Error: {ex.Message}");
+ isFailed = true;
+ webRequest?.Dispose();
+ webRequest = null;
+ yield break;
+ }
+
+ yield return Timing.WaitUntilDone(webRequest.SendWebRequest());
+
+ if (webRequest.result != UnityWebRequest.Result.Success)
+ {
+ Log.Error($"[WebWavPcmSource] Failed to download audio! URL: {url} | Error: {webRequest.error}");
+ isFailed = true;
+ webRequest?.Dispose();
+ webRequest = null;
+ yield break;
+ }
+
+ byte[] rawBytes = webRequest.downloadHandler.data;
+ webRequest.Dispose();
+ webRequest = null;
+
+ Task toPcmTask = Task.Run(() => WavUtility.WavToPcm(rawBytes));
+
+ yield return Timing.WaitUntilTrue(() => toPcmTask.IsCompleted);
+
+ if (toPcmTask.IsFaulted)
+ {
+ Log.Error($"[WebPreloadWavPcmSource] Failed to read the downloaded file! Ensure the link points to a valid .WAV file. Error: {toPcmTask.Exception?.InnerException?.Message ?? toPcmTask.Exception?.Message}");
+ isFailed = true;
+ yield break;
+ }
+
+ AudioData audioData = toPcmTask.Result;
+ audioData.TrackInfo.Path = url;
+
+ try
+ {
+ internalSource = new PreloadedPcmSource(audioData.Pcm);
+ TrackInfo = audioData.TrackInfo;
+ isReady = true;
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[WebPreloadWavPcmSource] Failed to read the downloaded file! Ensure the link points to a valid .WAV file. Error: {ex.InnerException?.Message ?? ex.Message}");
+ isFailed = true;
+ }
+ }
+ }
+}
\ 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 0000000000..6ca303970e
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs
@@ -0,0 +1,98 @@
+// -----------------------------------------------------------------------
+//
+// 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;
+
+ using Mirror;
+
+ ///
+ /// Represents all configurable audio and network settings for play from pool method.
+ ///
+ public struct PlaybackSettings
+ {
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ public 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.
+ /// Ignored for web URLs and cached sources.
+ ///
+ public bool Stream { get; set; } = false;
+
+ ///
+ /// Gets or sets a value indicating whether to load the audio via the storage Manager for optimized playback.
+ ///
+ 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 is ).
+ ///
+ public Player TargetPlayer { get; set; } = null;
+
+ ///
+ /// 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 (used when is ).
+ ///
+ 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/Audio/ScheduledEvent.cs b/EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs
new file mode 100644
index 0000000000..a87768f3c8
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs
@@ -0,0 +1,52 @@
+// -----------------------------------------------------------------------
+//
+// 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 action for audio playback.
+ ///
+ public class ScheduledEvent : IComparable
+ {
+ ///
+ /// 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 ScheduledEvent(double time, Action action, string id = null)
+ {
+ Time = time;
+ Action = action;
+ Id = id ?? Guid.NewGuid().ToString();
+ }
+
+ ///
+ /// 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; }
+
+ ///
+ /// Gets the unique identifier for this time event.
+ ///
+ public string Id { 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 int CompareTo(ScheduledEvent other) => Time.CompareTo(other.Time);
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs
new file mode 100644
index 0000000000..da580416f9
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs
@@ -0,0 +1,99 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio
+{
+ using System;
+
+ using Exiled.API.Structs.Audio;
+
+ 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 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.
+ ///
+ 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 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.
+ ///
+ /// 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/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
index c1b1bc3f73..9be12fa6b8 100644
--- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
@@ -13,6 +13,10 @@ namespace Exiled.API.Features.Audio
using System.IO;
using System.Runtime.InteropServices;
+ using Exiled.API.Features.Audio.PcmSources;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
+
using VoiceChat;
///
@@ -22,13 +26,46 @@ public static class WavUtility
{
private const float Divide = 1f / 32768f;
+ ///
+ /// 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).
+ /// If true, loads the audio via for zero-latency memory playback.
+ /// An initialized .
+ public static IPcmSource CreatePcmSource(string path, bool stream = false, bool cache = false)
+ {
+ if (cache)
+ return new CachedPcmSource(path, path);
+
+ if (path.StartsWith("http"))
+ return new WebWavPcmSource(path);
+
+ if (stream)
+ return new WavStreamSource(path);
+
+ return new PreloadedPcmSource(path);
+ }
+
///
/// 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 containing an array of floats representing the PCM data and its TrackData.
+ public static AudioData WavToPcm(string path)
{
+ if (!File.Exists(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($"[WavUtility] 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;
@@ -37,23 +74,12 @@ public static float[] WavToPcm(string path)
try
{
int bytesRead = fs.Read(rentedBuffer, 0, length);
-
using MemoryStream ms = new(rentedBuffer, 0, bytesRead);
- SkipHeader(ms);
-
- int headerOffset = (int)ms.Position;
- int dataLength = bytesRead - headerOffset;
-
- Span audioDataSpan = rentedBuffer.AsSpan(headerOffset, dataLength);
- Span samples = MemoryMarshal.Cast(audioDataSpan);
+ AudioData 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;
-
- return pcm;
+ return result;
}
finally
{
@@ -61,18 +87,72 @@ public static float[] WavToPcm(string path)
}
}
+ ///
+ /// Converts a WAV byte array to a PCM float array.
+ ///
+ /// The raw bytes of the WAV file.
+ /// 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);
+
+ 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 AudioData 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 new(pcm, metaData);
+ }
+
///
/// 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();
+
+ if (stream.Length < 12)
+ {
+ Log.Error("[WavUtility] WAV file is too short to contain a valid header.");
+ throw new InvalidDataException("WAV file is too short to contain a valid header.");
+ }
+
Span headerBuffer = stackalloc byte[12];
stream.Read(headerBuffer);
+ int rate = 0;
+ int bits = 0;
+ int channels = 0;
+
Span chunkHeader = stackalloc byte[8];
while (true)
{
+ if (stream.Position + 8 > stream.Length)
+ {
+ Log.Error("[WavUtility] WAV file ended prematurely while parsing chunks.");
+ throw new InvalidDataException("WAV file ended prematurely while parsing chunks.");
+ }
+
int read = stream.Read(chunkHeader);
if (read < 8)
break;
@@ -87,21 +167,72 @@ 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)
- throw new InvalidDataException($"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.");
+ }
if (chunkSize > 16)
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
{
@@ -109,8 +240,46 @@ public static void SkipHeader(Stream stream)
}
if (stream.Position >= stream.Length)
- throw new InvalidDataException("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.");
+ }
+ }
+
+ 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"))
+ 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/AdminToy.cs b/EXILED/Exiled.API/Features/Toys/AdminToy.cs
index 15cbc0ab72..6f8fe5555a 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/Light.cs b/EXILED/Exiled.API/Features/Toys/Light.cs
index 9e167d1bb5..4dad774de2 100644
--- a/EXILED/Exiled.API/Features/Toys/Light.cs
+++ b/EXILED/Exiled.API/Features/Toys/Light.cs
@@ -151,13 +151,12 @@ public static Light Create(Vector3? position /*= null*/, Vector3? rotation /*= n
Position = position ?? Vector3.zero,
Rotation = Quaternion.Euler(rotation ?? Vector3.zero),
Scale = scale ?? Vector3.one,
+ Color = color ?? Color.gray,
};
if (spawn)
light.Spawn();
- light.Color = color ?? Color.gray;
-
return light;
}
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 4478c1143b..40201e9aa9 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -5,17 +5,22 @@
//
// -----------------------------------------------------------------------
+#pragma warning disable SA1129 // Do not use default value type constructor
namespace Exiled.API.Features.Toys
{
using System;
using System.Collections.Generic;
- using System.IO;
+ using System.Linq;
+ using System.Threading.Tasks;
using AdminToys;
using Enums;
using Exiled.API.Features.Audio;
+ using Exiled.API.Features.Audio.PcmSources;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
using Interfaces;
@@ -23,36 +28,82 @@ namespace Exiled.API.Features.Toys
using Mirror;
+ using NorthwoodLib.Pools;
+
+ using RoundRestarting;
+
using UnityEngine;
using VoiceChat;
using VoiceChat.Codec;
using VoiceChat.Codec.Enums;
using VoiceChat.Networking;
+ using VoiceChat.Playbacks;
using Object = UnityEngine.Object;
+ using Random = UnityEngine.Random;
///
/// A wrapper class for .
///
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;
+
private const int FrameSize = VoiceChatSettings.PacketSizePerChannel;
private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate;
+ private static readonly Queue Pool;
+
+ private static readonly Vector3 SpeakerParkPosition = Vector3.down * 999;
+
+ private OpusEncoder encoder;
+
private float[] frame;
private byte[] encoded;
private float[] resampleBuffer;
+ private Func processNextFrame;
+
+ private CoroutineHandle playBackRoutine;
+ private CoroutineHandle fadeRoutine;
private double resampleTime;
private int resampleBufferFilled;
+ private int nextScheduledEventIndex = 0;
+ private int idChangeFrame;
- private IPcmSource source;
- private OpusEncoder encoder;
- private CoroutineHandle playBackRoutine;
-
- private bool isPitchDefault = true;
private bool isPlayBackInitialized = false;
+ private bool isPitchDefault = true;
+ private bool needsSyncWait = false;
+
+ static Speaker()
+ {
+ Pool = new();
+ RoundRestart.OnRestartTriggered += Pool.Clear;
+ }
///
/// Initializes a new instance of the class.
@@ -85,13 +136,19 @@ 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).
///
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.
///
@@ -105,7 +162,7 @@ internal Speaker(SpeakerToy speakerToy)
///
/// Gets or sets the network channel used for sending audio packets from this speaker .
///
- public int Channel { get; set; } = Channels.ReliableOrdered2;
+ public int Channel { get; set; } = Channels.Unreliable;
///
/// Gets or sets a value indicating whether the audio playback should loop when it reaches the end.
@@ -117,6 +174,11 @@ internal Speaker(SpeakerToy speakerToy)
///
public bool DestroyAfter { get; set; }
+ ///
+ /// Gets or sets a value indicating whether the speaker should return to the pool after playback finishes.
+ ///
+ public bool ReturnToPoolAfter { get; set; }
+
///
/// Gets or sets the play mode for this speaker, determining how audio is sent to players.
///
@@ -139,7 +201,7 @@ internal Speaker(SpeakerToy speakerToy)
public Func Predicate { get; set; }
///
- /// Gets a value indicating whether gets is a sound playing on this speaker or not.
+ /// Gets a value indicating whether a sound is currently playing on this speaker.
///
public bool IsPlaying => playBackRoutine.IsRunning && !IsPaused;
@@ -162,9 +224,15 @@ public bool IsPaused
playBackRoutine.IsAliveAndPaused = value;
if (value)
+ {
OnPlaybackPaused?.Invoke();
+ SpeakerEvents.OnPlaybackPaused(this);
+ }
else
+ {
OnPlaybackResumed?.Invoke();
+ SpeakerEvents.OnPlaybackResumed(this);
+ }
}
}
@@ -174,15 +242,19 @@ public bool IsPaused
///
public double CurrentTime
{
- get => source?.CurrentTime ?? 0.0;
+ get => CurrentSource?.CurrentTime ?? 0.0;
set
{
- if (source != null)
- {
- source.CurrentTime = value;
- resampleTime = 0.0;
- resampleBufferFilled = 0;
- }
+ if (CurrentSource == null)
+ return;
+
+ CurrentSource.CurrentTime = value;
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+
+ ResetEncoder();
+ Filter?.Reset();
+ UpdateNextScheduledEventIndex();
}
}
@@ -190,12 +262,52 @@ 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.
+ ///
+ 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
+ {
+ get => TotalDuration > 0.0 ? (float)(CurrentTime / TotalDuration) : 0f;
+ set
+ {
+ if (TotalDuration > 0.0)
+ CurrentTime = TotalDuration * Mathf.Clamp01(value);
+ }
+ }
+
+ ///
+ /// Gets the currently playing audio source.
+ /// Pre-made filters are available in the namespace.
+ ///
+ public IPcmSource CurrentSource { 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 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.
+ ///
+ public List TrackQueue => field ??= new();
///
- /// Gets the path to the last audio file played on this speaker.
+ /// Gets the list of time-based events for the current audio track.
///
- public string LastTrack { get; private set; }
+ public List ScheduledEvents => field ??= new();
///
/// Gets or sets the playback pitch.
@@ -209,6 +321,19 @@ public float Pitch
get;
set
{
+ if (field == value)
+ return;
+
+ if (Mathf.Abs(value - 1f) > 0.0001f && CurrentSource is ILiveSource)
+ {
+ field = 1f;
+ 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)
@@ -219,6 +344,8 @@ public float Pitch
}
}
+ = 1f;
+
///
/// Gets or sets the volume of the audio source.
///
@@ -229,7 +356,11 @@ public float Pitch
public float Volume
{
get => Base.NetworkVolume;
- set => Base.NetworkVolume = value;
+ set
+ {
+ StopFade();
+ Base.NetworkVolume = value;
+ }
}
///
@@ -277,24 +408,39 @@ public float MinDistance
public byte ControllerId
{
get => Base.NetworkControllerId;
- set => Base.NetworkControllerId = value;
+ set
+ {
+ if (Base.NetworkControllerId == value)
+ return;
+
+ Base.NetworkControllerId = value;
+ needsSyncWait = true;
+ idChangeFrame = Time.frameCount;
+ }
}
///
/// Creates a new .
///
- /// The position of the .
- /// The rotation of the .
- /// The scale of the .
+ /// The parent transform to attach the to.
+ /// The local position of the .
+ /// The volume level of the audio source.
+ /// Whether the audio source is spatialized (3D sound).
+ /// The minimum distance at which the audio reaches full volume.
+ /// The maximum distance at which the audio can be heard.
+ /// The specific controller ID to assign. If null, the next available ID is used.
/// Whether the should be initially spawned.
/// The new .
- public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scale, bool spawn)
+ public static Speaker Create(Transform parent = null, Vector3? position = null, float volume = DefaultVolume, bool isSpatial = DefaultSpatial, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, byte? controllerId = null, bool spawn = true)
{
- Speaker speaker = new(Object.Instantiate(Prefab))
+ Speaker speaker = new(Object.Instantiate(Prefab, parent))
{
- Position = position ?? Vector3.zero,
- Rotation = Quaternion.Euler(rotation ?? Vector3.zero),
- Scale = scale ?? Vector3.one,
+ Volume = volume,
+ IsSpatial = isSpatial,
+ MinDistance = minDistance,
+ MaxDistance = maxDistance,
+ ControllerId = controllerId ?? GetNextFreeControllerId(),
+ LocalPosition = position ?? Vector3.zero,
};
if (spawn)
@@ -304,267 +450,690 @@ public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scal
}
///
- /// Creates a new .
+ /// Rents an available speaker from the pool or creates a new one if the pool is empty.
///
- /// The transform to create this on.
- /// Whether the should be initially spawned.
- /// Whether the should keep the same world position.
- /// The new .
- public static Speaker Create(Transform transform, bool spawn, bool worldPositionStays = true)
+ /// The parent transform to attach the to.
+ /// The local position of the .
+ /// A clean instance ready for use.
+ public static Speaker Rent(Transform parent = null, Vector3? position = null)
{
- Speaker speaker = new(Object.Instantiate(Prefab, transform, worldPositionStays))
+ Speaker speaker = null;
+
+ while (Pool.Count > 0)
{
- Position = transform.position,
- Rotation = transform.rotation,
- Scale = transform.localScale.normalized,
- };
+ speaker = Pool.Dequeue();
- if (spawn)
- speaker.Spawn();
+ if (speaker != null && speaker.Base != null)
+ break;
+
+ speaker = null;
+ }
+
+ if (speaker == null)
+ {
+ speaker = Create(parent, position);
+ }
+ else
+ {
+ speaker.IsStatic = false;
+
+ if (parent != null)
+ speaker.Transform.parent = parent;
+
+ speaker.LocalPosition = position ?? Vector3.zero;
+ speaker.ControllerId = GetNextFreeControllerId(speaker.ControllerId);
+ SpeakerToyPlaybackBase.AllInstances.Add(speaker.Base.Playback);
+ }
return speaker;
}
///
- /// Plays audio through this speaker.
+ /// 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.)
///
- /// An instance.
- /// Targets who will hear the audio. If null, audio will be sent to all players.
- public static void Play(AudioMessage message, IEnumerable targets = null)
+ /// The path/url or custom name/key (if has set to true) to the wav file.
+ /// The parent transform, if any.
+ /// The local position of the speaker.
+ /// 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 PlayWavFromPool(string path, Transform parent = null, Vector3? position = null, in PlaybackSettings? settings = null)
{
- foreach (Player target in targets ?? Player.List)
- target.Connection.Send(message);
+ if (string.IsNullOrEmpty(path))
+ {
+ Log.Error("[Speaker] Provided path/url or name cannot be null or empty!");
+ return false;
+ }
+
+ PlaybackSettings settingsFull = settings ?? new PlaybackSettings();
+ if (!settingsFull.UseCache && !WavUtility.TryValidatePath(path, out string errorMessage))
+ {
+ Log.Error($"[Speaker] {errorMessage}");
+ return false;
+ }
+
+ IPcmSource source;
+ try
+ {
+ source = WavUtility.CreatePcmSource(path, settingsFull.Stream, settingsFull.UseCache);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to initialize audio source for PlayFromPool. Path: '{path}'.\n{ex}");
+ return false;
+ }
+
+ return PlayFromPool(source, parent, position, settingsFull);
}
///
- /// Plays audio through this speaker.
+ /// Rents a speaker from the pool, plays a custom PCM source one time, and automatically returns it to the pool afterwards.
///
- /// Audio samples.
- /// The length of the samples array.
- /// Targets who will hear the audio. If null, audio will be sent to all players.
- public void Play(byte[] samples, int? length = null, IEnumerable targets = null) => Play(new AudioMessage(ControllerId, samples, length ?? samples.Length), targets);
+ /// The custom IPcmSource to play.
+ /// The parent transform, if any.
+ /// The local position of the speaker.
+ /// 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, Transform parent = null, Vector3? position = null, in PlaybackSettings? settings = null)
+ {
+ if (source == null)
+ {
+ Log.Error("[Speaker] Provided custom IPcmSource is null for PlayFromPool!");
+ return false;
+ }
+
+ Speaker speaker = Rent(parent, position);
+
+ PlaybackSettings settingsFull = settings ?? new PlaybackSettings();
+
+ speaker.Volume = settingsFull.Volume;
+ speaker.IsSpatial = settingsFull.IsSpatial;
+ speaker.MinDistance = settingsFull.MinDistance;
+ speaker.MaxDistance = settingsFull.MaxDistance;
+
+ 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;
+
+ if (!speaker.Play(source, true))
+ {
+ speaker.ReturnToPool();
+ return false;
+ }
+
+ return true;
+ }
///
- /// Plays a wav file through this speaker.(File must be 16 bit, mono and 48khz.)
+ /// Gets the next available controller ID for a .
///
- /// 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.
- public void Play(string path, bool stream = false, bool destroyAfter = false, bool loop = false)
+ /// 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(byte? preferredId = null)
{
- if (!File.Exists(path))
- throw new FileNotFoundException("The specified file does not exist.", path);
+ HashSet usedIds = HashSetPool.Shared.Rent(byte.MaxValue + 1);
- if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
- throw new NotSupportedException($"The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file.");
+ foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances)
+ {
+ usedIds.Add(playbackBase.ControllerId);
+ }
- TryInitializePlayBack();
- Stop();
+ if (usedIds.Count >= byte.MaxValue + 1)
+ {
+ HashSetPool.Shared.Return(usedIds);
+ Log.Warn("[Speaker] All controller IDs are in use. Default Controll Id will be use, Audio may conflict!");
+ return DefaultControllerId;
+ }
- Loop = loop;
- LastTrack = path;
- DestroyAfter = destroyAfter;
- source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path);
- playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject));
+ if (preferredId.HasValue && !usedIds.Contains(preferredId.Value))
+ {
+ HashSetPool.Shared.Return(usedIds);
+ return preferredId.Value;
+ }
+
+ byte id = 0;
+ while (usedIds.Contains(id))
+ {
+ id++;
+ }
+
+ HashSetPool.Shared.Return(usedIds);
+ return id;
}
///
- /// Stops playback.
+ /// Plays a local wav file or web URL through this speaker. (File must be 16-bit, mono, and 48kHz.)
///
- public void Stop()
+ /// 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 optimized playback.
+ /// 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, bool useCache = false)
{
- if (playBackRoutine.IsRunning)
+ if (string.IsNullOrEmpty(path))
{
- Timing.KillCoroutines(playBackRoutine);
- OnPlaybackStopped?.Invoke();
+ 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;
+ }
+
+ IPcmSource newSource;
+ try
+ {
+ newSource = WavUtility.CreatePcmSource(path, stream, useCache);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to initialize audio source for file at path: '{path}'.\nException Details: {ex}");
+ return false;
}
- source?.Dispose();
- source = null;
+ return Play(newSource, clearQueue);
}
- private void TryInitializePlayBack()
+ ///
+ /// Converts provided paths/URLs to sources and plays them mixed together.
+ ///
+ /// The collection of paths or URLs to the audio files.
+ /// If true, clears the upcoming tracks in the playlist before starting playback.
+ /// If true, streams local files from disk. (Ignored for web URLs).
+ /// If true, utilizes for the sources.
+ /// true if at least one valid path was loaded and started; otherwise, false.
+ public bool PlayMixedWav(IEnumerable paths, bool clearQueue = true, bool stream = false, bool useCache = false)
{
- if (isPlayBackInitialized)
- return;
+ if (paths == null || !paths.Any())
+ {
+ Log.Error("[Speaker] No paths provided for PlayMixedWav!");
+ return false;
+ }
- isPlayBackInitialized = true;
+ List createdSources = new();
- frame = new float[FrameSize];
- resampleBuffer = Array.Empty();
- encoder = new(OpusApplicationType.Audio);
- encoded = new byte[VoiceChatSettings.MaxEncodedSize];
+ foreach (string path in paths)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ Log.Warn("[Speaker] One of the provided paths for PlayMixedWav is null or empty. Skipping this entry.");
+ continue;
+ }
- AdminToyBase.OnRemoved += OnToyRemoved;
+ if (!WavUtility.TryValidatePath(path, out string error))
+ {
+ Log.Error($"[Speaker] Skipping invalid path in mix: {path}. Reason: {error}");
+ continue;
+ }
+
+ try
+ {
+ IPcmSource source = WavUtility.CreatePcmSource(path, stream, useCache);
+ if (source != null)
+ createdSources.Add(source);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to create source for mix from '{path}': {ex.Message}");
+ }
+ }
+
+ if (createdSources.Count == 0)
+ return false;
+
+ return PlayMixed(createdSources, clearQueue);
}
- private IEnumerator PlayBackCoroutine()
+ ///
+ /// Plays the live voice of a specific player through this speaker.
+ ///
+ /// The player whose voice will be broadcasted.
+ /// If true, prevents the player's original voice message's from being heard while broadcasting.
+ /// If true, clears the upcoming tracks in the playlist before starting playback.
+ /// true if the playback started successfully; otherwise, false.
+ public bool PlayFromPlayer(Player player, bool blockOriginalVoice = false, bool clearQueue = true)
{
- OnPlaybackStarted?.Invoke();
+ if (player == null)
+ {
+ Log.Error("[Speaker] Source player cannot be null when streaming live microphone!");
+ return false;
+ }
- resampleTime = 0.0;
- resampleBufferFilled = 0;
+ PlayerVoiceSource source;
+ try
+ {
+ source = new PlayerVoiceSource(player, blockOriginalVoice);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to initialize live voice stream for player '{player.Nickname}' ({player.Id}).\nException Details: {ex}");
+ return false;
+ }
- float timeAccumulator = 0f;
+ return Play(source, clearQueue);
+ }
- while (true)
+ ///
+ /// 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)
{
- timeAccumulator += Time.deltaTime;
-
- while (timeAccumulator >= FrameTime)
- {
- timeAccumulator -= FrameTime;
+ Log.Error("[Speaker] Provided custom IPcmSource is null!");
+ return false;
+ }
- if (isPitchDefault)
- {
- int read = source.Read(frame, 0, FrameSize);
- if (read < FrameSize)
- Array.Clear(frame, read, FrameSize - read);
- }
- else
- {
- ResampleFrame();
- }
+ TryInitializePlayBack();
+ Stop(clearQueue);
- int len = encoder.Encode(frame, encoded);
+ CurrentSource = customSource;
+ LastTrackInfo = CurrentSource.TrackInfo;
- if (len > 2)
- SendPacket(len);
+ if (CurrentSource is ILiveSource)
+ Pitch = 1.0f;
- if (!source.Ended)
- continue;
+ playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject));
+ return true;
+ }
- OnPlaybackFinished?.Invoke(LastTrack);
+ ///
+ /// Plays multiple instances mixed together.
+ ///
+ /// The collection of PCM sources to mix and play.
+ /// If true, clears the upcoming tracks in the playlist before starting playback.
+ /// true if at least one source was successfully mixed; otherwise, false.
+ public bool PlayMixed(IEnumerable sources, bool clearQueue = true)
+ {
+ if (sources == null || !sources.Any())
+ {
+ Log.Error("[Speaker] No sources provided for PlayMixed!");
+ return false;
+ }
- if (Loop)
- {
- source.Reset();
- OnPlaybackLooped?.Invoke();
- resampleTime = resampleBufferFilled = 0;
- continue;
- }
+ if (clearQueue)
+ TrackQueue.Clear();
- if (DestroyAfter)
- Destroy();
- else
- Stop();
+ bool anyAdded = false;
- yield break;
- }
+ foreach (IPcmSource source in sources)
+ {
+ if (source == null)
+ continue;
- yield return Timing.WaitForOneFrame;
+ if (AddMixed(source))
+ anyAdded = true;
}
+
+ return anyAdded;
}
- private void ResampleFrame()
+ ///
+ /// 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)
{
- int requiredSize = (int)(FrameSize * Mathf.Abs(Pitch) * 2) + 10;
-
- if (resampleBuffer.Length < requiredSize)
+ if (extraSource == null)
{
- resampleBuffer = new float[requiredSize];
- resampleTime = 0.0;
- resampleBufferFilled = 0;
+ Log.Error("[Speaker] Provided extra IPcmSource for mixing is null!");
+ return false;
}
- int outputIdx = 0;
+ if (!playBackRoutine.IsRunning || CurrentSource == null || CurrentSource.Ended)
+ return Play(extraSource, false);
- while (outputIdx < FrameSize)
+ if (extraSource is ILiveSource)
+ Pitch = 1.0f;
+
+ if (CurrentSource is MixerSource currentMixer)
{
- if (resampleBufferFilled == 0)
- {
- int toRead = resampleBuffer.Length - 4;
- int actualRead = source.Read(resampleBuffer, 0, toRead);
+ currentMixer.AddSource(extraSource);
+ return true;
+ }
- if (actualRead == 0)
- {
- while (outputIdx < FrameSize)
- frame[outputIdx++] = 0f;
- return;
- }
+ 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;
+ }
+ }
- resampleBufferFilled = actualRead;
- resampleTime = 0.0;
- }
+ ///
+ /// Stops playback.
+ ///
+ /// If true, clears the upcoming tracks in the playlist.
+ public void Stop(bool clearQueue = true)
+ {
+ if (!isPlayBackInitialized)
+ return;
- int currentSample = (int)resampleTime;
+ if (playBackRoutine.IsRunning)
+ {
+ playBackRoutine.IsRunning = false;
- if (currentSample >= resampleBufferFilled - 1)
- {
- if (resampleBufferFilled > 0)
- {
- resampleBuffer[0] = resampleBuffer[resampleBufferFilled - 1];
+ OnPlaybackStopped?.Invoke();
+ SpeakerEvents.OnPlaybackStopped(this);
+ }
- int toRead = resampleBuffer.Length - 5;
- int actualRead = source.Read(resampleBuffer, 1, toRead);
+ if (clearQueue)
+ TrackQueue.Clear();
- if (actualRead == 0)
- {
- while (outputIdx < FrameSize)
- frame[outputIdx++] = 0f;
- return;
- }
+ StopFade();
+ ResetEncoder();
+ ClearScheduledEvents();
- resampleBufferFilled = actualRead + 1;
- resampleTime -= currentSample;
- }
- else
- {
- resampleBufferFilled = 0;
- }
+ Filter?.Reset();
+ CurrentSource?.Dispose();
+ CurrentSource = null;
+ }
- continue;
- }
+ ///
+ /// 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.
+ /// 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, bool linear = false, Action onComplete = null)
+ {
+ if (fadeRoutine.IsRunning)
+ fadeRoutine.IsRunning = false;
- double frac = resampleTime - currentSample;
- float sample1 = resampleBuffer[currentSample];
- float sample2 = resampleBuffer[currentSample + 1];
+ fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, linear, onComplete).CancelWith(GameObject));
+ }
- frame[outputIdx++] = (float)(sample1 + ((sample2 - sample1) * frac));
+ ///
+ /// Stops currently active volume fading process, leaving the volume at its exact current level.
+ ///
+ public void StopFade()
+ {
+ if (fadeRoutine.IsRunning)
+ fadeRoutine.IsRunning = false;
+ }
- resampleTime += Pitch;
+ ///
+ /// Restarts the currently playing track from the beginning.
+ ///
+ public void RestartTrack()
+ {
+ if (!playBackRoutine.IsRunning)
+ return;
+
+ CurrentTime = 0.0;
+ }
+
+ ///
+ /// Helper method to easily queue a .wav file/url with stream support.
+ ///
+ /// An optional name or identifier for this track in the queue. This is only used for reference.
+ /// 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 optimized playback.
+ /// true if successfully queued or started.
+ public bool QueueWavTrack(string name, string path, bool isStream = false, bool useCache = false)
+ {
+ 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(name, () => WavUtility.CreatePcmSource(path, isStream, useCache)));
}
- private void SendPacket(int len)
+ ///
+ /// Adds a track to the playback queue. If nothing is playing, playback starts immediately.
+ ///
+ /// The queued track containing its creation logic and optional identifier.
+ /// true if successfully queued or started.
+ public bool QueueTrack(QueuedTrack track)
{
- AudioMessage msg = new(ControllerId, encoded, len);
+ if (!playBackRoutine.IsRunning && !IsPaused)
+ return Play(track.SourceProvider.Invoke());
+
+ TrackQueue.Add(track);
+ return true;
+ }
+
+ ///
+ /// Skips the currently playing track and starts playing the next one in the queue.
+ ///
+ public void SkipTrack()
+ {
+ if (TrackQueue.Count == 0)
+ {
+ Stop();
+ return;
+ }
+
+ Stop(clearQueue: false);
+
+ 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)
+ {
+ Log.Error($"[Speaker] Playlist next track failed: '{nextTrack}'.\n{ex}");
+ 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.Name == path) : TrackQueue.FindLastIndex(t => t.Name == path);
+
+ if (index == -1)
+ return false;
+
+ TrackQueue.RemoveAt(index);
+ return true;
+ }
+
+ ///
+ /// Shuffles the tracks in the into a random order with Fisher-Yates algorithm.
+ ///
+ 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 MEC Coroutine inside the action.
+ ///
+ /// 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 AddScheduledEvent(double timeInSeconds, Action action, string id = null)
+ {
+ ScheduledEvent timeEvent = new(timeInSeconds, action, id);
+
+ ScheduledEvents.Add(timeEvent);
+ ScheduledEvents.Sort();
+ UpdateNextScheduledEventIndex();
+
+ return timeEvent.Id;
+ }
+
+ ///
+ /// Removes a specific time-based event using its ID.
+ ///
+ /// The unique string identifier of the event to remove.
+ /// 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)
+ return false;
+
+ UpdateNextScheduledEventIndex();
+ return true;
+ }
+
+ ///
+ /// Clears all time-based events for the current playback.
+ ///
+ public void ClearScheduledEvents()
+ {
+ ScheduledEvents.Clear();
+ nextScheduledEventIndex = 0;
+ }
+
+ ///
+ /// Stops the current playback, resets all properties of the , and returns the instance to the object pool for future reuse.
+ ///
+ public void ReturnToPool()
+ {
+ if (Base == null)
+ return;
+
+ Stop();
+
+ if (Transform.parent != null || AdminToyBase._clientParentId != 0)
+ {
+ Transform.parent = null;
+ Base.RpcChangeParent(0);
+ }
+
+ LocalPosition = SpeakerParkPosition;
+
+ Volume = DefaultVolume;
+ IsSpatial = DefaultSpatial;
+ MinDistance = DefaultMinDistance;
+ MaxDistance = DefaultMaxDistance;
+
+ IsStatic = true;
+ Loop = false;
+ DestroyAfter = false;
+ ReturnToPoolAfter = false;
+ PlayMode = SpeakerPlayMode.Global;
+ Channel = Channels.ReliableOrdered2;
+
+ LastTrackInfo = default;
+
+ Predicate = null;
+ TargetPlayer = null;
+ TargetPlayers = null;
+
+ Pitch = 1f;
+ Filter = null;
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+ isPitchDefault = true;
+ needsSyncWait = false;
+
+ OnPlaybackStarted = null;
+ OnPlaybackPaused = null;
+ OnPlaybackResumed = null;
+ OnPlaybackLooped = null;
+ OnTrackSwitching = null;
+ OnPlaybackFinished = null;
+ OnPlaybackStopped = null;
+
+ SpeakerToyPlaybackBase.AllInstances.Remove(Base.Playback);
+
+ 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(msg, Channel);
+ NetworkServer.SendToReady(audioMessage, Channel);
break;
case SpeakerPlayMode.Player:
- TargetPlayer?.Connection.Send(msg, Channel);
+ TargetPlayer?.Connection?.Send(audioMessage, Channel);
break;
case SpeakerPlayMode.PlayerList:
+
+ if (TargetPlayers is null)
+ break;
+
using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
- NetworkMessages.Pack(msg, writer);
+ NetworkMessages.Pack(audioMessage, writer);
ArraySegment segment = writer.ToArraySegment();
foreach (Player ply in TargetPlayers)
{
- ply?.Connection.Send(segment, Channel);
+ ply?.Connection?.Send(segment, Channel);
}
}
break;
case SpeakerPlayMode.Predicate:
+ if (Predicate is null)
+ break;
+
using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
- NetworkMessages.Pack(msg, writer);
+ NetworkMessages.Pack(audioMessage, writer);
ArraySegment segment = writer.ToArraySegment();
foreach (Player ply in Player.List)
{
if (Predicate(ply))
- ply.Connection.Send(segment, Channel);
+ ply.Connection?.Send(segment, Channel);
}
}
@@ -572,6 +1141,25 @@ private void SendPacket(int len)
}
}
+ private void TryInitializePlayBack()
+ {
+ if (isPlayBackInitialized)
+ return;
+
+ isPlayBackInitialized = true;
+
+ frame = new float[FrameSize];
+ processNextFrame = ProcessNextFrame;
+ resampleBuffer = Array.Empty();
+ 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 void OnToyRemoved(AdminToyBase toy)
{
if (toy != Base)
@@ -580,8 +1168,288 @@ 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 void UpdateNextScheduledEventIndex()
+ {
+ nextScheduledEventIndex = 0;
+ double current = CurrentTime;
+
+ while (nextScheduledEventIndex < ScheduledEvents.Count && ScheduledEvents[nextScheduledEventIndex].Time <= current)
+ {
+ nextScheduledEventIndex++;
+ }
+ }
+
+ 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 IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, bool linear, Action onComplete)
+ {
+ float timePassed = 0f;
+ bool isFadeOut = startVolume > targetVolume;
+
+ while (timePassed < duration)
+ {
+ timePassed += Time.deltaTime;
+ float t = timePassed / duration;
+
+ if (!linear)
+ t = isFadeOut ? 1f - ((1f - t) * (1f - t)) : t * t;
+
+ Base.NetworkVolume = Mathf.Lerp(startVolume, targetVolume, t);
+ yield return Timing.WaitForOneFrame;
+ }
+
+ Base.NetworkVolume = targetVolume;
+ onComplete?.Invoke();
+ }
+
+ 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;
+
+ ReadNextFrame();
+ int firstLen = processNextFrame();
+
+ if (firstLen > 2)
+ SendAudioMessage(new AudioMessage(ControllerId, encoded, firstLen));
+
+ if (CurrentSource.Ended)
+ {
+ OnPlaybackFinished?.Invoke();
+ SpeakerEvents.OnPlaybackFinished(this);
+ EndingPlayBack();
+ yield break;
+ }
+
+ Task encodeTask = PrepareNextFrameAsync();
+
+ while (true)
+ {
+ timeAccumulator += Time.deltaTime;
+
+ while (timeAccumulator >= FrameTime)
+ {
+ timeAccumulator -= FrameTime;
+
+ if (encodeTask.IsFaulted)
+ {
+ Log.Error($"[Speaker] An error occurred during audio encoding.\nException Details: {encodeTask.Exception}");
+ Stop();
+ yield break;
+ }
+
+ int len = encodeTask.Result;
+
+ if (len > 2)
+ SendAudioMessage(new AudioMessage(ControllerId, encoded, len));
+
+ if (!CurrentSource.Ended)
+ {
+ encodeTask = PrepareNextFrameAsync();
+ continue;
+ }
+
+ bool trackFailed = CurrentSource is IAsyncPcmSource asyncSource && asyncSource.IsFailed;
+
+ if (!trackFailed)
+ {
+ OnPlaybackFinished?.Invoke();
+ SpeakerEvents.OnPlaybackFinished(this);
+
+ yield return Timing.WaitForOneFrame;
+
+ if (Loop)
+ {
+ resampleTime = 0.0;
+ timeAccumulator = 0;
+ resampleBufferFilled = 0;
+ nextScheduledEventIndex = 0;
+
+ ResetEncoder();
+ Filter?.Reset();
+ CurrentSource.Reset();
+
+ OnPlaybackLooped?.Invoke();
+ SpeakerEvents.OnPlaybackLooped(this);
+
+ encodeTask = PrepareNextFrameAsync();
+ continue;
+ }
+ }
+
+ EndingPlayBack();
+
+ yield break;
+ }
+
+ while (nextScheduledEventIndex < ScheduledEvents.Count && CurrentTime >= ScheduledEvents[nextScheduledEventIndex].Time)
+ {
+ try
+ {
+ ScheduledEvents[nextScheduledEventIndex].Action?.Invoke();
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to execute scheduled time event at {ScheduledEvents[nextScheduledEventIndex].Time:F2}s.\nException Details: {ex}");
+ }
+
+ nextScheduledEventIndex++;
+ }
+
+ yield return Timing.WaitForOneFrame;
+ }
+ }
+
+ private void ReadNextFrame()
+ {
+ if (isPitchDefault)
+ {
+ int read = CurrentSource.Read(frame, 0, FrameSize);
+ if (read < FrameSize)
+ Array.Clear(frame, read, FrameSize - read);
+ }
+ else
+ {
+ ResampleFrame();
+ }
+ }
+
+ private int ProcessNextFrame()
+ {
+ Filter?.Process(frame);
+ return encoder.Encode(frame, encoded);
+ }
+
+ private Task PrepareNextFrameAsync()
+ {
+ ReadNextFrame();
+ return Task.Run(processNextFrame);
+ }
+
+ private void ResampleFrame()
+ {
+ int requiredSize = (int)(FrameSize * Mathf.Abs(Pitch) * 2) + 10;
+
+ if (resampleBuffer.Length < requiredSize)
+ {
+ resampleBuffer = new float[requiredSize];
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+ }
+
+ int outputIdx = 0;
+
+ while (outputIdx < FrameSize)
+ {
+ if (resampleBufferFilled == 0)
+ {
+ int toRead = resampleBuffer.Length - 4;
+ int actualRead = CurrentSource.Read(resampleBuffer, 0, toRead);
+
+ if (actualRead == 0)
+ {
+ while (outputIdx < FrameSize)
+ frame[outputIdx++] = 0f;
+ return;
+ }
+
+ resampleBufferFilled = actualRead;
+ resampleTime = 0.0;
+ }
+
+ int currentSample = (int)resampleTime;
+
+ if (currentSample >= resampleBufferFilled - 1)
+ {
+ if (resampleBufferFilled > 0)
+ {
+ resampleBuffer[0] = resampleBuffer[resampleBufferFilled - 1];
+
+ int toRead = resampleBuffer.Length - 5;
+ int actualRead = CurrentSource.Read(resampleBuffer, 1, toRead);
+
+ if (actualRead == 0)
+ {
+ while (outputIdx < FrameSize)
+ frame[outputIdx++] = 0f;
+ return;
+ }
+
+ resampleBufferFilled = actualRead + 1;
+ resampleTime -= currentSample;
+ }
+ else
+ {
+ resampleBufferFilled = 0;
+ }
+
+ continue;
+ }
+
+ double frac = resampleTime - currentSample;
+ float sample1 = resampleBuffer[currentSample];
+ float sample2 = resampleBuffer[currentSample + 1];
+
+ frame[outputIdx++] = (float)(sample1 + ((sample2 - sample1) * frac));
+
+ resampleTime += Pitch;
+ }
+ }
+
+ private void EndingPlayBack()
+ {
+ if (TrackQueue.Count > 0)
+ {
+ playBackRoutine.IsRunning = false;
+ SkipTrack();
+ }
+ else if (ReturnToPoolAfter)
+ {
+ ReturnToPool();
+ }
+ else if (DestroyAfter)
+ {
+ Destroy();
+ }
+ else
+ {
+ Stop();
+ }
}
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Interfaces/Audio/IAsyncPcmSource.cs b/EXILED/Exiled.API/Interfaces/Audio/IAsyncPcmSource.cs
new file mode 100644
index 0000000000..1ae0a2f7e5
--- /dev/null
+++ b/EXILED/Exiled.API/Interfaces/Audio/IAsyncPcmSource.cs
@@ -0,0 +1,25 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Interfaces.Audio
+{
+ ///
+ /// Represents an audio source that loads its data asynchronously and can potentially fail.
+ ///
+ public interface IAsyncPcmSource
+ {
+ ///
+ /// Gets a value indicating whether the asynchronous source has finished loading/buffering and is ready to be played.
+ ///
+ bool IsReady { get; }
+
+ ///
+ /// Gets a value indicating whether the source failed to load.
+ ///
+ bool IsFailed { get; }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs b/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs
new file mode 100644
index 0000000000..3bd096a2a7
--- /dev/null
+++ b/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs
@@ -0,0 +1,26 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Interfaces.Audio
+{
+ ///
+ /// 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);
+
+ ///
+ /// Resets the internal state and buffers of the filter.
+ ///
+ void Reset();
+ }
+}
diff --git a/EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs b/EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs
new file mode 100644
index 0000000000..ad9c9caea9
--- /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 89%
rename from EXILED/Exiled.API/Interfaces/IPcmSource.cs
rename to EXILED/Exiled.API/Interfaces/Audio/IPcmSource.cs
index 680f568410..6f0423b86f 100644
--- a/EXILED/Exiled.API/Interfaces/IPcmSource.cs
+++ b/EXILED/Exiled.API/Interfaces/Audio/IPcmSource.cs
@@ -5,10 +5,12 @@
//
// -----------------------------------------------------------------------
-namespace Exiled.API.Interfaces
+namespace Exiled.API.Interfaces.Audio
{
using System;
+ using Exiled.API.Structs.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.
///
diff --git a/EXILED/Exiled.API/Structs/Audio/AudioData.cs b/EXILED/Exiled.API/Structs/Audio/AudioData.cs
new file mode 100644
index 0000000000..e8924f8c10
--- /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/Audio/QueuedTrack.cs b/EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs
new file mode 100644
index 0000000000..34d1cfbf63
--- /dev/null
+++ b/EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs
@@ -0,0 +1,40 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Structs.Audio
+{
+ using System;
+
+ using Exiled.API.Interfaces.Audio;
+
+ ///
+ /// 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 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)
+ {
+ Name = name;
+ SourceProvider = sourceFactory;
+ }
+
+ ///
+ /// Gets the name, path, or identifier of the track.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the provider function used to create the custom audio source on demand.
+ ///
+ public Func SourceProvider { get; }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Structs/Audio/TrackData.cs b/EXILED/Exiled.API/Structs/Audio/TrackData.cs
new file mode 100644
index 0000000000..93f549369f
--- /dev/null
+++ b/EXILED/Exiled.API/Structs/Audio/TrackData.cs
@@ -0,0 +1,74 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Structs.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 the file path of the track.
+ ///
+ public string Path { 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;
+
+ if (!string.IsNullOrEmpty(Path))
+ return System.IO.Path.GetFileNameWithoutExtension(Path);
+
+ 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");
+ }
+ }
+ }
+}
From 6ae54e676286e622bdf3a5de0e6afbde15ded084 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Wed, 29 Apr 2026 20:28:33 +0300
Subject: [PATCH 33/33] (MS forgor) Change Speaker channel from
ReliableOrdered2 to Unreliable (#812)
---
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 40201e9aa9..a1e320c002 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -1059,7 +1059,7 @@ public void ReturnToPool()
DestroyAfter = false;
ReturnToPoolAfter = false;
PlayMode = SpeakerPlayMode.Global;
- Channel = Channels.ReliableOrdered2;
+ Channel = Channels.Unreliable;
LastTrackInfo = default;
@@ -1452,4 +1452,4 @@ private void EndingPlayBack()
}
}
}
-}
\ No newline at end of file
+}