From efebf223e8f0db7b543d93425d94fc0d4ae7a771 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Fri, 2 Jan 2026 22:54:10 +0300
Subject: [PATCH 01/38] Speaker Toy Api
---
EXILED/Exiled.API/Enums/SpeakerPlayMode.cs | 30 ++
.../Features/Audio/PreloadedPcmSource.cs | 68 +++++
.../Features/Audio/WavStreamSource.cs | 77 +++++
EXILED/Exiled.API/Features/Toys/Speaker.cs | 281 +++++++++++++++++-
EXILED/Exiled.API/Interfaces/IPcmSource.cs | 36 +++
5 files changed, 486 insertions(+), 6 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/Interfaces/IPcmSource.cs
diff --git a/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
new file mode 100644
index 0000000000..8ab0a46117
--- /dev/null
+++ b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
@@ -0,0 +1,30 @@
+// -----------------------------------------------------------------------
+//
+// 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
+ {
+ ///
+ /// Play audio globally to all players.
+ ///
+ Global,
+
+ ///
+ /// Play audio to a specific list of players.
+ ///
+ PlayerList,
+
+ ///
+ /// Play audio to players matching a predicate.
+ ///
+ Predicate,
+ }
+}
diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
new file mode 100644
index 0000000000..3e181704b9
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
@@ -0,0 +1,68 @@
+// -----------------------------------------------------------------------
+//
+// 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;
+
+ ///
+ /// Represents a preloaded PCM audio source.
+ ///
+ public 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 PCM data to preload.
+ public PreloadedPcmSource(float[] pcm)
+ {
+ data = pcm;
+ }
+
+ ///
+ public bool Ended
+ {
+ get
+ {
+ return pos >= data.Length;
+ }
+ }
+
+ ///
+ 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;
+ }
+
+ ///
+ public void Reset()
+ {
+ pos = 0;
+ }
+
+ ///
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
new file mode 100644
index 0000000000..01f3944297
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
@@ -0,0 +1,77 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio
+{
+ using System.IO;
+
+ using Exiled.API.Features.Toys;
+ using Exiled.API.Interfaces;
+
+ ///
+ /// Provides a PCM audio source from a WAV file stream.
+ ///
+ public class WavStreamSource : IPcmSource
+ {
+ private readonly long endPosition;
+ private readonly long startPosition;
+ private readonly BinaryReader reader;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The path to the audio file.
+ public WavStreamSource(string path)
+ {
+ reader = new BinaryReader(File.OpenRead(path));
+ Speaker.SkipWavHeader(reader);
+ startPosition = reader.BaseStream.Position;
+ endPosition = reader.BaseStream.Length;
+ }
+
+ ///
+ /// Gets a value indicating whether the end of the stream has been reached.
+ ///
+ public bool Ended
+ {
+ get
+ {
+ return reader.BaseStream.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 i = 0;
+
+ while (i < count && reader.BaseStream.Position < endPosition)
+ {
+ buffer[offset + i] = reader.ReadInt16() / 32768f;
+ i++;
+ }
+
+ return i;
+ }
+
+ ///
+ /// Resets the stream position to the start.
+ ///
+ public void Reset() => reader.BaseStream.Position = startPosition;
+
+ ///
+ /// Releases all resources used by the .
+ ///
+ public void Dispose() => reader.Dispose();
+ }
+}
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 8b0af6bd79..45d736a98e 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -7,26 +7,60 @@
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 .
+ /// Represents a speaker toy that can play audio in the game world.
+ /// Provides methods for playing, stopping, and controlling audio playback, as well as managing playback settings.
///
public class Speaker : AdminToy, IWrapper
{
+ private const int SampleRate = VoiceChatSettings.SampleRate;
+ private const int FrameSize = VoiceChatSettings.PacketSizePerChannel;
+ private const float FrameTime = (float)FrameSize / SampleRate;
+
+ private readonly OpusEncoder encoder;
+ private readonly float[] frame = new float[FrameSize];
+ private readonly byte[] encoded = new byte[VoiceChatSettings.MaxEncodedSize];
+
+ private IPcmSource source;
+ private float timeAccumulator;
+ private CoroutineHandle playBackRoutine;
+
///
/// Initializes a new instance of the class.
///
/// The of the toy.
internal Speaker(SpeakerToy speakerToy)
- : base(speakerToy, AdminToyType.Speaker) => Base = speakerToy;
+ : base(speakerToy, AdminToyType.Speaker)
+ {
+ Base = speakerToy;
+ encoder = new OpusEncoder(OpusApplicationType.Audio);
+ AdminToyBase.OnRemoved += OnToyRemoved;
+ }
///
/// Gets the prefab.
@@ -38,6 +72,57 @@ 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; }
+
+ ///
+ /// 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 list of target players who will hear the audio played by this speaker when is set to .
+ ///
+ public List 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
+ {
+ get => 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 => playBackRoutine.IsAliveAndPaused = value;
+ }
+
///
/// Gets or sets the volume of the audio source.
///
@@ -109,7 +194,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 +223,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 +247,189 @@ 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 PlayWav(string path, bool stream = true, bool destroyAfter = false, bool loop = false)
+ {
+ Stop();
+
+ Loop = loop;
+ DestroyAfter = destroyAfter;
+ source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(WavToPcm(path));
+ playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject));
+ }
+
+ ///
+ /// Stops playback.
+ ///
+ public void Stop()
+ {
+ if (playBackRoutine.IsRunning)
+ Timing.KillCoroutines(playBackRoutine);
+
+ source?.Dispose();
+ source = null;
+ }
+
+ ///
+ /// Skips the WAV header.
+ ///
+ /// The binary reader.
+ internal static void SkipWavHeader(BinaryReader br)
+ {
+ br.ReadBytes(12);
+
+ while (true)
+ {
+ string chunk = new(br.ReadChars(4));
+ int size = br.ReadInt32();
+
+ if (chunk == "fmt ")
+ {
+ short format = br.ReadInt16();
+ short channels = br.ReadInt16();
+ int rate = br.ReadInt32();
+ br.ReadInt32();
+ br.ReadInt16();
+ short bits = br.ReadInt16();
+
+ if (format != 1 || channels != 1 || rate != SampleRate || bits != 16)
+ Log.Error("WAV must be PCM16 mono 48kHz");
+
+ br.BaseStream.Position += size - 16;
+ }
+ else if (chunk == "data")
+ {
+ return;
+ }
+ else
+ {
+ br.BaseStream.Position += size;
+ }
+ }
+ }
+
+ private IEnumerator PlayBackCoroutine()
+ {
+ timeAccumulator = 0f;
+
+ while (true)
+ {
+ timeAccumulator += Time.deltaTime;
+
+ while (timeAccumulator >= FrameTime)
+ {
+ timeAccumulator -= FrameTime;
+
+ int read = source.Read(frame, 0, FrameSize);
+
+ if (read < FrameSize)
+ Array.Clear(frame, read, FrameSize - read);
+
+ int len = encoder.Encode(frame, encoded);
+
+ if (len > 2)
+ SendPacket(len);
+
+ if (!source.Ended)
+ continue;
+
+ if (Loop)
+ {
+ source.Reset();
+ timeAccumulator = 0f;
+ break;
+ }
+
+ if (DestroyAfter)
+ {
+ NetworkServer.Destroy(GameObject);
+ }
+
+ yield break;
+ }
+
+ yield return Timing.WaitForOneFrame;
+ }
+ }
+
+ private void SendPacket(int len)
+ {
+ AudioMessage msg = new(ControllerId, encoded, len);
+
+ switch(PlayMode)
+ {
+ case SpeakerPlayMode.Global:
+ NetworkServer.SendToReady(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 writer2 = NetworkWriterPool.Get())
+ {
+ NetworkMessages.Pack(msg, writer2);
+ ArraySegment segment = writer2.ToArraySegment();
+
+ foreach (Player ply in Player.List)
+ {
+ if (Predicate(ply))
+ ply.Connection.Send(segment, Channel);
+ }
+ }
+
+ break;
+ }
+ }
+
+ private float[] WavToPcm(string path)
+ {
+ using FileStream fs = File.OpenRead(path);
+ using BinaryReader br = new(fs);
+
+ SkipWavHeader(br);
+
+ int samples = (int)((fs.Length - fs.Position) / 2);
+ float[] pcm = new float[samples];
+
+ for (int i = 0; i < samples; i++)
+ pcm[i] = br.ReadInt16() / 32768f;
+
+ return pcm;
+ }
+
+ private void OnToyRemoved(AdminToyBase toy)
+ {
+ if (toy != Base)
+ return;
+
+ Dispose();
+ }
+
+ private void Dispose()
+ {
+ AdminToyBase.OnRemoved -= OnToyRemoved;
+
+ Stop();
+ encoder?.Dispose();
+ }
}
}
diff --git a/EXILED/Exiled.API/Interfaces/IPcmSource.cs b/EXILED/Exiled.API/Interfaces/IPcmSource.cs
new file mode 100644
index 0000000000..59cdf6b88d
--- /dev/null
+++ b/EXILED/Exiled.API/Interfaces/IPcmSource.cs
@@ -0,0 +1,36 @@
+// -----------------------------------------------------------------------
+//
+// 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; }
+
+ ///
+ /// 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);
+
+ ///
+ /// Resets the PCM source to its initial state, allowing reading from the beginning.
+ ///
+ void Reset();
+ }
+}
From 959fe93e80df663a38d66d6cf30a7703b92e8e96 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Sat, 3 Jan 2026 14:56:05 +0300
Subject: [PATCH 02/38] .
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 45d736a98e..24e877511f 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -33,8 +33,7 @@ namespace Exiled.API.Features.Toys
using Object = UnityEngine.Object;
///
- /// Represents a speaker toy that can play audio in the game world.
- /// Provides methods for playing, stopping, and controlling audio playback, as well as managing playback settings.
+ /// A wrapper class for .
///
public class Speaker : AdminToy, IWrapper
{
From 0c798d4d671d3a473f564d13845b0f5cb994b9d8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Sat, 3 Jan 2026 15:05:55 +0300
Subject: [PATCH 03/38] chore: trigger CI
From f02fd3b66ce604952c86dca3f016a84b67294d23 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Sat, 3 Jan 2026 15:12:56 +0300
Subject: [PATCH 04/38] added missing doc
---
.../Features/Audio/PreloadedPcmSource.cs | 20 +++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
index 3e181704b9..9274003772 100644
--- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
@@ -35,7 +35,9 @@ public PreloadedPcmSource(float[] pcm)
data = pcm;
}
- ///
+ ///
+ /// Gets a value indicating whether the end of the PCM data buffer has been reached.
+ ///
public bool Ended
{
get
@@ -44,7 +46,13 @@ public bool Ended
}
}
- ///
+ ///
+ /// 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);
@@ -54,13 +62,17 @@ public int Read(float[] buffer, int offset, int count)
return read;
}
- ///
+ ///
+ /// Resets the read position to the beginning of the PCM data buffer.
+ ///
public void Reset()
{
pos = 0;
}
- ///
+ ///
+ /// Releases all resources used by the .
+ ///
public void Dispose()
{
}
From b93a35074c0ed6e32397bfed5f90ca7a4bcdce55 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Sat, 3 Jan 2026 15:44:22 +0300
Subject: [PATCH 05/38] Change default stream parameter to false in PlayWav
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 24e877511f..7b718d3b35 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -254,7 +254,7 @@ public static void Play(AudioMessage message, IEnumerable targets = null
/// Whether to stream the audio or preload it.
/// Whether to destroy the speaker after playback.
/// Whether to loop the audio.
- public void PlayWav(string path, bool stream = true, bool destroyAfter = false, bool loop = false)
+ public void PlayWav(string path, bool stream = false, bool destroyAfter = false, bool loop = false)
{
Stop();
From 63fc8bf575b6d3c2e38a0c1b95dc739ffc10c1ef Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Sat, 3 Jan 2026 15:51:12 +0300
Subject: [PATCH 06/38] Improve XML documentation for Channel property
Updated XML documentation for the Channel property to include a reference to Channels.
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 7b718d3b35..40f9bd9ebf 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -72,7 +72,7 @@ internal Speaker(SpeakerToy speakerToy)
public SpeakerToy Base { get; }
///
- /// Gets or sets the network channel used for sending audio packets from this speaker.
+ /// Gets or sets the network channel used for sending audio packets from this speaker .
///
public int Channel { get; set; }
From a2d006e5cd58176a63754ec6616771a1000b075f Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Sat, 3 Jan 2026 21:09:18 +0300
Subject: [PATCH 07/38] 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
---
.../Features/Audio/PreloadedPcmSource.cs | 8 +-
.../Features/Audio/WavStreamSource.cs | 5 +-
.../Exiled.API/Features/Audio/WavUtility.cs | 81 +++++++++++++++++++
EXILED/Exiled.API/Features/Toys/Speaker.cs | 61 +-------------
4 files changed, 90 insertions(+), 65 deletions(-)
create mode 100644 EXILED/Exiled.API/Features/Audio/WavUtility.cs
diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
index 9274003772..1513b0d0ce 100644
--- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
@@ -14,7 +14,7 @@ namespace Exiled.API.Features.Audio
///
/// Represents a preloaded PCM audio source.
///
- public class PreloadedPcmSource : IPcmSource
+ public sealed class PreloadedPcmSource : IPcmSource
{
///
/// The PCM data buffer.
@@ -29,10 +29,10 @@ public class PreloadedPcmSource : IPcmSource
///
/// Initializes a new instance of the class.
///
- /// The PCM data to preload.
- public PreloadedPcmSource(float[] pcm)
+ /// The file path to the WAV audio file to preload as PCM data.
+ public PreloadedPcmSource(string path)
{
- data = pcm;
+ data = WavUtility.WavToPcm(path);
}
///
diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
index 01f3944297..bbf85cd08b 100644
--- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
@@ -9,13 +9,12 @@ namespace Exiled.API.Features.Audio
{
using System.IO;
- using Exiled.API.Features.Toys;
using Exiled.API.Interfaces;
///
/// Provides a PCM audio source from a WAV file stream.
///
- public class WavStreamSource : IPcmSource
+ public sealed class WavStreamSource : IPcmSource
{
private readonly long endPosition;
private readonly long startPosition;
@@ -28,7 +27,7 @@ public class WavStreamSource : IPcmSource
public WavStreamSource(string path)
{
reader = new BinaryReader(File.OpenRead(path));
- Speaker.SkipWavHeader(reader);
+ WavUtility.SkipHeader(reader);
startPosition = reader.BaseStream.Position;
endPosition = reader.BaseStream.Length;
}
diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
new file mode 100644
index 0000000000..5603db3632
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
@@ -0,0 +1,81 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio
+{
+ using System.IO;
+
+ using VoiceChat;
+
+ ///
+ /// Provides utility methods for working with WAV audio files, such as converting to PCM data and validating headers.
+ ///
+ public static class WavUtility
+ {
+ ///
+ /// Converts a WAV file at the specified path to a PCM float array.
+ ///
+ /// The file path of the WAV file to convert.
+ /// An array of floats representing the PCM data.
+ public static float[] WavToPcm(string path)
+ {
+ using FileStream fs = File.OpenRead(path);
+ using BinaryReader br = new(fs);
+
+ SkipHeader(br);
+
+ int samples = (int)((fs.Length - fs.Position) / 2);
+ float[] pcm = new float[samples];
+
+ for (int i = 0; i < samples; i++)
+ pcm[i] = br.ReadInt16() / 32768f;
+
+ return pcm;
+ }
+
+ ///
+ /// Skips the WAV file header and validates that the format is PCM16 mono with the specified sample rate.
+ ///
+ /// The to read from.
+ ///
+ /// Thrown if the WAV file is not PCM16, mono, or does not match the expected sample rate.
+ ///
+ public static void SkipHeader(BinaryReader br)
+ {
+ br.ReadBytes(12);
+
+ while (true)
+ {
+ string chunk = new(br.ReadChars(4));
+ int size = br.ReadInt32();
+
+ if (chunk == "fmt ")
+ {
+ short format = br.ReadInt16();
+ short channels = br.ReadInt16();
+ int rate = br.ReadInt32();
+ br.ReadInt32();
+ br.ReadInt16();
+ short bits = br.ReadInt16();
+
+ if (format != 1 || channels != 1 || rate != VoiceChatSettings.SampleRate || bits != 16)
+ Log.Error($"Invalid WAV format (format={format}, channels={channels}, rate={rate}, bits={bits}). Expected PCM16, mono and {VoiceChatSettings.SampleRate}Hz.");
+
+ br.BaseStream.Position += size - 16;
+ }
+ else if (chunk == "data")
+ {
+ return;
+ }
+ else
+ {
+ br.BaseStream.Position += size;
+ }
+ }
+ }
+ }
+}
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 40f9bd9ebf..aa99c74d25 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -37,9 +37,8 @@ namespace Exiled.API.Features.Toys
///
public class Speaker : AdminToy, IWrapper
{
- private const int SampleRate = VoiceChatSettings.SampleRate;
private const int FrameSize = VoiceChatSettings.PacketSizePerChannel;
- private const float FrameTime = (float)FrameSize / SampleRate;
+ private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate;
private readonly OpusEncoder encoder;
private readonly float[] frame = new float[FrameSize];
@@ -94,7 +93,7 @@ internal Speaker(SpeakerToy speakerToy)
///
/// Gets or sets the list of target players who will hear the audio played by this speaker when is set to .
///
- public List TargetPlayers { get; set; }
+ public HashSet TargetPlayers { get; set; }
///
/// Gets or sets the predicate used to determine which players will hear the audio when is set to .
@@ -260,7 +259,7 @@ public void PlayWav(string path, bool stream = false, bool destroyAfter = false,
Loop = loop;
DestroyAfter = destroyAfter;
- source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(WavToPcm(path));
+ source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path);
playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject));
}
@@ -276,44 +275,6 @@ public void Stop()
source = null;
}
- ///
- /// Skips the WAV header.
- ///
- /// The binary reader.
- internal static void SkipWavHeader(BinaryReader br)
- {
- br.ReadBytes(12);
-
- while (true)
- {
- string chunk = new(br.ReadChars(4));
- int size = br.ReadInt32();
-
- if (chunk == "fmt ")
- {
- short format = br.ReadInt16();
- short channels = br.ReadInt16();
- int rate = br.ReadInt32();
- br.ReadInt32();
- br.ReadInt16();
- short bits = br.ReadInt16();
-
- if (format != 1 || channels != 1 || rate != SampleRate || bits != 16)
- Log.Error("WAV must be PCM16 mono 48kHz");
-
- br.BaseStream.Position += size - 16;
- }
- else if (chunk == "data")
- {
- return;
- }
- else
- {
- br.BaseStream.Position += size;
- }
- }
- }
-
private IEnumerator PlayBackCoroutine()
{
timeAccumulator = 0f;
@@ -399,22 +360,6 @@ private void SendPacket(int len)
}
}
- private float[] WavToPcm(string path)
- {
- using FileStream fs = File.OpenRead(path);
- using BinaryReader br = new(fs);
-
- SkipWavHeader(br);
-
- int samples = (int)((fs.Length - fs.Position) / 2);
- float[] pcm = new float[samples];
-
- for (int i = 0; i < samples; i++)
- pcm[i] = br.ReadInt16() / 32768f;
-
- return pcm;
- }
-
private void OnToyRemoved(AdminToyBase toy)
{
if (toy != Base)
From 67b3348d323ea1d5c694e1b06fa861b54f245070 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Sat, 3 Jan 2026 23:56:59 +0300
Subject: [PATCH 08/38] Update Speaker.cs
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index aa99c74d25..a03265c7c3 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -344,10 +344,10 @@ private void SendPacket(int len)
break;
case SpeakerPlayMode.Predicate:
- using (NetworkWriterPooled writer2 = NetworkWriterPool.Get())
+ using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
- NetworkMessages.Pack(msg, writer2);
- ArraySegment segment = writer2.ToArraySegment();
+ NetworkMessages.Pack(msg, writer);
+ ArraySegment segment = writer.ToArraySegment();
foreach (Player ply in Player.List)
{
From 21c91cce3559a8d1946263ed5c554443125d896d Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Sun, 4 Jan 2026 00:37:31 +0300
Subject: [PATCH 09/38] Update Speaker.cs
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index a03265c7c3..50046ea06e 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -255,6 +255,12 @@ public static void Play(AudioMessage message, IEnumerable targets = null
/// Whether to loop the audio.
public void PlayWav(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.");
+
Stop();
Loop = loop;
From 052e46dd5eb316a1536c4b70909989020d92378f Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Sun, 4 Jan 2026 02:38:21 +0300
Subject: [PATCH 10/38] Update performance
---
.../Features/Audio/WavStreamSource.cs | 30 ++++++++++++++-----
.../Exiled.API/Features/Audio/WavUtility.cs | 20 +++++++++----
EXILED/Exiled.API/Features/Toys/Speaker.cs | 3 +-
3 files changed, 38 insertions(+), 15 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
index bbf85cd08b..0f7058ab3b 100644
--- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
@@ -7,10 +7,14 @@
namespace Exiled.API.Features.Audio
{
+ using System;
using System.IO;
+ using System.Runtime.InteropServices;
using Exiled.API.Interfaces;
+ using VoiceChat;
+
///
/// Provides a PCM audio source from a WAV file stream.
///
@@ -19,6 +23,7 @@ public sealed class WavStreamSource : IPcmSource
private readonly long endPosition;
private readonly long startPosition;
private readonly BinaryReader reader;
+ private readonly byte[] readBuffer = new byte[VoiceChatSettings.PacketSizePerChannel * 2];
///
/// Initializes a new instance of the class.
@@ -52,15 +57,26 @@ public bool Ended
/// The number of samples read.
public int Read(float[] buffer, int offset, int count)
{
- int i = 0;
+ int bytesNeeded = count * 2;
- while (i < count && reader.BaseStream.Position < endPosition)
- {
- buffer[offset + i] = reader.ReadInt16() / 32768f;
- i++;
- }
+ if (bytesNeeded > readBuffer.Length)
+ bytesNeeded = readBuffer.Length;
+
+ int bytesRead = reader.Read(readBuffer, 0, bytesNeeded);
+ if (bytesRead == 0)
+ return 0;
+
+ if (bytesRead % 2 != 0)
+ bytesRead--;
+
+ Span byteSpan = readBuffer.AsSpan(0, bytesRead);
+ Span shortSpan = MemoryMarshal.Cast(byteSpan);
+
+ int samplesRead = shortSpan.Length;
+ for (int i = 0; i < samplesRead; i++)
+ buffer[offset + i] = shortSpan[i] / 32768f;
- return i;
+ return samplesRead;
}
///
diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
index 5603db3632..8b00e8a8f6 100644
--- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
@@ -7,7 +7,9 @@
namespace Exiled.API.Features.Audio
{
+ using System;
using System.IO;
+ using System.Runtime.InteropServices;
using VoiceChat;
@@ -23,16 +25,22 @@ public static class WavUtility
/// An array of floats representing the PCM data.
public static float[] WavToPcm(string path)
{
- using FileStream fs = File.OpenRead(path);
- using BinaryReader br = new(fs);
+ byte[] fileBytes = File.ReadAllBytes(path);
+
+ using MemoryStream ms = new(fileBytes);
+ using BinaryReader br = new(ms);
SkipHeader(br);
- int samples = (int)((fs.Length - fs.Position) / 2);
- float[] pcm = new float[samples];
+ int headerOffset = (int)ms.Position;
+ int dataLength = fileBytes.Length - headerOffset;
+
+ Span audioDataSpan = fileBytes.AsSpan(headerOffset, dataLength);
+ Span samples = MemoryMarshal.Cast(audioDataSpan);
- for (int i = 0; i < samples; i++)
- pcm[i] = br.ReadInt16() / 32768f;
+ float[] pcm = new float[samples.Length];
+ for (int i = 0; i < samples.Length; i++)
+ pcm[i] = samples[i] / 32768f;
return pcm;
}
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 50046ea06e..a64c9b2e42 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -309,8 +309,7 @@ private IEnumerator PlayBackCoroutine()
if (Loop)
{
source.Reset();
- timeAccumulator = 0f;
- break;
+ continue;
}
if (DestroyAfter)
From 51298eef23e173786b1c53f7d1dcc9abb274162e Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Sun, 4 Jan 2026 12:38:55 +0300
Subject: [PATCH 11/38] Update Speaker.cs
Update PreloadedPcmSource.cs
Added another constructor for public usages
Update PreloadedPcmSource.cs
---
EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs | 6 +++---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
index 1513b0d0ce..e078b8c6ce 100644
--- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
@@ -29,10 +29,10 @@ public sealed class PreloadedPcmSource : IPcmSource
///
/// Initializes a new instance of the class.
///
- /// The file path to the WAV audio file to preload as PCM data.
- public PreloadedPcmSource(string path)
+ /// The raw PCM float array.
+ public PreloadedPcmSource(float[] pcmData)
{
- data = WavUtility.WavToPcm(path);
+ data = pcmData;
}
///
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index a64c9b2e42..ee76feabea 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -265,7 +265,7 @@ public void PlayWav(string path, bool stream = false, bool destroyAfter = false,
Loop = loop;
DestroyAfter = destroyAfter;
- source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path);
+ source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(WavUtility.WavToPcm(path));
playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject));
}
From 3f7ce95399514ee8452251b4e5bde83669b9ece2 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Mon, 5 Jan 2026 13:20:09 +0300
Subject: [PATCH 12/38] 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.
---
.../Features/Audio/PreloadedPcmSource.cs | 46 ++++++++++++-
.../Features/Audio/WavStreamSource.cs | 35 ++++++++++
EXILED/Exiled.API/Features/Toys/Speaker.cs | 68 ++++++++++++++++++-
EXILED/Exiled.API/Interfaces/IPcmSource.cs | 16 +++++
4 files changed, 159 insertions(+), 6 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
index e078b8c6ce..abfc4ab8c2 100644
--- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
@@ -11,6 +11,8 @@ namespace Exiled.API.Features.Audio
using Exiled.API.Interfaces;
+ using VoiceChat;
+
///
/// Represents a preloaded PCM audio source.
///
@@ -26,6 +28,15 @@ public sealed class PreloadedPcmSource : IPcmSource
///
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.
///
@@ -46,6 +57,20 @@ public bool Ended
}
}
+ ///
+ /// 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.
///
@@ -62,6 +87,23 @@ public int Read(float[] buffer, int offset, int count)
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.
///
@@ -70,9 +112,7 @@ public void Reset()
pos = 0;
}
- ///
- /// Releases all resources used by the .
- ///
+ ///
public void Dispose()
{
}
diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
index 0f7058ab3b..4ed8e566af 100644
--- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
@@ -37,6 +37,20 @@ public WavStreamSource(string path)
endPosition = reader.BaseStream.Length;
}
+ ///
+ /// 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 => (reader.BaseStream.Position - startPosition) / 2.0 / VoiceChatSettings.SampleRate;
+ set => Seek(value);
+ }
+
///
/// Gets a value indicating whether the end of the stream has been reached.
///
@@ -79,6 +93,27 @@ public int Read(float[] buffer, int offset, int count)
return samplesRead;
}
+ ///
+ /// 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--;
+
+ reader.BaseStream.Position = newPos;
+ }
+
///
/// Resets the stream position to the start.
///
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index ee76feabea..0fb781e524 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -60,6 +60,32 @@ internal Speaker(SpeakerToy speakerToy)
AdminToyBase.OnRemoved += OnToyRemoved;
}
+ ///
+ /// 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 starts.
+ ///
+ public event Action OnPlaybackStarted;
+
+ ///
+ /// Invoked when the audio playback stops completely (either manually or end of file).
+ ///
+ public event Action OnPlaybackStopped;
+
+ ///
+ /// Invoked when the audio track finishes playing.
+ /// If looping is enabled, this triggers every time the track restarts.
+ ///
+ public event Action OnPlaybackFinished;
+
///
/// Gets the prefab.
///
@@ -118,7 +144,17 @@ public bool IsPlaying
public bool IsPaused
{
get => playBackRoutine.IsAliveAndPaused;
- set => playBackRoutine.IsAliveAndPaused = value;
+ set
+ {
+ if (!playBackRoutine.IsRunning)
+ return;
+
+ playBackRoutine.IsAliveAndPaused = value;
+ if (value)
+ OnPlaybackPaused?.Invoke();
+ else
+ OnPlaybackResumed?.Invoke();
+ }
}
///
@@ -182,6 +218,26 @@ public byte ControllerId
set => Base.NetworkControllerId = value;
}
+ ///
+ /// 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;
+ }
+ }
+
+ ///
+ /// Gets the total duration of the current track in seconds.
+ /// Returns 0 if not playing.
+ ///
+ public double Duration => source?.TotalDuration ?? 0.0;
+
///
/// Creates a new .
///
@@ -275,7 +331,10 @@ public void PlayWav(string path, bool stream = false, bool destroyAfter = false,
public void Stop()
{
if (playBackRoutine.IsRunning)
+ {
Timing.KillCoroutines(playBackRoutine);
+ OnPlaybackStopped?.Invoke();
+ }
source?.Dispose();
source = null;
@@ -283,6 +342,7 @@ public void Stop()
private IEnumerator PlayBackCoroutine()
{
+ OnPlaybackStarted?.Invoke();
timeAccumulator = 0f;
while (true)
@@ -306,6 +366,8 @@ private IEnumerator PlayBackCoroutine()
if (!source.Ended)
continue;
+ OnPlaybackFinished?.Invoke();
+
if (Loop)
{
source.Reset();
@@ -313,9 +375,9 @@ private IEnumerator PlayBackCoroutine()
}
if (DestroyAfter)
- {
NetworkServer.Destroy(GameObject);
- }
+ else
+ Stop();
yield break;
}
diff --git a/EXILED/Exiled.API/Interfaces/IPcmSource.cs b/EXILED/Exiled.API/Interfaces/IPcmSource.cs
index 59cdf6b88d..680f568410 100644
--- a/EXILED/Exiled.API/Interfaces/IPcmSource.cs
+++ b/EXILED/Exiled.API/Interfaces/IPcmSource.cs
@@ -19,6 +19,16 @@ public interface IPcmSource : IDisposable
///
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.
///
@@ -28,6 +38,12 @@ public interface IPcmSource : IDisposable
/// 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.
///
From 9285154cc96c707accca5890150dc68deca1c02f Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Mon, 5 Jan 2026 13:34:07 +0300
Subject: [PATCH 13/38] Update Speaker.cs
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 0fb781e524..eeb541354d 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -321,7 +321,7 @@ public void PlayWav(string path, bool stream = false, bool destroyAfter = false,
Loop = loop;
DestroyAfter = destroyAfter;
- source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(WavUtility.WavToPcm(path));
+ source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path);
playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject));
}
From 072aba892e3caf2e2150fb64300e8f690524f3cf Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Tue, 6 Jan 2026 13:34:25 +0300
Subject: [PATCH 14/38] Update WavUtility.cs
---
EXILED/Exiled.API/Features/Audio/WavUtility.cs | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
index 8b00e8a8f6..d1085450bd 100644
--- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
@@ -14,7 +14,7 @@ namespace Exiled.API.Features.Audio
using VoiceChat;
///
- /// Provides utility methods for working with WAV audio files, such as converting to PCM data and validating headers.
+ /// Provides utility methods for working with WAV audio files.
///
public static class WavUtility
{
@@ -49,9 +49,6 @@ public static float[] WavToPcm(string path)
/// Skips the WAV file header and validates that the format is PCM16 mono with the specified sample rate.
///
/// The to read from.
- ///
- /// Thrown if the WAV file is not PCM16, mono, or does not match the expected sample rate.
- ///
public static void SkipHeader(BinaryReader br)
{
br.ReadBytes(12);
From 6b97e0f5ab35f94c2170a3c6288d99c70becb232 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Tue, 6 Jan 2026 13:52:17 +0300
Subject: [PATCH 15/38] Update Speaker.cs
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index eeb541354d..406a41e086 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -40,7 +40,7 @@ public class Speaker : AdminToy, IWrapper
private const int FrameSize = VoiceChatSettings.PacketSizePerChannel;
private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate;
- private readonly OpusEncoder encoder;
+ private readonly OpusEncoder encoder = new(OpusApplicationType.Audio);
private readonly float[] frame = new float[FrameSize];
private readonly byte[] encoded = new byte[VoiceChatSettings.MaxEncodedSize];
@@ -56,7 +56,6 @@ internal Speaker(SpeakerToy speakerToy)
: base(speakerToy, AdminToyType.Speaker)
{
Base = speakerToy;
- encoder = new OpusEncoder(OpusApplicationType.Audio);
AdminToyBase.OnRemoved += OnToyRemoved;
}
From 49df481159b1e07706f1cee568b0290a8d12e8dc Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Tue, 6 Jan 2026 16:58:54 +0300
Subject: [PATCH 16/38] Update Speaker.cs
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 38 +++++++++++++++-------
1 file changed, 27 insertions(+), 11 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 406a41e086..c79bf05ef7 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -40,13 +40,13 @@ public class Speaker : AdminToy, IWrapper
private const int FrameSize = VoiceChatSettings.PacketSizePerChannel;
private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate;
- private readonly OpusEncoder encoder = new(OpusApplicationType.Audio);
- private readonly float[] frame = new float[FrameSize];
- private readonly byte[] encoded = new byte[VoiceChatSettings.MaxEncodedSize];
-
+ private float[] frame;
+ private byte[] encoded;
private IPcmSource source;
+ private OpusEncoder encoder;
private float timeAccumulator;
private CoroutineHandle playBackRoutine;
+ private bool isPlayBackInitialized = false;
///
/// Initializes a new instance of the class.
@@ -56,7 +56,6 @@ internal Speaker(SpeakerToy speakerToy)
: base(speakerToy, AdminToyType.Speaker)
{
Base = speakerToy;
- AdminToyBase.OnRemoved += OnToyRemoved;
}
///
@@ -314,8 +313,9 @@ public void PlayWav(string path, bool stream = false, bool destroyAfter = false,
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.");
+ throw new NotSupportedException($"The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file.");
+ InitializePlayBack();
Stop();
Loop = loop;
@@ -339,6 +339,20 @@ public void Stop()
source = null;
}
+ private void InitializePlayBack()
+ {
+ if (isPlayBackInitialized)
+ return;
+
+ isPlayBackInitialized = true;
+
+ frame = new float[FrameSize];
+ encoder = new(OpusApplicationType.Audio);
+ encoded = new byte[VoiceChatSettings.MaxEncodedSize];
+
+ AdminToyBase.OnRemoved += OnToyRemoved;
+ }
+
private IEnumerator PlayBackCoroutine()
{
OnPlaybackStarted?.Invoke();
@@ -431,15 +445,17 @@ private void OnToyRemoved(AdminToyBase toy)
if (toy != Base)
return;
- Dispose();
- }
-
- private void Dispose()
- {
AdminToyBase.OnRemoved -= OnToyRemoved;
+ if (!isPlayBackInitialized)
+ return;
+
Stop();
encoder?.Dispose();
+ encoder = null;
+ frame = null;
+ encoded = null;
+ isPlayBackInitialized = false;
}
}
}
From ccca1abadbb26257c8bee0bda5448e252a286573 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Tue, 6 Jan 2026 17:14:10 +0300
Subject: [PATCH 17/38] Update Speaker.cs
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index c79bf05ef7..3195bdce3d 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -147,6 +147,9 @@ public bool IsPaused
if (!playBackRoutine.IsRunning)
return;
+ if (playBackRoutine.IsAliveAndPaused == value)
+ return;
+
playBackRoutine.IsAliveAndPaused = value;
if (value)
OnPlaybackPaused?.Invoke();
@@ -447,14 +450,12 @@ private void OnToyRemoved(AdminToyBase toy)
AdminToyBase.OnRemoved -= OnToyRemoved;
- if (!isPlayBackInitialized)
- return;
-
Stop();
encoder?.Dispose();
encoder = null;
frame = null;
encoded = null;
+
isPlayBackInitialized = false;
}
}
From 85193d984e1c143754390b93ad6df8470805c118 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Wed, 7 Jan 2026 00:01:59 +0300
Subject: [PATCH 18/38] Update Speaker.cs
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 3195bdce3d..2948694b1a 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -237,7 +237,10 @@ public double CurrentTime
/// Gets the total duration of the current track in seconds.
/// Returns 0 if not playing.
///
- public double Duration => source?.TotalDuration ?? 0.0;
+ public double Duration
+ {
+ get => source?.TotalDuration ?? 0.0;
+ }
///
/// Creates a new .
From 89fe70ddcb8302eb9e2d67349c3a803a70a05e8a Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Wed, 7 Jan 2026 15:27:52 +0300
Subject: [PATCH 19/38] Added OnPlaybackLooped event
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 20 +++++++++++++-------
1 file changed, 13 insertions(+), 7 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 2948694b1a..5e036d91b0 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -58,6 +58,11 @@ internal Speaker(SpeakerToy speakerToy)
Base = speakerToy;
}
+ ///
+ /// Invoked when the audio playback starts.
+ ///
+ public event Action OnPlaybackStarted;
+
///
/// Invoked when the audio playback is paused.
///
@@ -69,20 +74,20 @@ internal Speaker(SpeakerToy speakerToy)
public event Action OnPlaybackResumed;
///
- /// Invoked when the audio playback starts.
+ /// Invoked when the audio playback loops back to the beginning.
///
- public event Action OnPlaybackStarted;
+ public event Action OnPlaybackLooped;
///
- /// Invoked when the audio playback stops completely (either manually or end of file).
+ /// Invoked when the audio track finishes playing.
+ /// If looping is enabled, this triggers every time the track finished.
///
- public event Action OnPlaybackStopped;
+ public event Action OnPlaybackFinished;
///
- /// Invoked when the audio track finishes playing.
- /// If looping is enabled, this triggers every time the track restarts.
+ /// Invoked when the audio playback stops completely (either manually or end of file).
///
- public event Action OnPlaybackFinished;
+ public event Action OnPlaybackStopped;
///
/// Gets the prefab.
@@ -390,6 +395,7 @@ private IEnumerator PlayBackCoroutine()
if (Loop)
{
source.Reset();
+ OnPlaybackLooped?.Invoke();
continue;
}
From ae9992f5a2b891d110ad04dc5a1bbd2b67042234 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Mon, 12 Jan 2026 01:41:25 +0300
Subject: [PATCH 20/38] Update Speaker.cs
Refactor: Optimize WavStreamSource with ArrayPool & dynamic buffering
Feat: Add Pitch (it can be reversed if you wish)
Update EXILED.props
---
EXILED/EXILED.props | 2 +-
.../Features/Audio/WavStreamSource.cs | 41 ++++--
EXILED/Exiled.API/Features/Toys/Speaker.cs | 128 ++++++++++++++++--
3 files changed, 147 insertions(+), 24 deletions(-)
diff --git a/EXILED/EXILED.props b/EXILED/EXILED.props
index edc17d4fb2..b3cea192e3 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/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
index 4ed8e566af..2ff35f6623 100644
--- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
@@ -8,6 +8,7 @@
namespace Exiled.API.Features.Audio
{
using System;
+ using System.Buffers;
using System.IO;
using System.Runtime.InteropServices;
@@ -23,7 +24,6 @@ public sealed class WavStreamSource : IPcmSource
private readonly long endPosition;
private readonly long startPosition;
private readonly BinaryReader reader;
- private readonly byte[] readBuffer = new byte[VoiceChatSettings.PacketSizePerChannel * 2];
///
/// Initializes a new instance of the class.
@@ -73,24 +73,36 @@ public int Read(float[] buffer, int offset, int count)
{
int bytesNeeded = count * 2;
- if (bytesNeeded > readBuffer.Length)
- bytesNeeded = readBuffer.Length;
+ byte[] tempBuffer = ArrayPool.Shared.Rent(bytesNeeded);
- int bytesRead = reader.Read(readBuffer, 0, bytesNeeded);
- if (bytesRead == 0)
- return 0;
+ try
+ {
+ int bytesRead = reader.Read(tempBuffer, 0, bytesNeeded);
+
+ if (bytesRead == 0)
+ return 0;
- if (bytesRead % 2 != 0)
- bytesRead--;
+ if (bytesRead % 2 != 0)
+ bytesRead--;
- Span byteSpan = readBuffer.AsSpan(0, bytesRead);
- Span shortSpan = MemoryMarshal.Cast(byteSpan);
+ Span byteSpan = tempBuffer.AsSpan(0, bytesRead);
+ Span shortSpan = MemoryMarshal.Cast(byteSpan);
- int samplesRead = shortSpan.Length;
- for (int i = 0; i < samplesRead; i++)
- buffer[offset + i] = shortSpan[i] / 32768f;
+ int samplesRead = shortSpan.Length;
+ for (int i = 0; i < samplesRead; i++)
+ {
+ if (offset + i >= buffer.Length)
+ break;
- return samplesRead;
+ buffer[offset + i] = shortSpan[i] / 32768f;
+ }
+
+ return samplesRead;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(tempBuffer);
+ }
}
///
@@ -105,6 +117,7 @@ public void Seek(double seconds)
long newPos = startPosition + targetByte;
if (newPos > endPosition)
newPos = endPosition;
+
if (newPos < startPosition)
newPos = startPosition;
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 5e036d91b0..91f1c62a72 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -42,10 +42,16 @@ public class Speaker : AdminToy, IWrapper
private float[] frame;
private byte[] encoded;
+ private float[] resampleBuffer;
+
+ private double resampleTime;
+ private int resampleBufferFilled;
+
private IPcmSource source;
private OpusEncoder encoder;
- private float timeAccumulator;
private CoroutineHandle playBackRoutine;
+
+ private bool isPitchDefault = true;
private bool isPlayBackInitialized = false;
///
@@ -104,6 +110,24 @@ internal Speaker(SpeakerToy speakerToy)
///
public int Channel { get; set; }
+ ///
+ /// Gets or sets the playback pitch.
+ ///
+ public float Pitch
+ {
+ get;
+ set
+ {
+ field = value;
+ isPitchDefault = Math.Abs(field - 1.0f) < 0.0001f;
+ if (isPitchDefault)
+ {
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+ }
+ }
+ }
+
///
/// Gets or sets a value indicating whether the audio playback should loop when it reaches the end.
///
@@ -234,7 +258,11 @@ public double CurrentTime
set
{
if (source != null)
+ {
source.CurrentTime = value;
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+ }
}
}
@@ -318,7 +346,7 @@ public static void Play(AudioMessage message, IEnumerable targets = null
/// Whether to stream the audio or preload it.
/// Whether to destroy the speaker after playback.
/// Whether to loop the audio.
- public void PlayWav(string path, bool stream = false, bool destroyAfter = false, bool loop = false)
+ 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);
@@ -358,6 +386,7 @@ private void InitializePlayBack()
isPlayBackInitialized = true;
frame = new float[FrameSize];
+ resampleBuffer = Array.Empty();
encoder = new(OpusApplicationType.Audio);
encoded = new byte[VoiceChatSettings.MaxEncodedSize];
@@ -367,7 +396,11 @@ private void InitializePlayBack()
private IEnumerator PlayBackCoroutine()
{
OnPlaybackStarted?.Invoke();
- timeAccumulator = 0f;
+
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+
+ float timeAccumulator = 0f;
while (true)
{
@@ -377,10 +410,16 @@ private IEnumerator PlayBackCoroutine()
{
timeAccumulator -= FrameTime;
- int read = source.Read(frame, 0, FrameSize);
-
- if (read < FrameSize)
- Array.Clear(frame, read, FrameSize - read);
+ 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);
@@ -396,6 +435,7 @@ private IEnumerator PlayBackCoroutine()
{
source.Reset();
OnPlaybackLooped?.Invoke();
+ resampleTime = resampleBufferFilled = 0;
continue;
}
@@ -411,11 +451,81 @@ private IEnumerator PlayBackCoroutine()
}
}
+ 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)
+ switch (PlayMode)
{
case SpeakerPlayMode.Global:
NetworkServer.SendToReady(msg, Channel);
@@ -464,7 +574,7 @@ private void OnToyRemoved(AdminToyBase toy)
encoder = null;
frame = null;
encoded = null;
-
+ resampleBuffer = null;
isPlayBackInitialized = false;
}
}
From 889ca197ee09dac4b0ad59d19283209f06288036 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Mon, 12 Jan 2026 12:13:24 +0300
Subject: [PATCH 21/38] Update to c# lang version 14
---
EXILED/Exiled.API/Features/Core/Generic/EnumClass.cs | 6 +++---
.../Exiled.API/Features/Core/Generic/UnmanagedEnumClass.cs | 6 +++---
2 files changed, 6 insertions(+), 6 deletions(-)
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;
From 895fba89a24344a12b08c1c6aa18eaa78834065c Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Mon, 12 Jan 2026 12:34:01 +0300
Subject: [PATCH 22/38] Update Speaker.cs
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 91f1c62a72..a477ff87b6 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -59,10 +59,7 @@ public class Speaker : AdminToy, IWrapper
///
/// The of the toy.
internal Speaker(SpeakerToy speakerToy)
- : base(speakerToy, AdminToyType.Speaker)
- {
- Base = speakerToy;
- }
+ : base(speakerToy, AdminToyType.Speaker) => Base = speakerToy;
///
/// Invoked when the audio playback starts.
From 3abd0b183233566fdce2dfd05db8e05a9d9cd91c Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Mon, 12 Jan 2026 14:05:42 +0300
Subject: [PATCH 23/38] Enum 4 byte to 1 byte
---
EXILED/Exiled.API/Enums/SpeakerPlayMode.cs | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
index 8ab0a46117..f94c3bc360 100644
--- a/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
+++ b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
@@ -10,21 +10,21 @@ namespace Exiled.API.Enums
///
/// Specifies the available modes for playing audio through a speaker.
///
- public enum SpeakerPlayMode
+ public enum SpeakerPlayMode : byte
{
///
/// Play audio globally to all players.
///
- Global,
+ Global = 0,
///
/// Play audio to a specific list of players.
///
- PlayerList,
+ PlayerList = 1,
///
/// Play audio to players matching a predicate.
///
- Predicate,
+ Predicate = 2,
}
}
From 88497dea037d78420b8e2ca995a8f9a1379523c9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Mon, 12 Jan 2026 16:55:53 +0300
Subject: [PATCH 24/38] -Replaced 'File.ReadAllBytes' with
'ArrayPool.Shared' -Optimized WAV header skipping (string comparisons
to direct uint32 hex checks)
---
.../Exiled.API/Features/Audio/WavUtility.cs | 49 +++++++++++++------
1 file changed, 34 insertions(+), 15 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
index d1085450bd..20e3865ece 100644
--- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
@@ -8,6 +8,7 @@
namespace Exiled.API.Features.Audio
{
using System;
+ using System.Buffers;
using System.IO;
using System.Runtime.InteropServices;
@@ -25,24 +26,36 @@ public static class WavUtility
/// An array of floats representing the PCM data.
public static float[] WavToPcm(string path)
{
- byte[] fileBytes = File.ReadAllBytes(path);
+ using FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
+ int length = (int)fs.Length;
- using MemoryStream ms = new(fileBytes);
- using BinaryReader br = new(ms);
+ byte[] rentedBuffer = ArrayPool.Shared.Rent(length);
- SkipHeader(br);
+ try
+ {
+ int bytesRead = fs.Read(rentedBuffer, 0, length);
+
+ using MemoryStream ms = new(rentedBuffer, 0, bytesRead);
+ using BinaryReader br = new(ms);
- int headerOffset = (int)ms.Position;
- int dataLength = fileBytes.Length - headerOffset;
+ SkipHeader(br);
- Span audioDataSpan = fileBytes.AsSpan(headerOffset, dataLength);
- Span samples = MemoryMarshal.Cast(audioDataSpan);
+ int headerOffset = (int)ms.Position;
+ int dataLength = bytesRead - headerOffset;
- float[] pcm = new float[samples.Length];
- for (int i = 0; i < samples.Length; i++)
- pcm[i] = samples[i] / 32768f;
+ Span audioDataSpan = rentedBuffer.AsSpan(headerOffset, dataLength);
+ Span samples = MemoryMarshal.Cast(audioDataSpan);
- return pcm;
+ float[] pcm = new float[samples.Length];
+ for (int i = 0; i < samples.Length; i++)
+ pcm[i] = samples[i] / 32768f;
+
+ return pcm;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(rentedBuffer);
+ }
}
///
@@ -55,10 +68,11 @@ public static void SkipHeader(BinaryReader br)
while (true)
{
- string chunk = new(br.ReadChars(4));
+ uint chunk = br.ReadUInt32();
int size = br.ReadInt32();
- if (chunk == "fmt ")
+ // 'fmt ' chunk
+ if (chunk == 0x20746D66)
{
short format = br.ReadInt16();
short channels = br.ReadInt16();
@@ -72,7 +86,9 @@ public static void SkipHeader(BinaryReader br)
br.BaseStream.Position += size - 16;
}
- else if (chunk == "data")
+
+ // 'data' chunk
+ else if (chunk == 0x61746164)
{
return;
}
@@ -80,6 +96,9 @@ public static void SkipHeader(BinaryReader br)
{
br.BaseStream.Position += size;
}
+
+ if (br.BaseStream.Position >= br.BaseStream.Length)
+ throw new InvalidDataException("WAV file does not contain a 'data' chunk. File might be corrupted or empty.");
}
}
}
From ae2103fc482dc61b673002458c4a3f93698ac343 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Tue, 13 Jan 2026 01:32:39 +0300
Subject: [PATCH 25/38] Refactor
---
.../Features/Audio/PreloadedPcmSource.cs | 8 +-------
.../Features/Audio/WavStreamSource.cs | 18 +++++++++---------
EXILED/Exiled.API/Features/Toys/Speaker.cs | 10 ++--------
3 files changed, 12 insertions(+), 24 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
index abfc4ab8c2..537c27dfc1 100644
--- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
@@ -49,13 +49,7 @@ public PreloadedPcmSource(float[] pcmData)
///
/// Gets a value indicating whether the end of the PCM data buffer has been reached.
///
- public bool Ended
- {
- get
- {
- return pos >= data.Length;
- }
- }
+ public bool Ended => pos >= data.Length;
///
/// Gets the total duration of the audio in seconds.
diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
index 2ff35f6623..60eeb26832 100644
--- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
@@ -54,13 +54,7 @@ public double CurrentTime
///
/// Gets a value indicating whether the end of the stream has been reached.
///
- public bool Ended
- {
- get
- {
- return reader.BaseStream.Position >= endPosition;
- }
- }
+ public bool Ended => reader.BaseStream.Position >= endPosition;
///
/// Reads PCM data from the stream into the specified buffer.
@@ -130,11 +124,17 @@ public void Seek(double seconds)
///
/// Resets the stream position to the start.
///
- public void Reset() => reader.BaseStream.Position = startPosition;
+ public void Reset()
+ {
+ reader.BaseStream.Position = startPosition;
+ }
///
/// Releases all resources used by the .
///
- public void Dispose() => reader.Dispose();
+ public void Dispose()
+ {
+ reader.Dispose();
+ }
}
}
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index a477ff87b6..6b119a1c71 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -154,10 +154,7 @@ public float Pitch
///
/// Gets a value indicating whether gets is a sound playing on this speaker or not.
///
- public bool IsPlaying
- {
- get => playBackRoutine.IsRunning && !IsPaused;
- }
+ public bool IsPlaying => playBackRoutine.IsRunning && !IsPaused;
///
/// Gets or sets a value indicating whether the playback is paused.
@@ -267,10 +264,7 @@ public double CurrentTime
/// Gets the total duration of the current track in seconds.
/// Returns 0 if not playing.
///
- public double Duration
- {
- get => source?.TotalDuration ?? 0.0;
- }
+ public double Duration => source?.TotalDuration ?? 0.0;
///
/// Creates a new .
From 1bc0db91922b147917634945b4735a25e6c79b50 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Thu, 15 Jan 2026 14:45:14 +0300
Subject: [PATCH 26/38] Initialize Channel property with ReliableOrdered2
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 6b119a1c71..73c76aa71c 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -105,7 +105,7 @@ internal Speaker(SpeakerToy speakerToy)
///
/// Gets or sets the network channel used for sending audio packets from this speaker .
///
- public int Channel { get; set; }
+ public int Channel { get; set; } = Channels.ReliableOrdered2;
///
/// Gets or sets the playback pitch.
From d6cf318041b8b236a3ee9dcc41d85caef54d6cf4 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Thu, 15 Jan 2026 15:16:06 +0300
Subject: [PATCH 27/38] Refactor Speaker class properties
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 88 +++++++++++-----------
1 file changed, 46 insertions(+), 42 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 73c76aa71c..260fbf56aa 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -107,24 +107,6 @@ internal Speaker(SpeakerToy speakerToy)
///
public int Channel { get; set; } = Channels.ReliableOrdered2;
- ///
- /// Gets or sets the playback pitch.
- ///
- public float Pitch
- {
- get;
- set
- {
- field = value;
- isPitchDefault = Math.Abs(field - 1.0f) < 0.0001f;
- if (isPitchDefault)
- {
- resampleTime = 0.0;
- resampleBufferFilled = 0;
- }
- }
- }
-
///
/// Gets or sets a value indicating whether the audio playback should loop when it reaches the end.
///
@@ -181,6 +163,52 @@ public bool IsPaused
}
}
+ ///
+ /// 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 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 = value;
+ isPitchDefault = Math.Abs(field - 1.0f) < 0.0001f;
+ if (isPitchDefault)
+ {
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+ }
+ }
+ }
+
///
/// Gets or sets the volume of the audio source.
///
@@ -242,30 +270,6 @@ public byte ControllerId
set => Base.NetworkControllerId = value;
}
- ///
- /// 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 Duration => source?.TotalDuration ?? 0.0;
-
///
/// Creates a new .
///
From d17ca3612e04adef675f3958e1fb1ec2ae60e884 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Thu, 15 Jan 2026 15:23:28 +0300
Subject: [PATCH 28/38] Update Speaker.cs
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 260fbf56aa..62d0de8751 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -199,7 +199,7 @@ public float Pitch
get;
set
{
- field = value;
+ field = Mathf.Max(0.1f, Mathf.Abs(value));
isPitchDefault = Math.Abs(field - 1.0f) < 0.0001f;
if (isPitchDefault)
{
From 2b1200c1e6cbdbba747f0b3f2c298ff434a9ac88 Mon Sep 17 00:00:00 2001
From: MS-crew <100300664+MS-crew@users.noreply.github.com>
Date: Thu, 15 Jan 2026 19:45:37 +0300
Subject: [PATCH 29/38] Update Speaker.cs
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 62d0de8751..f9d6f42eff 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -200,7 +200,7 @@ public float Pitch
set
{
field = Mathf.Max(0.1f, Mathf.Abs(value));
- isPitchDefault = Math.Abs(field - 1.0f) < 0.0001f;
+ isPitchDefault = Mathf.Abs(field - 1.0f) < 0.0001f;
if (isPitchDefault)
{
resampleTime = 0.0;
From 974b3f18891fe454edc629a6d1b5c561e980d827 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Fri, 16 Jan 2026 16:05:38 +0300
Subject: [PATCH 30/38] =?UTF-8?q?a=C4=9Fhhh?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
EXILED/Exiled.API/Enums/SpeakerPlayMode.cs | 2 +-
.../Features/Audio/PreloadedPcmSource.cs | 2 +-
.../Features/Audio/WavStreamSource.cs | 70 ++++++++++---------
.../Exiled.API/Features/Audio/WavUtility.cs | 51 ++++++++------
EXILED/Exiled.API/Features/Toys/Speaker.cs | 2 +-
5 files changed, 69 insertions(+), 58 deletions(-)
diff --git a/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
index f94c3bc360..60bce04f33 100644
--- a/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
+++ b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
@@ -27,4 +27,4 @@ public enum SpeakerPlayMode : byte
///
Predicate = 2,
}
-}
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
index 537c27dfc1..7be9d09a30 100644
--- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
@@ -111,4 +111,4 @@ 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
index 60eeb26832..d177cd28cf 100644
--- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
@@ -23,7 +23,9 @@ public sealed class WavStreamSource : IPcmSource
{
private readonly long endPosition;
private readonly long startPosition;
- private readonly BinaryReader reader;
+ private readonly FileStream stream;
+
+ private byte[] internalBuffer;
///
/// Initializes a new instance of the class.
@@ -31,10 +33,11 @@ public sealed class WavStreamSource : IPcmSource
/// The path to the audio file.
public WavStreamSource(string path)
{
- reader = new BinaryReader(File.OpenRead(path));
- WavUtility.SkipHeader(reader);
- startPosition = reader.BaseStream.Position;
- endPosition = reader.BaseStream.Length;
+ stream = File.OpenRead(path);
+ WavUtility.SkipHeader(stream);
+ startPosition = stream.Position;
+ endPosition = stream.Length;
+ internalBuffer = ArrayPool.Shared.Rent(VoiceChatSettings.PacketSizePerChannel * 2);
}
///
@@ -47,14 +50,14 @@ public WavStreamSource(string path)
///
public double CurrentTime
{
- get => (reader.BaseStream.Position - startPosition) / 2.0 / VoiceChatSettings.SampleRate;
+ 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 => reader.BaseStream.Position >= endPosition;
+ public bool Ended => stream.Position >= endPosition;
///
/// Reads PCM data from the stream into the specified buffer.
@@ -67,36 +70,30 @@ public int Read(float[] buffer, int offset, int count)
{
int bytesNeeded = count * 2;
- byte[] tempBuffer = ArrayPool.Shared.Rent(bytesNeeded);
-
- try
+ if (internalBuffer.Length < bytesNeeded)
{
- int bytesRead = reader.Read(tempBuffer, 0, bytesNeeded);
+ ArrayPool.Shared.Return(internalBuffer);
+ internalBuffer = ArrayPool.Shared.Rent(bytesNeeded);
+ }
- if (bytesRead == 0)
- return 0;
+ int bytesRead = stream.Read(internalBuffer, 0, bytesNeeded);
- if (bytesRead % 2 != 0)
- bytesRead--;
+ if (bytesRead == 0)
+ return 0;
- Span byteSpan = tempBuffer.AsSpan(0, bytesRead);
- Span shortSpan = MemoryMarshal.Cast(byteSpan);
+ if (bytesRead % 2 != 0)
+ bytesRead--;
- int samplesRead = shortSpan.Length;
- for (int i = 0; i < samplesRead; i++)
- {
- if (offset + i >= buffer.Length)
- break;
+ Span byteSpan = internalBuffer.AsSpan(0, bytesRead);
+ Span shortSpan = MemoryMarshal.Cast(byteSpan);
- buffer[offset + i] = shortSpan[i] / 32768f;
- }
+ int samplesInDestination = buffer.Length - offset;
+ int samplesToWrite = Math.Min(shortSpan.Length, samplesInDestination);
- return samplesRead;
- }
- finally
- {
- ArrayPool.Shared.Return(tempBuffer);
- }
+ for (int i = 0; i < samplesToWrite; i++)
+ buffer[offset + i] = shortSpan[i] / 32768f;
+
+ return samplesToWrite;
}
///
@@ -118,7 +115,7 @@ public void Seek(double seconds)
if (newPos % 2 != 0)
newPos--;
- reader.BaseStream.Position = newPos;
+ stream.Position = newPos;
}
///
@@ -126,7 +123,7 @@ public void Seek(double seconds)
///
public void Reset()
{
- reader.BaseStream.Position = startPosition;
+ stream.Position = startPosition;
}
///
@@ -134,7 +131,12 @@ public void Reset()
///
public void Dispose()
{
- reader.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
index 20e3865ece..777af5b974 100644
--- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
@@ -9,6 +9,7 @@ namespace Exiled.API.Features.Audio
{
using System;
using System.Buffers;
+ using System.Buffers.Binary;
using System.IO;
using System.Runtime.InteropServices;
@@ -36,9 +37,8 @@ public static float[] WavToPcm(string path)
int bytesRead = fs.Read(rentedBuffer, 0, length);
using MemoryStream ms = new(rentedBuffer, 0, bytesRead);
- using BinaryReader br = new(ms);
- SkipHeader(br);
+ SkipHeader(ms);
int headerOffset = (int)ms.Position;
int dataLength = bytesRead - headerOffset;
@@ -47,6 +47,7 @@ public static float[] WavToPcm(string path)
Span samples = MemoryMarshal.Cast(audioDataSpan);
float[] pcm = new float[samples.Length];
+
for (int i = 0; i < samples.Length; i++)
pcm[i] = samples[i] / 32768f;
@@ -61,45 +62,53 @@ public static float[] WavToPcm(string path)
///
/// Skips the WAV file header and validates that the format is PCM16 mono with the specified sample rate.
///
- /// The to read from.
- public static void SkipHeader(BinaryReader br)
+ /// The to read from.
+ public static void SkipHeader(Stream stream)
{
- br.ReadBytes(12);
+ Span headerBuffer = stackalloc byte[12];
+ stream.Read(headerBuffer);
+ Span chunkHeader = stackalloc byte[8];
while (true)
{
- uint chunk = br.ReadUInt32();
- int size = br.ReadInt32();
+ 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 (chunk == 0x20746D66)
+ if (chunkId == 0x20746D66)
{
- short format = br.ReadInt16();
- short channels = br.ReadInt16();
- int rate = br.ReadInt32();
- br.ReadInt32();
- br.ReadInt16();
- short bits = br.ReadInt16();
+ 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)
- Log.Error($"Invalid WAV format (format={format}, channels={channels}, rate={rate}, bits={bits}). Expected PCM16, mono and {VoiceChatSettings.SampleRate}Hz.");
+ throw new InvalidDataException($"Invalid WAV format (format={format}, channels={channels}, rate={rate}, bits={bits}). Expected PCM16, mono and {VoiceChatSettings.SampleRate}Hz.");
- br.BaseStream.Position += size - 16;
+ if (chunkSize > 16)
+ stream.Seek(chunkSize - 16, SeekOrigin.Current);
}
// 'data' chunk
- else if (chunk == 0x61746164)
+ else if (chunkId == 0x61746164)
{
return;
}
else
{
- br.BaseStream.Position += size;
+ stream.Seek(chunkSize, SeekOrigin.Current);
}
- if (br.BaseStream.Position >= br.BaseStream.Length)
- throw new InvalidDataException("WAV file does not contain a 'data' chunk. File might be corrupted or empty.");
+ 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/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index f9d6f42eff..52333f83ef 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -573,4 +573,4 @@ private void OnToyRemoved(AdminToyBase toy)
isPlayBackInitialized = false;
}
}
-}
+}
\ No newline at end of file
From e284fdd679ea09a3e481552aedb3dc52be6eef24 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Fri, 16 Jan 2026 21:11:49 +0300
Subject: [PATCH 31/38] renamed method
---
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 52333f83ef..d47b7606c8 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -349,7 +349,7 @@ public void Play(string path, bool stream = false, bool destroyAfter = false, bo
if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
throw new NotSupportedException($"The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file.");
- InitializePlayBack();
+ TryInitializePlayBack();
Stop();
Loop = loop;
@@ -373,7 +373,7 @@ public void Stop()
source = null;
}
- private void InitializePlayBack()
+ private void TryInitializePlayBack()
{
if (isPlayBackInitialized)
return;
From 83494fa38b0acc1de07435780437bfe06a378f7b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Sat, 17 Jan 2026 12:39:50 +0300
Subject: [PATCH 32/38] Added Target Player Play Mode
---
EXILED/Exiled.API/Enums/SpeakerPlayMode.cs | 9 +++++++--
EXILED/Exiled.API/Features/Toys/Speaker.cs | 11 ++++++++++-
2 files changed, 17 insertions(+), 3 deletions(-)
diff --git a/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
index 60bce04f33..9bafa513e1 100644
--- a/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
+++ b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs
@@ -17,14 +17,19 @@ public enum SpeakerPlayMode : byte
///
Global = 0,
+ ///
+ /// Play audio to a specific player.
+ ///
+ Player = 1,
+
///
/// Play audio to a specific list of players.
///
- PlayerList = 1,
+ PlayerList = 2,
///
/// Play audio to players matching a predicate.
///
- Predicate = 2,
+ Predicate = 3,
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index d47b7606c8..e91276fd87 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -122,6 +122,11 @@ internal Speaker(SpeakerToy speakerToy)
///
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 .
///
@@ -526,6 +531,10 @@ private void SendPacket(int len)
NetworkServer.SendToReady(msg, Channel);
break;
+ case SpeakerPlayMode.Player:
+ TargetPlayer?.Connection.Send(msg, Channel);
+ break;
+
case SpeakerPlayMode.PlayerList:
using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
@@ -534,7 +543,7 @@ private void SendPacket(int len)
foreach (Player ply in TargetPlayers)
{
- ply.Connection.Send(segment, Channel);
+ ply?.Connection.Send(segment, Channel);
}
}
From 60ae7139a16e4c5016510c69455acb5633c991eb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Sun, 18 Jan 2026 01:36:41 +0300
Subject: [PATCH 33/38] .
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index e91276fd87..51e716b48f 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -440,7 +440,7 @@ private IEnumerator PlayBackCoroutine()
}
if (DestroyAfter)
- NetworkServer.Destroy(GameObject);
+ Destroy();
else
Stop();
@@ -574,11 +574,18 @@ private void OnToyRemoved(AdminToyBase toy)
AdminToyBase.OnRemoved -= OnToyRemoved;
Stop();
+
encoder?.Dispose();
encoder = null;
+
frame = null;
encoded = null;
resampleBuffer = null;
+
+ Predicate = null;
+ TargetPlayer = null;
+ TargetPlayers = null;
+
isPlayBackInitialized = false;
}
}
From f6273030a35a417e2f81062607d2be53e4ab7d10 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Tue, 27 Jan 2026 14:16:10 +0300
Subject: [PATCH 34/38] added lasttrack
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 51e716b48f..7c50937027 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -192,6 +192,11 @@ public double CurrentTime
///
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.
///
@@ -358,6 +363,7 @@ public void Play(string path, bool stream = false, bool destroyAfter = false, bo
Stop();
Loop = loop;
+ LastTrack = path;
DestroyAfter = destroyAfter;
source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path);
playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject));
From 1125e193eff112211497f0a23e61743c3686520e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Fri, 30 Jan 2026 01:26:00 +0300
Subject: [PATCH 35/38] perf
---
EXILED/Exiled.API/Features/Audio/WavStreamSource.cs | 6 ++++--
EXILED/Exiled.API/Features/Audio/WavUtility.cs | 4 +++-
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
index d177cd28cf..d910093f1b 100644
--- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
@@ -21,6 +21,8 @@ namespace Exiled.API.Features.Audio
///
public sealed class WavStreamSource : IPcmSource
{
+ private const float Divide = 1f / 32768f;
+
private readonly long endPosition;
private readonly long startPosition;
private readonly FileStream stream;
@@ -33,7 +35,7 @@ public sealed class WavStreamSource : IPcmSource
/// The path to the audio file.
public WavStreamSource(string path)
{
- stream = File.OpenRead(path);
+ stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.SequentialScan);
WavUtility.SkipHeader(stream);
startPosition = stream.Position;
endPosition = stream.Length;
@@ -91,7 +93,7 @@ public int Read(float[] buffer, int offset, int count)
int samplesToWrite = Math.Min(shortSpan.Length, samplesInDestination);
for (int i = 0; i < samplesToWrite; i++)
- buffer[offset + i] = shortSpan[i] / 32768f;
+ buffer[offset + i] = shortSpan[i] * Divide;
return samplesToWrite;
}
diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
index 777af5b974..c1b1bc3f73 100644
--- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
@@ -20,6 +20,8 @@ namespace Exiled.API.Features.Audio
///
public static class WavUtility
{
+ private const float Divide = 1f / 32768f;
+
///
/// Converts a WAV file at the specified path to a PCM float array.
///
@@ -49,7 +51,7 @@ public static float[] WavToPcm(string path)
float[] pcm = new float[samples.Length];
for (int i = 0; i < samples.Length; i++)
- pcm[i] = samples[i] / 32768f;
+ pcm[i] = samples[i] * Divide;
return pcm;
}
From 485184431fce8363a237cd3915f6e9656de5615e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Mon, 2 Feb 2026 22:36:39 +0300
Subject: [PATCH 36/38] =?UTF-8?q?Gc=20halleder=20onlar=C4=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 7c50937027..18d2c7d199 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -582,16 +582,6 @@ private void OnToyRemoved(AdminToyBase toy)
Stop();
encoder?.Dispose();
- encoder = null;
-
- frame = null;
- encoded = null;
- resampleBuffer = null;
-
- Predicate = null;
- TargetPlayer = null;
- TargetPlayers = null;
-
isPlayBackInitialized = false;
}
}
From 26d838afe8e600e926a8a3ed60690af1a11857b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Mon, 2 Feb 2026 22:37:55 +0300
Subject: [PATCH 37/38] =?UTF-8?q?=C3=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
EXILED/Exiled.API/Features/Toys/Speaker.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 18d2c7d199..d6bbbc28ac 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -582,7 +582,6 @@ private void OnToyRemoved(AdminToyBase toy)
Stop();
encoder?.Dispose();
- isPlayBackInitialized = false;
}
}
}
\ No newline at end of file
From 06c0a35fcd88706d5eca18eae66847e48648d6cd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20SAVA=C5=9E?=
Date: Tue, 3 Feb 2026 14:41:58 +0300
Subject: [PATCH 38/38] added lasttrack to finishedTrack
---
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 d6bbbc28ac..4478c1143b 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -85,7 +85,7 @@ internal Speaker(SpeakerToy speakerToy)
/// Invoked when the audio track finishes playing.
/// If looping is enabled, this triggers every time the track finished.
///
- public event Action OnPlaybackFinished;
+ public event Action OnPlaybackFinished;
///
/// Invoked when the audio playback stops completely (either manually or end of file).
@@ -435,7 +435,7 @@ private IEnumerator PlayBackCoroutine()
if (!source.Ended)
continue;
- OnPlaybackFinished?.Invoke();
+ OnPlaybackFinished?.Invoke(LastTrack);
if (Loop)
{