diff --git a/EXILED/Exiled.API/Exiled.API.csproj b/EXILED/Exiled.API/Exiled.API.csproj
index 70f81bf0d1..6dea6426ae 100644
--- a/EXILED/Exiled.API/Exiled.API.csproj
+++ b/EXILED/Exiled.API/Exiled.API.csproj
@@ -40,6 +40,7 @@
+
diff --git a/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs b/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs
new file mode 100644
index 0000000000..2a86024035
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs
@@ -0,0 +1,213 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio
+{
+ using System;
+ using System.Collections.Concurrent;
+ using System.Collections.Generic;
+ using System.IO;
+
+ using Exiled.API.Structs.Audio;
+
+ using MEC;
+
+ using RoundRestarting;
+
+ using UnityEngine.Networking;
+
+ ///
+ /// Manages a global in-memory storage of decoded PCM audio data. Once stored, audio can be played using .
+ ///
+ public static class AudioDataStorage
+ {
+ static AudioDataStorage()
+ {
+ AudioStorage = new();
+ RoundRestart.OnRestartTriggered += OnRoundRestart;
+ }
+
+ ///
+ /// Gets the underlying storage, keyed by name.
+ ///
+ public static ConcurrentDictionary AudioStorage { get; }
+
+ ///
+ /// Gets or sets a value indicating whether the storage is automatically cleared when a round restart is triggered.
+ ///
+ public static bool ClearOnRoundRestart { get; set; } = true;
+
+ ///
+ /// Loads and stores a local .wav file under the specified name.
+ ///
+ /// The unique storage key to assign to this audio.
+ /// The absolute path to the local .wav file.
+ /// true if the file was successfully loaded and stored; otherwise, false.
+ public static bool Add(string name, string path)
+ {
+ if (!ValidateName(name))
+ return false;
+
+ if (AudioStorage.ContainsKey(name))
+ {
+ Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping add.");
+ return false;
+ }
+
+ if (path.StartsWith("http"))
+ {
+ Log.Error($"[AudioDataStorage] '{path}' is a URL. Use AudioDataStorage.AddUrl() for web sources.");
+ return false;
+ }
+
+ if (!File.Exists(path))
+ {
+ Log.Error($"[AudioDataStorage] Local file not found: '{path}'");
+ return false;
+ }
+
+ try
+ {
+ AudioData parsed = WavUtility.WavToPcm(path);
+ return AudioStorage.TryAdd(name, parsed);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[AudioDataStorage] Failed to load '{path}' into storage:\n{ex}");
+ return false;
+ }
+ }
+
+ ///
+ /// Stores raw PCM audio samples under the specified name.
+ ///
+ /// The unique storage key to assign.
+ /// The raw PCM float array to store.
+ /// true if successfully added; otherwise, false.
+ public static bool Add(string name, float[] pcm)
+ {
+ if (pcm == null)
+ {
+ Log.Error($"[AudioDataStorage] Cannot store null array for key '{name}'.");
+ return false;
+ }
+
+ TrackData trackInfo = new()
+ {
+ Title = name,
+ Duration = (double)pcm.Length / VoiceChat.VoiceChatSettings.SampleRate,
+ };
+
+ return Add(name, new AudioData(pcm, trackInfo));
+ }
+
+ ///
+ /// Stores a fully constructed under the specified name.
+ ///
+ /// The unique storage key to assign.
+ /// The to store.
+ /// true if successfully added; otherwise, false.
+ public static bool Add(string name, AudioData audioData)
+ {
+ if (!ValidateName(name))
+ return false;
+
+ if (audioData.Pcm == null || audioData.Pcm.Length == 0)
+ {
+ Log.Error($"[AudioDataStorage] AudioData for key '{name}' has null or empty PCM.");
+ return false;
+ }
+
+ if (AudioStorage.ContainsKey(name))
+ {
+ Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping add.");
+ return false;
+ }
+
+ return AudioStorage.TryAdd(name, audioData);
+ }
+
+ ///
+ /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the storage.
+ ///
+ /// The unique storage key to assign.
+ /// The HTTP or HTTPS URL pointing to a valid .wav file.
+ /// A for the running download coroutine.
+ public static CoroutineHandle AddUrl(string name, string url) => Timing.RunCoroutine(AddUrlCoroutine(name, url));
+
+ ///
+ /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the storage.
+ ///
+ /// The unique storage key to assign.
+ /// The HTTP or HTTPS URL pointing to a valid .wav file.
+ /// A MEC-compatible of .
+ public static IEnumerator AddUrlCoroutine(string name, string url)
+ {
+ if (!ValidateName(name))
+ yield break;
+
+ if (string.IsNullOrEmpty(url) || !url.StartsWith("http"))
+ {
+ Log.Error($"[AudioDataStorage] Invalid URL for key '{name}': '{url}'. Must start with http/https.");
+ yield break;
+ }
+
+ if (AudioStorage.ContainsKey(name))
+ {
+ Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping download.");
+ yield break;
+ }
+
+ using UnityWebRequest www = UnityWebRequest.Get(url);
+ yield return Timing.WaitUntilDone(www.SendWebRequest());
+
+ if (www.result != UnityWebRequest.Result.Success)
+ {
+ Log.Error($"[AudioDataStorage] Download failed for '{url}': {www.error}");
+ yield break;
+ }
+
+ try
+ {
+ AudioData parsed = WavUtility.WavToPcm(www.downloadHandler.data);
+ parsed.TrackInfo.Path = url;
+ AudioStorage.TryAdd(name, parsed);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[AudioDataStorage] Failed to parse downloaded WAV from '{url}':\n{ex}");
+ }
+ }
+
+ ///
+ /// Removes a stored audio entry by name.
+ ///
+ /// The storage name/key to remove.
+ /// true if the entry was found and removed; otherwise, false.
+ public static bool Remove(string name) => AudioStorage.TryRemove(name, out _);
+
+ ///
+ /// Clears all entries from the audio storage, freeing all associated memory.
+ ///
+ public static void Clear() => AudioStorage.Clear();
+
+ private static bool ValidateName(string name)
+ {
+ if (!string.IsNullOrEmpty(name))
+ return true;
+
+ Log.Error("[AudioDataStorage] Storage name (key) cannot be null or empty.");
+ return false;
+ }
+
+ private static void OnRoundRestart()
+ {
+ if (ClearOnRoundRestart)
+ Clear();
+ }
+ }
+}
diff --git a/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs
new file mode 100644
index 0000000000..53fa8124db
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs
@@ -0,0 +1,181 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio.Filters
+{
+ using System;
+
+ using Exiled.API.Interfaces.Audio;
+
+ using UnityEngine;
+
+ ///
+ /// A true DSP Fractional Delay Filter equipped with an RBJ Butterworth Biquad Filter.
+ ///
+ public sealed class EchoFilter : IAudioFilter
+ {
+ private const float MaxDelayMs = 10000f;
+ private readonly float[] delayBuffer;
+ private readonly int maxBufferLength;
+
+ private int writeIndex;
+
+ private float b0;
+ private float b1;
+ private float b2;
+ private float a1;
+ private float a2;
+ private float x1;
+ private float x2;
+ private float y1;
+ private float y2;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The delay time in milliseconds (10 - 10000).
+ /// The feedback multiplier determining how long the echo lasts.
+ /// The volume of the original sound.
+ /// The volume of the echoed sound.
+ /// How much high-frequency is absorbed each bounce (0 = pure digital ring, 1 = heavy muffled echo).
+ public EchoFilter(float delayMs = 300f, float decay = 0.5f, float dry = 1.0f, float wet = 0.5f, float damp = 0.3f)
+ {
+ maxBufferLength = (int)(VoiceChat.VoiceChatSettings.SampleRate * (MaxDelayMs / 1000f));
+ delayBuffer = new float[maxBufferLength];
+
+ writeIndex = 0;
+ x1 = x2 = y1 = y2 = 0f;
+
+ Delay = delayMs;
+ Feedback = decay;
+ DryMix = dry;
+ WetMix = wet;
+ Damping = damp;
+ }
+
+ ///
+ /// Gets or sets the delay time in milliseconds. Dynamically adjusts the read head.
+ ///
+ public float Delay
+ {
+ get => field;
+ set => field = Mathf.Clamp(value, 10f, MaxDelayMs);
+ }
+
+ ///
+ /// Gets or sets the feedback multiplier. Determines how many times the echo repeats before dying out.
+ ///
+ public float Feedback
+ {
+ get => field;
+ set => field = Mathf.Clamp01(value);
+ }
+
+ ///
+ /// Gets or sets the volume of the original (dry) unaffected sound.
+ ///
+ public float DryMix
+ {
+ get => field;
+ set => field = Mathf.Clamp01(value);
+ }
+
+ ///
+ /// Gets or sets the volume of the delayed (wet) echoed sound.
+ ///
+ public float WetMix
+ {
+ get => field;
+ set => field = Mathf.Clamp01(value);
+ }
+
+ ///
+ /// Gets or sets the damping coefficient. Automatically recalculates the RBJ Biquad coefficients.
+ ///
+ public float Damping
+ {
+ get => field;
+ set
+ {
+ field = Mathf.Clamp01(value);
+ CalculateBiquad(field);
+ }
+ }
+
+ ///
+ /// Processes the raw PCM audio frame directly before it is encoded and sending.
+ ///
+ /// The array of PCM audio samples.
+ public void Process(float[] frame)
+ {
+ float currentDelayMs = Delay;
+ float currentFeedback = Feedback;
+ float currentDry = DryMix;
+ float currentWet = WetMix;
+
+ float delaySamples = VoiceChat.VoiceChatSettings.SampleRate * (currentDelayMs / 1000f);
+
+ for (int i = 0; i < frame.Length; i++)
+ {
+ float input = frame[i];
+ float readPos = writeIndex - delaySamples;
+ if (readPos < 0)
+ readPos += maxBufferLength;
+
+ int index1 = (int)readPos;
+ int index2 = (index1 + 1) % maxBufferLength;
+ float frac = readPos - index1;
+
+ float delayedSample = (delayBuffer[index1] * (1f - frac)) + (delayBuffer[index2] * frac);
+ float filteredSample = (b0 * delayedSample) + (b1 * x1) + (b2 * x2) - (a1 * y1) - (a2 * y2);
+
+ x2 = x1;
+ x1 = delayedSample;
+ y2 = y1;
+ y1 = filteredSample;
+
+ float output = (input * currentDry) + (filteredSample * currentWet);
+ delayBuffer[writeIndex] = input + (filteredSample * currentFeedback);
+
+ writeIndex++;
+ if (writeIndex >= maxBufferLength)
+ writeIndex = 0;
+
+ frame[i] = output / (1f + Mathf.Abs(output));
+ }
+ }
+
+ ///
+ public void Reset()
+ {
+ Array.Clear(delayBuffer, 0, delayBuffer.Length);
+ writeIndex = 0;
+ x1 = x2 = y1 = y2 = 0f;
+ }
+
+ ///
+ /// Calculates the Robert Bristow-Johnson (RBJ) Audio EQ parameters for the low-pass filter.
+ ///
+ private void CalculateBiquad(float dampValue)
+ {
+ float cutoffFrequency = Mathf.Lerp(20000f, 500f, dampValue);
+
+ if (cutoffFrequency >= VoiceChat.VoiceChatSettings.SampleRate / 2f)
+ cutoffFrequency = (VoiceChat.VoiceChatSettings.SampleRate / 2f) - 100f;
+
+ float w0 = 2f * Mathf.PI * cutoffFrequency / VoiceChat.VoiceChatSettings.SampleRate;
+ float alpha = Mathf.Sin(w0) / (2f * 0.7071f);
+
+ float a0 = 1f + alpha;
+ b0 = ((1f - Mathf.Cos(w0)) / 2f) / a0;
+ b1 = (1f - Mathf.Cos(w0)) / a0;
+ b2 = ((1f - Mathf.Cos(w0)) / 2f) / a0;
+ a1 = (-2f * Mathf.Cos(w0)) / a0;
+ a2 = (1f - alpha) / a0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs
new file mode 100644
index 0000000000..e2cf32f569
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs
@@ -0,0 +1,299 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio.Filters
+{
+ using System;
+
+ using Exiled.API.Interfaces.Audio;
+
+ using UnityEngine;
+
+ ///
+ /// A true DSP Granular Pitch Shifter based on the smbPitchShift algorithm.
+ ///
+ public sealed class PitchShiftFilter : IAudioFilter
+ {
+ private const int FftFrameSize = 2048;
+ private const int FftFrameSize2 = FftFrameSize / 2;
+ private const int MaxFrameLength = 8192;
+
+ private static readonly float[] HannWindow = BuildHannWindow();
+
+ private readonly float[] gInFIFO = new float[MaxFrameLength];
+ private readonly float[] gOutFIFO = new float[MaxFrameLength];
+ private readonly float[] gFFTworksp = new float[2 * FftFrameSize];
+ private readonly float[] gLastPhase = new float[FftFrameSize2 + 1];
+ private readonly float[] gSumPhase = new float[FftFrameSize2 + 1];
+ private readonly float[] gOutputAccum = new float[2 * FftFrameSize];
+ private readonly float[] gAnaFreq = new float[FftFrameSize];
+ private readonly float[] gAnaMagn = new float[FftFrameSize];
+ private readonly float[] gSynFreq = new float[FftFrameSize];
+ private readonly float[] gSynMagn = new float[FftFrameSize];
+
+ private readonly float[] outputBuffer = new float[MaxFrameLength];
+
+ private readonly float[] twiddleCos;
+ private readonly float[] twiddleSin;
+
+ private long gRover = 0;
+
+ private int cachedOversample = -1;
+ private long stepSize;
+ private long inFifoLatency;
+ private float expct;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The pitch multiplier. Above 1.0 for higher pitch, below 1.0 for lower pitch.
+ ///
+ /// The overlap factor controlling quality vs CPU usage. Higher values produce better quality but require more CPU. Must be a power of 2. Typical values: 2 (low CPU), 4 (default, balanced), 8 (high quality).
+ ///
+ public PitchShiftFilter(float pitch = 1.5f, int oversample = 4)
+ {
+ twiddleCos = new float[FftFrameSize];
+ twiddleSin = new float[FftFrameSize];
+ PrecomputeTwiddleFactors();
+
+ Pitch = pitch;
+ Oversample = oversample;
+ }
+
+ ///
+ /// Gets or sets the pitch multiplier applied during playback.
+ /// Values above 1.0 produce a higher (thinner) pitch; values below 1.0 produce a lower (deeper) pitch.
+ ///
+ public float Pitch
+ {
+ get => field;
+ set => field = Mathf.Clamp(value, 0.1f, 4.0f);
+ }
+
+ ///
+ /// Gets or sets the overlap factor controlling quality versus CPU usage.
+ /// Higher values improve quality but increase processing cost. Must be a power of 2.
+ /// Typical values: 2 (low CPU), 4 (balanced, default), 8 (high quality).
+ ///
+ public int Oversample
+ {
+ get => field;
+ set
+ {
+ field = Mathf.Clamp(value, 2, 32);
+ cachedOversample = -1;
+ }
+ }
+
+ ///
+ public void Process(float[] frame)
+ {
+ if (Mathf.Abs(Pitch - 1.0f) < 0.001f)
+ return;
+
+ EnsureOversampleConstants();
+ SmbPitchShift(Pitch, frame.Length, frame, outputBuffer);
+
+ Array.Copy(outputBuffer, frame, frame.Length);
+ }
+
+ ///
+ public void Reset()
+ {
+ Array.Clear(gInFIFO, 0, gInFIFO.Length);
+ Array.Clear(gOutFIFO, 0, gOutFIFO.Length);
+ Array.Clear(gFFTworksp, 0, gFFTworksp.Length);
+ Array.Clear(gLastPhase, 0, gLastPhase.Length);
+ Array.Clear(gSumPhase, 0, gSumPhase.Length);
+ Array.Clear(gOutputAccum, 0, gOutputAccum.Length);
+ Array.Clear(gAnaFreq, 0, gAnaFreq.Length);
+ Array.Clear(gAnaMagn, 0, gAnaMagn.Length);
+ Array.Clear(gSynFreq, 0, gSynFreq.Length);
+ Array.Clear(gSynMagn, 0, gSynMagn.Length);
+ Array.Clear(outputBuffer, 0, outputBuffer.Length);
+
+ gRover = 0;
+ }
+
+ private static float[] BuildHannWindow()
+ {
+ float[] window = new float[FftFrameSize];
+ for (int i = 0; i < FftFrameSize; i++)
+ window[i] = 0.5f - (0.5f * Mathf.Cos(2.0f * Mathf.PI * i / FftFrameSize));
+
+ return window;
+ }
+
+ private void PrecomputeTwiddleFactors()
+ {
+ for (int le = 4, k = 0; le <= FftFrameSize * 2; le <<= 1, k++)
+ {
+ int le2 = le >> 1;
+ float arg = Mathf.PI / (le2 >> 1);
+ twiddleCos[k] = Mathf.Cos(arg);
+ twiddleSin[k] = Mathf.Sin(arg);
+ }
+ }
+
+ private void EnsureOversampleConstants()
+ {
+ if (cachedOversample == Oversample)
+ return;
+
+ cachedOversample = Oversample;
+ stepSize = FftFrameSize / Oversample;
+ inFifoLatency = FftFrameSize - stepSize;
+ expct = 2.0f * Mathf.PI * stepSize / FftFrameSize;
+
+ if (gRover == 0)
+ gRover = inFifoLatency;
+ }
+
+ private void SmbPitchShift(float pitchShift, int numSampsToProcess, float[] indata, float[] outdata)
+ {
+ float freqPerBin = VoiceChat.VoiceChatSettings.SampleRate / (float)FftFrameSize;
+
+ for (int i = 0; i < numSampsToProcess; i++)
+ {
+ gInFIFO[gRover] = indata[i];
+ outdata[i] = gOutFIFO[gRover - inFifoLatency];
+ gRover++;
+
+ if (gRover < FftFrameSize)
+ continue;
+
+ gRover = inFifoLatency;
+
+ for (int k = 0; k < FftFrameSize; k++)
+ {
+ gFFTworksp[2 * k] = gInFIFO[k] * HannWindow[k];
+ gFFTworksp[(2 * k) + 1] = 0.0f;
+ }
+
+ SmbFft(gFFTworksp, -1);
+
+ for (int k = 0; k <= FftFrameSize2; k++)
+ {
+ float real = gFFTworksp[2 * k];
+ float imag = gFFTworksp[(2 * k) + 1];
+
+ float magn = 2.0f * Mathf.Sqrt((real * real) + (imag * imag));
+ float phase = Mathf.Atan2(imag, real);
+
+ float tmp = phase - gLastPhase[k];
+ gLastPhase[k] = phase;
+
+ tmp -= k * expct;
+
+ long qpd = (long)(tmp / Mathf.PI);
+ if (qpd >= 0)
+ qpd += qpd & 1;
+ else
+ qpd -= qpd & 1;
+
+ tmp -= Mathf.PI * qpd;
+
+ tmp = Oversample * tmp / (2.0f * Mathf.PI);
+ tmp = (k * freqPerBin) + (tmp * freqPerBin);
+
+ gAnaMagn[k] = magn;
+ gAnaFreq[k] = tmp;
+ }
+
+ Array.Clear(gSynMagn, 0, FftFrameSize);
+ Array.Clear(gSynFreq, 0, FftFrameSize);
+
+ for (int k = 0; k <= FftFrameSize2; k++)
+ {
+ long index = (long)(k * pitchShift);
+ if (index <= FftFrameSize2)
+ {
+ gSynMagn[index] += gAnaMagn[k];
+ gSynFreq[index] = gAnaFreq[k] * pitchShift;
+ }
+ }
+
+ for (int k = 0; k <= FftFrameSize2; k++)
+ {
+ float magn = gSynMagn[k];
+ float tmp = gSynFreq[k];
+
+ tmp -= k * freqPerBin;
+ tmp /= freqPerBin;
+ tmp = 2.0f * Mathf.PI * tmp / Oversample;
+ tmp += k * expct;
+
+ gSumPhase[k] += tmp;
+
+ gFFTworksp[2 * k] = magn * Mathf.Cos(gSumPhase[k]);
+ gFFTworksp[(2 * k) + 1] = magn * Mathf.Sin(gSumPhase[k]);
+ }
+
+ Array.Clear(gFFTworksp, FftFrameSize + 2, FftFrameSize - 2);
+
+ SmbFft(gFFTworksp, 1);
+
+ for (int k = 0; k < FftFrameSize; k++)
+ gOutputAccum[k] += 2.0f * HannWindow[k] * gFFTworksp[2 * k] / (FftFrameSize2 * Oversample);
+
+ for (int k = 0; k < stepSize; k++)
+ gOutFIFO[k] = gOutputAccum[k];
+
+ Array.Copy(gOutputAccum, stepSize, gOutputAccum, 0, FftFrameSize);
+ Array.Clear(gOutputAccum, FftFrameSize, (int)stepSize);
+
+ Array.Copy(gInFIFO, stepSize, gInFIFO, 0, inFifoLatency);
+ }
+ }
+
+ private void SmbFft(float[] fftBuffer, int sign)
+ {
+ for (int i = 2; i < (2 * FftFrameSize) - 2; i += 2)
+ {
+ int j = 0;
+ for (int bitm = 2; bitm < 2 * FftFrameSize; bitm <<= 1)
+ {
+ if ((i & bitm) != 0)
+ j++;
+ j <<= 1;
+ }
+
+ if (i < j)
+ {
+ (fftBuffer[j], fftBuffer[i]) = (fftBuffer[i], fftBuffer[j]);
+ (fftBuffer[j + 1], fftBuffer[i + 1]) = (fftBuffer[i + 1], fftBuffer[j + 1]);
+ }
+ }
+
+ int stageIndex = 0;
+ for (int le = 4; le <= FftFrameSize * 2; le <<= 1, stageIndex++)
+ {
+ int le2 = le >> 1;
+ float wr = twiddleCos[stageIndex];
+ float wi = twiddleSin[stageIndex] * sign;
+ float ur = 1.0f, ui = 0.0f;
+
+ for (int j = 0; j < le2; j += 2)
+ {
+ for (int i = j; i < 2 * FftFrameSize; i += le)
+ {
+ float tr = (fftBuffer[i + le2] * ur) - (fftBuffer[i + le2 + 1] * ui);
+ float ti = (fftBuffer[i + le2] * ui) + (fftBuffer[i + le2 + 1] * ur);
+ fftBuffer[i + le2] = fftBuffer[i] - tr;
+ fftBuffer[i + le2 + 1] = fftBuffer[i + 1] - ti;
+ fftBuffer[i] += tr;
+ fftBuffer[i + 1] += ti;
+ }
+
+ float newUr = (ur * wr) - (ui * wi);
+ ui = (ur * wi) + (ui * wr);
+ ur = newUr;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs
new file mode 100644
index 0000000000..01ea22267a
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs
@@ -0,0 +1,176 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio.PcmSources
+{
+ using System;
+ using System.IO;
+
+ using Exiled.API.Features.Audio;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
+
+ using VoiceChat;
+
+ ///
+ /// Provides an that plays audio data directly from the for optimized, repeated playback.
+ ///
+ public sealed class CachedPcmSource : IPcmSource
+ {
+ private readonly float[] data;
+ private int pos;
+
+ ///
+ /// Initializes a new instance of the class by fetching already cached audio using its name.
+ ///
+ /// The name/key of the audio in the cache.
+ public CachedPcmSource(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ Log.Error("[CachedPcmSource] Cannot initialize CachedPcmSource. Cache name cannot be null or empty.");
+ throw new ArgumentException("Cache name cannot be null or empty.", nameof(name));
+ }
+
+ if (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio))
+ {
+ Log.Error($"[CachedPcmSource] Audio with name '{name}' not found in AudioDataStorage.");
+ throw new FileNotFoundException($"Audio '{name}' is not cached. Please cache it first using AudioDataStorage");
+ }
+
+ data = cachedAudio.Pcm;
+ TrackInfo = cachedAudio.TrackInfo;
+ }
+
+ ///
+ /// Initializes a new instance of the class. Fetches cached audio or loads a local WAV file into the cache if not present.
+ ///
+ /// The custom name/key to assign to this audio in the cache.
+ /// The absolute path to the local audio file.
+ public CachedPcmSource(string name, string path)
+ {
+ if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(path))
+ {
+ Log.Error($"[CachedPcmSource] Cannot initialize CachedPcmSource. Invalid name: '{name}' or path: '{path}'.");
+ throw new ArgumentException("Name or path cannot be null or empty.");
+ }
+
+ if (!AudioDataStorage.AudioStorage.ContainsKey(name))
+ {
+ if (!AudioDataStorage.Add(name, path))
+ {
+ Log.Error($"[CachedPcmSource] Failed to load local file '{path}' into cache under the name '{name}'.");
+ throw new FileNotFoundException($"Failed to cache and load '{path}'.");
+ }
+ }
+
+ if (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio))
+ {
+ Log.Error($"[CachedPcmSource] Audio with name '{name}' could not be retrieved from storage after adding.");
+ throw new InvalidOperationException($"Failed to retrieve '{name}' from storage after caching.");
+ }
+
+ data = cachedAudio.Pcm;
+ TrackInfo = cachedAudio.TrackInfo;
+ }
+
+ ///
+ /// Initializes a new instance of the class by fetching cached audio or injecting raw PCM samples into the cache if not present.
+ ///
+ /// The custom name/key to assign to this audio in the cache.
+ /// The raw PCM audio samples (float array).
+ public CachedPcmSource(string name, float[] pcm)
+ {
+ if (string.IsNullOrEmpty(name) || pcm == null || pcm.Length == 0)
+ {
+ Log.Error($"[CachedPcmSource] Cannot initialize CachedPcmSource. Invalid name or empty PCM data for '{name}'.");
+ throw new ArgumentException("Name or PCM data cannot be null.");
+ }
+
+ if (!AudioDataStorage.AudioStorage.ContainsKey(name))
+ {
+ if (!AudioDataStorage.Add(name, pcm))
+ {
+ Log.Error($"[CachedPcmSource] Failed to load raw PCM data into cache under the name '{name}'.");
+ throw new InvalidOperationException($"Failed to cache PCM data for '{name}'.");
+ }
+ }
+
+ if (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio))
+ {
+ Log.Error($"[CachedPcmSource] Audio with name '{name}' could not be retrieved from storage after adding.");
+ throw new InvalidOperationException($"Failed to retrieve '{name}' from storage after caching.");
+ }
+
+ data = cachedAudio.Pcm;
+ TrackInfo = cachedAudio.TrackInfo;
+ }
+
+ ///
+ /// Gets the metadata of the loaded track.
+ ///
+ public TrackData TrackInfo { get; }
+
+ ///
+ /// Gets a value indicating whether the end of the PCM data buffer has been reached.
+ ///
+ public bool Ended => pos >= data.Length;
+
+ ///
+ /// Gets the total duration of the audio in seconds.
+ ///
+ public double TotalDuration => (double)data.Length / VoiceChatSettings.SampleRate;
+
+ ///
+ /// Gets or sets the current playback position in seconds.
+ ///
+ public double CurrentTime
+ {
+ get => (double)pos / VoiceChatSettings.SampleRate;
+ set => Seek(value);
+ }
+
+ ///
+ /// Reads a sequence of PCM samples from the cached buffer into the specified array.
+ ///
+ /// The destination array.
+ /// The index to start writing.
+ /// The maximum number of samples to read.
+ /// The actual number of samples read.
+ public int Read(float[] buffer, int offset, int count)
+ {
+ int read = Math.Min(count, data.Length - pos);
+ Array.Copy(data, pos, buffer, offset, read);
+ pos += read;
+
+ return read;
+ }
+
+ ///
+ /// Seeks to the specified position in seconds.
+ ///
+ /// The target position in seconds.
+ public void Seek(double seconds)
+ {
+ long targetIndex = (long)(seconds * VoiceChatSettings.SampleRate);
+ pos = (int)Math.Max(0, Math.Min(targetIndex, data.Length));
+ }
+
+ ///
+ /// Resets the read position to the beginning of the PCM data buffer.
+ ///
+ public void Reset()
+ {
+ pos = 0;
+ }
+
+ ///
+ public void Dispose()
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs
new file mode 100644
index 0000000000..20a04ceb5a
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs
@@ -0,0 +1,168 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio.PcmSources
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
+
+ using UnityEngine;
+
+ ///
+ /// Provides an that dynamically mixes multiple audio sources together in real-time.
+ ///
+ /// This allows playing overlapping sounds (e.g., background music + voice announcements) simultaneously
+ /// through a single speaker without needing multiple Voice Controller IDs.
+ ///
+ ///
+ public sealed class MixerSource : IPcmSource
+ {
+ private readonly List sources = new();
+ private float[] tempBuffer;
+
+ ///
+ /// Initializes a new instance of the class with the specified initial sources.
+ ///
+ /// An array of instances to mix.
+ public MixerSource(params IPcmSource[] initialSources)
+ {
+ if (initialSources != null)
+ sources.AddRange(initialSources.Where(s => s != null));
+
+ TrackInfo = new TrackData { Path = "Audio Mixer", Duration = 0 };
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the mixer should stay alive and output silence even when all internal sources have finished playing.
+ ///
+ public bool KeepAlive { get; set; } = false;
+
+ ///
+ /// Gets the metadata of the mixer track.
+ ///
+ public TrackData TrackInfo { get; }
+
+ ///
+ /// Gets the maximum total duration of all active sources in the mixer, in seconds.
+ ///
+ public double TotalDuration => sources.Count > 0 ? sources.Max(x => x.TotalDuration) : 0.0;
+
+ ///
+ /// Gets or sets the current playback position in seconds across all active sources.
+ ///
+ public double CurrentTime
+ {
+ get => sources.Count > 0 ? sources.Max(x => x.CurrentTime) : 0.0;
+ set => Seek(value);
+ }
+
+ ///
+ /// Gets a value indicating whether all internal sources have ended and is set to false.
+ ///
+ public bool Ended => !KeepAlive && (sources.Count == 0 || sources.All(x => x.Ended));
+
+ ///
+ /// Reads a sequence of mixed PCM samples from all active sources into the specified buffer.
+ ///
+ /// The destination buffer to fill with mixed PCM data.
+ /// The zero-based index in at which to begin writing.
+ /// The maximum number of samples to read and mix.
+ /// The number of samples written to the .
+ public int Read(float[] buffer, int offset, int count)
+ {
+ if (tempBuffer == null || tempBuffer.Length < count)
+ tempBuffer = new float[count];
+
+ Array.Clear(buffer, offset, count);
+ int maxRead = 0;
+
+ for (int i = sources.Count - 1; i >= 0; i--)
+ {
+ IPcmSource src = sources[i];
+
+ if (src.Ended)
+ {
+ src.Dispose();
+ sources.RemoveAt(i);
+ continue;
+ }
+
+ int read = src.Read(tempBuffer, 0, count);
+ if (read > maxRead)
+ maxRead = read;
+
+ for (int j = 0; j < read; j++)
+ buffer[offset + j] += tempBuffer[j];
+ }
+
+ for (int i = 0; i < maxRead; i++)
+ buffer[offset + i] = Mathf.Clamp(buffer[offset + i], -1f, 1f);
+
+ return KeepAlive ? count : maxRead;
+ }
+
+ ///
+ /// Seeks to the specified position in seconds for all active sources in the mixer.
+ ///
+ /// The target position in seconds.
+ public void Seek(double seconds)
+ {
+ foreach (IPcmSource pcmSource in sources)
+ pcmSource.Seek(seconds);
+ }
+
+ ///
+ /// Resets the playback position to the start for all active sources in the mixer.
+ ///
+ public void Reset()
+ {
+ foreach (IPcmSource pcmSource in sources)
+ pcmSource.Reset();
+ }
+
+ ///
+ /// Releases all resources used by the and automatically disposes of all internal sources.
+ ///
+ public void Dispose()
+ {
+ foreach (IPcmSource pcmSource in sources)
+ pcmSource?.Dispose();
+
+ sources.Clear();
+ }
+
+ ///
+ /// Dynamically adds a new to the mixer while it is playing.
+ ///
+ /// The audio source to add.
+ public void AddSource(IPcmSource source)
+ {
+ if (source != null)
+ sources.Add(source);
+ }
+
+ ///
+ /// Dynamically removes an existing from the mixer.
+ ///
+ /// The audio source to remove.
+ /// If true, automatically calls Dispose on the removed source.
+ public void RemoveSource(IPcmSource source, bool dispose = true)
+ {
+ if (source == null)
+ return;
+
+ if (dispose)
+ source.Dispose();
+
+ sources.Remove(source);
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs
new file mode 100644
index 0000000000..bbddb54a75
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs
@@ -0,0 +1,153 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio.PcmSources
+{
+ using System.Buffers;
+ using System.Collections.Concurrent;
+ using System.Collections.Generic;
+
+ using Exiled.API.Features;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
+
+ using LabApi.Events.Arguments.PlayerEvents;
+
+ using VoiceChat;
+ using VoiceChat.Codec;
+
+ ///
+ /// Provides a that captures and decodes live microphone input from a specific player.
+ ///
+ public sealed class PlayerVoiceSource : IPcmSource, ILiveSource
+ {
+ private readonly Player sourcePlayer;
+ private readonly OpusDecoder decoder;
+ private readonly Queue pcmQueue;
+
+ private float[] decodeBuffer;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The player whose voice will be captured.
+ /// If true, prevents the player's original voice message's from being heard while broadcasting.
+ public PlayerVoiceSource(Player player, bool blockOriginalVoice = false)
+ {
+ sourcePlayer = player;
+ BlockOriginalVoice = blockOriginalVoice;
+
+ decoder = new OpusDecoder();
+ pcmQueue = new Queue();
+ decodeBuffer = ArrayPool.Shared.Rent(VoiceChatSettings.PacketSizePerChannel);
+
+ TrackInfo = new TrackData
+ {
+ Path = $"{player.Nickname}-Mic",
+ Duration = double.PositiveInfinity,
+ };
+
+ LabApi.Events.Handlers.PlayerEvents.SendingVoiceMessage += OnVoiceChatting;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the player's original voice chat should be blocked while being broadcasted by this source.
+ ///
+ public bool BlockOriginalVoice { get; set; } = false;
+
+ ///
+ /// Gets the metadata of the streaming track.
+ ///
+ public TrackData TrackInfo { get; }
+
+ ///
+ /// Gets the total duration of the audio in seconds.
+ ///
+ public double TotalDuration => double.PositiveInfinity;
+
+ ///
+ /// Gets or sets the current playback position in seconds.
+ ///
+ public double CurrentTime
+ {
+ get => 0.0;
+ set => Seek(value);
+ }
+
+ ///
+ /// Gets a value indicating whether the end of the stream has been reached.
+ ///
+ public bool Ended => sourcePlayer?.GameObject == null;
+
+ ///
+ /// Reads PCM data from the stream into the specified buffer.
+ ///
+ /// The buffer to fill with PCM data.
+ /// The offset in the buffer at which to begin writing.
+ /// The maximum number of samples to read.
+ /// The number of samples read.
+ public int Read(float[] buffer, int offset, int count)
+ {
+ if (Ended)
+ return 0;
+
+ int read = 0;
+ while (read < count && pcmQueue.TryDequeue(out float sample))
+ {
+ buffer[offset + read] = sample;
+ read++;
+ }
+
+ return read;
+ }
+
+ ///
+ public void Seek(double seconds)
+ {
+ Log.Info("[PlayerVoiceSource] Seeking is not supported for live player voice streams.");
+ }
+
+ ///
+ public void Reset()
+ {
+ Log.Info("[PlayerVoiceSource] Resetting is not supported for live player voice streams.");
+ }
+
+ ///
+ /// Releases all resources used by the .
+ ///
+ public void Dispose()
+ {
+ LabApi.Events.Handlers.PlayerEvents.SendingVoiceMessage -= OnVoiceChatting;
+ decoder?.Dispose();
+ if (decodeBuffer != null)
+ {
+ ArrayPool.Shared.Return(decodeBuffer);
+ decodeBuffer = null;
+ }
+ }
+
+ private void OnVoiceChatting(PlayerSendingVoiceMessageEventArgs ev)
+ {
+ if (ev.Player != sourcePlayer)
+ return;
+
+ if (ev.Message.DataLength <= 2)
+ return;
+
+ if (BlockOriginalVoice)
+ ev.IsAllowed = false;
+
+ int decodedSamples = decoder.Decode(ev.Message.Data, ev.Message.DataLength, decodeBuffer);
+
+ for (int i = 0; i < decodedSamples; i++)
+ {
+ pcmQueue.Enqueue(decodeBuffer[i]);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs
new file mode 100644
index 0000000000..ee466fc30b
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadWebWavPcmSource.cs
@@ -0,0 +1,166 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio.PcmSources
+{
+ using System;
+ using System.Collections.Generic;
+
+ using Exiled.API.Features;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
+
+ using MEC;
+
+ using UnityEngine.Networking;
+
+ ///
+ /// Provides a that downloads a .wav file from a URL and preloads it for playback.
+ ///
+ public sealed class PreloadWebWavPcmSource : IPcmSource
+ {
+ private IPcmSource internalSource;
+ private UnityWebRequest webRequest;
+ private CoroutineHandle downloadRoutine;
+
+ private bool isReady = false;
+ private bool isFailed = false;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The direct URL to the .wav file.
+ public PreloadWebWavPcmSource(string url)
+ {
+ TrackInfo = default;
+ downloadRoutine = Timing.RunCoroutine(Download(url));
+ }
+
+ ///
+ /// Gets the metadata of the preloaded track.
+ ///
+ public TrackData TrackInfo { get; private set; }
+
+ ///
+ /// Gets the total duration of the audio in seconds.
+ ///
+ public double TotalDuration => isReady && internalSource != null ? internalSource.TotalDuration : 0.0;
+
+ ///
+ /// Gets or sets the current playback position in seconds.
+ ///
+ public double CurrentTime
+ {
+ get => isReady && internalSource != null ? internalSource.CurrentTime : 0.0;
+ set => Seek(value);
+ }
+
+ ///
+ /// Gets a value indicating whether the end of the playback has been reached.
+ ///
+ public bool Ended => isFailed || (isReady && internalSource != null && internalSource.Ended);
+
+ ///
+ /// Reads PCM data from the audio source into the specified buffer.
+ ///
+ /// The buffer to fill with PCM data.
+ /// The offset in the buffer at which to begin writing.
+ /// The maximum number of samples to read.
+ /// The number of samples read.
+ public int Read(float[] buffer, int offset, int count)
+ {
+ if (isFailed)
+ return 0;
+
+ if (!isReady || internalSource == null)
+ {
+ Array.Clear(buffer, offset, count);
+ return count;
+ }
+
+ return internalSource.Read(buffer, offset, count);
+ }
+
+ ///
+ /// Seeks to the specified position in the playback.
+ ///
+ /// The position in seconds to seek to.
+ public void Seek(double seconds)
+ {
+ if (isReady && internalSource != null)
+ internalSource.CurrentTime = seconds;
+ }
+
+ ///
+ /// Resets the playback position to the start.
+ ///
+ public void Reset()
+ {
+ if (isReady && internalSource != null)
+ internalSource.Reset();
+ }
+
+ ///
+ /// Releases all resources used by the .
+ ///
+ public void Dispose()
+ {
+ if (downloadRoutine.IsRunning)
+ downloadRoutine.IsRunning = false;
+
+ webRequest?.Abort();
+ webRequest?.Dispose();
+ internalSource?.Dispose();
+ }
+
+ private IEnumerator Download(string url)
+ {
+ webRequest = null;
+
+ try
+ {
+ webRequest = UnityWebRequest.Get(url);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[WebPreloadWavPcmSource] Failed to download audio! URL: {url} | Error: {ex.Message}");
+ isFailed = true;
+ yield break;
+ }
+
+ yield return Timing.WaitUntilDone(webRequest.SendWebRequest());
+
+ try
+ {
+ if (webRequest.result != UnityWebRequest.Result.Success)
+ {
+ Log.Error($"[WebPreloadWavPcmSource] Failed to download audio! URL: {url} | Error: {webRequest.error}");
+ isFailed = true;
+ yield break;
+ }
+
+ byte[] rawBytes = webRequest.downloadHandler.data;
+ AudioData audioData = WavUtility.WavToPcm(rawBytes);
+ audioData.TrackInfo.Path = url;
+
+ internalSource = new PreloadedPcmSource(audioData.Pcm);
+ TrackInfo = audioData.TrackInfo;
+ isReady = true;
+ }
+ catch (Exception e)
+ {
+ Log.Error($"[WebPreloadWavPcmSource] Failed to read the downloaded file! Ensure the link points to a valid .WAV file.\nException Details: {e.Message}");
+ isFailed = true;
+ }
+ finally
+ {
+ webRequest?.Dispose();
+ webRequest = null;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs
similarity index 84%
rename from EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
rename to EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs
index 7be9d09a30..ce1dd6a774 100644
--- a/EXILED/Exiled.API/Features/Audio/PreloadedPcmSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs
@@ -5,27 +5,22 @@
//
// -----------------------------------------------------------------------
-namespace Exiled.API.Features.Audio
+namespace Exiled.API.Features.Audio.PcmSources
{
using System;
- using Exiled.API.Interfaces;
+ using Exiled.API.Features.Audio;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
using VoiceChat;
///
- /// Represents a preloaded PCM audio source.
+ /// Provides a preloaded with Pcm data or file.
///
public sealed class PreloadedPcmSource : IPcmSource
{
- ///
- /// The PCM data buffer.
- ///
private readonly float[] data;
-
- ///
- /// The current read position in the data buffer.
- ///
private int pos;
///
@@ -34,7 +29,9 @@ public sealed class PreloadedPcmSource : IPcmSource
/// The path to the audio file.
public PreloadedPcmSource(string path)
{
- data = WavUtility.WavToPcm(path);
+ AudioData result = WavUtility.WavToPcm(path);
+ data = result.Pcm;
+ TrackInfo = result.TrackInfo;
}
///
@@ -44,8 +41,14 @@ public PreloadedPcmSource(string path)
public PreloadedPcmSource(float[] pcmData)
{
data = pcmData;
+ TrackInfo = new TrackData { Duration = TotalDuration };
}
+ ///
+ /// Gets the metadata of the loaded track.
+ ///
+ public TrackData TrackInfo { get; }
+
///
/// Gets a value indicating whether the end of the PCM data buffer has been reached.
///
@@ -88,14 +91,7 @@ public int Read(float[] buffer, int offset, int count)
public void Seek(double seconds)
{
long targetIndex = (long)(seconds * VoiceChatSettings.SampleRate);
-
- if (targetIndex < 0)
- targetIndex = 0;
-
- if (targetIndex > data.Length)
- targetIndex = data.Length;
-
- pos = (int)targetIndex;
+ pos = (int)Math.Max(0, Math.Min(targetIndex, data.Length));
}
///
diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs
new file mode 100644
index 0000000000..57f4680c81
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs
@@ -0,0 +1,201 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio.PcmSources
+{
+ using System;
+ using System.Collections.Generic;
+
+ using Exiled.API.Features;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
+
+ using MEC;
+
+ using UnityEngine.Networking;
+
+ ///
+ /// Provides a that converts text to speech using the VoiceRSS Text-to-Speech API.
+ ///
+ public sealed class VoiceRssTtsSource : IPcmSource
+ {
+ private const string ApiEndpoint = "https://api.voicerss.org/";
+ private const string AudioFormat = "48khz_16bit_mono";
+
+ private IPcmSource internalSource;
+ private UnityWebRequest webRequest;
+ private CoroutineHandle downloadRoutine;
+
+ private bool isReady = false;
+ private bool isFailed = false;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The text to convert to speech.(Length limited by 100KB).
+ /// Your VoiceRSS API key. Get a free key at .
+ /// The language and locale code for the TTS voice. See for all supported language codes.
+ /// Optional specific voice name for the selected language.(See for available voices per language.)
+ /// Speech rate from -10 (slowest) to 10 (fastest). Defaults to 0 (normal speed).
+ public VoiceRssTtsSource(string text, string apiKey, string language = "en-us", string voice = null, int rate = 0)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ isFailed = true;
+ Log.Error("[VoiceRssTtsSource] Text cannot be null or empty.");
+ throw new ArgumentException("Text cannot be null or empty.", nameof(text));
+ }
+
+ if (string.IsNullOrEmpty(apiKey))
+ {
+ isFailed = true;
+ Log.Error("[VoiceRssTtsSource] API key cannot be null or empty. Get a free key at https://www.voicerss.org/registration.aspx");
+ throw new ArgumentException("API key cannot be null or empty. Get a free key at https://www.voicerss.org/registration.aspx", nameof(apiKey));
+ }
+
+ TrackInfo = new TrackData { Path = $"VoiceRssTts: {text}", Duration = 0.0 };
+ downloadRoutine = Timing.RunCoroutine(DownloadRoutine(text, apiKey, language, voice, rate));
+ }
+
+ ///
+ /// Gets the metadata of the loaded track.
+ ///
+ public TrackData TrackInfo { get; private set; }
+
+ ///
+ /// Gets the total duration of the audio in seconds. Returns 0 while the download is in progress.
+ ///
+ public double TotalDuration => isReady && internalSource != null ? internalSource.TotalDuration : 0.0;
+
+ ///
+ /// Gets or sets the current playback position in seconds.
+ ///
+ public double CurrentTime
+ {
+ get => isReady && internalSource != null ? internalSource.CurrentTime : 0.0;
+ set => Seek(value);
+ }
+
+ ///
+ /// Gets a value indicating whether playback has ended or the download has failed.
+ ///
+ public bool Ended => isFailed || (isReady && internalSource != null && internalSource.Ended);
+
+ ///
+ /// Reads PCM data from the audio source into the specified buffer.
+ ///
+ /// The buffer to fill with PCM data.
+ /// The offset in the buffer at which to begin writing.
+ /// The maximum number of samples to read.
+ /// The number of samples read.
+ public int Read(float[] buffer, int offset, int count)
+ {
+ if (isFailed)
+ return 0;
+
+ if (!isReady || internalSource == null)
+ {
+ Array.Clear(buffer, offset, count);
+ return count;
+ }
+
+ return internalSource.Read(buffer, offset, count);
+ }
+
+ ///
+ /// Seeks to the specified position in seconds.
+ ///
+ /// The target position in seconds.
+ public void Seek(double seconds)
+ {
+ if (isReady && internalSource != null)
+ internalSource.Seek(seconds);
+ }
+
+ ///
+ /// Resets playback to the beginning.
+ ///
+ public void Reset()
+ {
+ if (isReady && internalSource != null)
+ internalSource.Reset();
+ }
+
+ ///
+ /// Releases all resources used by the .
+ ///
+ public void Dispose()
+ {
+ if (downloadRoutine.IsRunning)
+ downloadRoutine.IsRunning = false;
+
+ webRequest?.Abort();
+ webRequest?.Dispose();
+ internalSource?.Dispose();
+ }
+
+ private IEnumerator DownloadRoutine(string text, string apiKey, string language, string voice, int rate)
+ {
+ webRequest = null;
+ string clampedRate = Math.Clamp(rate, -10, 10).ToString();
+ string url = $"{ApiEndpoint}?key={Uri.EscapeDataString(apiKey)}&hl={Uri.EscapeDataString(language)}&c=WAV&f={AudioFormat}&r={clampedRate}&src={Uri.EscapeDataString(text)}";
+
+ if (!string.IsNullOrEmpty(voice))
+ url += $"&v={Uri.EscapeDataString(voice)}";
+
+ try
+ {
+ webRequest = UnityWebRequest.Get(url);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[VoiceRssTtsSource] Failed to start web request! URL: {url} | Error: {ex.Message}");
+ isFailed = true;
+ yield break;
+ }
+
+ yield return Timing.WaitUntilDone(webRequest.SendWebRequest());
+
+ try
+ {
+ if (webRequest.result != UnityWebRequest.Result.Success)
+ {
+ Log.Error($"[VoiceRssTtsSource] Download failed! Error: {webRequest.error}");
+ isFailed = true;
+ yield break;
+ }
+
+ string contentType = webRequest.GetResponseHeader("Content-Type") ?? string.Empty;
+ if (!contentType.Contains("audio") && !contentType.Contains("wav") && !contentType.Contains("octet-stream"))
+ {
+ string apiError = webRequest.downloadHandler.text;
+ Log.Error($"[VoiceRssTtsSource] API Error: {apiError}");
+ isFailed = true;
+ yield break;
+ }
+
+ byte[] rawBytes = webRequest.downloadHandler.data;
+ AudioData audioData = WavUtility.WavToPcm(rawBytes);
+ audioData.TrackInfo.Path = $"VoiceRSS: {text}";
+
+ internalSource = new PreloadedPcmSource(audioData.Pcm);
+ TrackInfo = audioData.TrackInfo;
+ isReady = true;
+ }
+ catch (Exception e)
+ {
+ Log.Error($"[VoiceRssTtsSource] Parsing Error! Ensure the API returns a valid PCM16 WAV.\nDetails: {e.Message}");
+ isFailed = true;
+ }
+ finally
+ {
+ webRequest?.Dispose();
+ webRequest = null;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs
similarity index 84%
rename from EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
rename to EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs
index d910093f1b..32a219a2b8 100644
--- a/EXILED/Exiled.API/Features/Audio/WavStreamSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs
@@ -5,19 +5,21 @@
//
// -----------------------------------------------------------------------
-namespace Exiled.API.Features.Audio
+namespace Exiled.API.Features.Audio.PcmSources
{
using System;
using System.Buffers;
using System.IO;
using System.Runtime.InteropServices;
- using Exiled.API.Interfaces;
+ using Exiled.API.Features.Audio;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
using VoiceChat;
///
- /// Provides a PCM audio source from a WAV file stream.
+ /// Provides a from a WAV file stream.
///
public sealed class WavStreamSource : IPcmSource
{
@@ -36,12 +38,17 @@ public sealed class WavStreamSource : IPcmSource
public WavStreamSource(string path)
{
stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.SequentialScan);
- WavUtility.SkipHeader(stream);
+ TrackInfo = WavUtility.SkipHeader(stream);
startPosition = stream.Position;
endPosition = stream.Length;
internalBuffer = ArrayPool.Shared.Rent(VoiceChatSettings.PacketSizePerChannel * 2);
}
+ ///
+ /// Gets the metadata of the streaming track.
+ ///
+ public TrackData TrackInfo { get; }
+
///
/// Gets the total duration of the audio in seconds.
///
@@ -70,6 +77,11 @@ public double CurrentTime
/// The number of samples read.
public int Read(float[] buffer, int offset, int count)
{
+ count = Math.Min(count, buffer.Length - offset);
+
+ if (count <= 0)
+ return 0;
+
int bytesNeeded = count * 2;
if (internalBuffer.Length < bytesNeeded)
@@ -89,13 +101,10 @@ public int Read(float[] buffer, int offset, int count)
Span byteSpan = internalBuffer.AsSpan(0, bytesRead);
Span shortSpan = MemoryMarshal.Cast(byteSpan);
- int samplesInDestination = buffer.Length - offset;
- int samplesToWrite = Math.Min(shortSpan.Length, samplesInDestination);
-
- for (int i = 0; i < samplesToWrite; i++)
+ for (int i = 0; i < shortSpan.Length; i++)
buffer[offset + i] = shortSpan[i] * Divide;
- return samplesToWrite;
+ return shortSpan.Length;
}
///
@@ -104,15 +113,7 @@ public int Read(float[] buffer, int offset, int count)
/// The position in seconds to seek to.
public void Seek(double seconds)
{
- long targetSample = (long)(seconds * VoiceChatSettings.SampleRate);
- long targetByte = targetSample * 2;
-
- long newPos = startPosition + targetByte;
- if (newPos > endPosition)
- newPos = endPosition;
-
- if (newPos < startPosition)
- newPos = startPosition;
+ long newPos = Math.Clamp(startPosition + ((long)(seconds * VoiceChatSettings.SampleRate) * 2), startPosition, endPosition);
if (newPos % 2 != 0)
newPos--;
diff --git a/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs
new file mode 100644
index 0000000000..6ca303970e
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs
@@ -0,0 +1,98 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio
+{
+ using System;
+ using System.Collections.Generic;
+
+ using Exiled.API.Enums;
+ using Exiled.API.Features;
+ using Exiled.API.Features.Toys;
+ using Exiled.API.Interfaces.Audio;
+
+ using Mirror;
+
+ ///
+ /// Represents all configurable audio and network settings for play from pool method.
+ ///
+ public struct PlaybackSettings
+ {
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ public PlaybackSettings()
+ {
+ }
+
+ ///
+ /// Gets or sets the volume level.
+ ///
+ public float Volume { get; set; } = Speaker.DefaultVolume;
+
+ ///
+ /// Gets or sets the playback pitch.
+ ///
+ public float Pitch { get; set; } = 1f;
+
+ ///
+ /// Gets or sets a value indicating whether the audio source is spatialized (3D sound).
+ ///
+ public bool IsSpatial { get; set; } = Speaker.DefaultSpatial;
+
+ ///
+ /// Gets or sets the minimum distance at which the audio reaches full volume.
+ ///
+ public float MinDistance { get; set; } = Speaker.DefaultMinDistance;
+
+ ///
+ /// Gets or sets the maximum distance at which the audio can be heard.
+ ///
+ public float MaxDistance { get; set; } = Speaker.DefaultMaxDistance;
+
+ ///
+ /// Gets or sets a value indicating whether the file should be streamed from disk.
+ /// Ignored for web URLs and cached sources.
+ ///
+ public bool Stream { get; set; } = false;
+
+ ///
+ /// Gets or sets a value indicating whether to load the audio via the storage Manager for optimized playback.
+ ///
+ public bool UseCache { get; set; } = false;
+
+ ///
+ /// Gets or sets the Mirror network channel used for sending audio packets.
+ ///
+ public int Channel { get; set; } = Channels.ReliableOrdered2;
+
+ ///
+ /// Gets or sets the play mode determining how the audio is sent to players.
+ ///
+ public SpeakerPlayMode PlayMode { get; set; } = SpeakerPlayMode.Global;
+
+ ///
+ /// Gets or sets the target player (used when is ).
+ ///
+ public Player TargetPlayer { get; set; } = null;
+
+ ///
+ /// Gets or sets the list of target players (used when is ).
+ ///
+ public HashSet TargetPlayers { get; set; } = null;
+
+ ///
+ /// Gets or sets the condition used to determine which players hear the audio (used when is ).
+ ///
+ public Func Predicate { get; set; } = null;
+
+ ///
+ /// Gets or sets an optional custom audio filter to apply to the PCM data.
+ ///
+ public IAudioFilter Filter { get; set; } = null;
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs b/EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs
new file mode 100644
index 0000000000..a87768f3c8
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs
@@ -0,0 +1,52 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio
+{
+ using System;
+
+ ///
+ /// Represents a time-based action for audio playback.
+ ///
+ public class ScheduledEvent : IComparable
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The exact time in seconds to trigger the action.
+ /// The action to execute.
+ /// The optional unique identifier for the event. If null, a random GUID will be generated automatically.
+ public ScheduledEvent(double time, Action action, string id = null)
+ {
+ Time = time;
+ Action = action;
+ Id = id ?? Guid.NewGuid().ToString();
+ }
+
+ ///
+ /// Gets the specific time in seconds at which the event should trigger.
+ ///
+ public double Time { get; }
+
+ ///
+ /// Gets the action to be invoked when the specified time is reached.
+ ///
+ public Action Action { get; }
+
+ ///
+ /// Gets the unique identifier for this time event.
+ ///
+ public string Id { get; }
+
+ ///
+ /// Compares this instance to another based on their trigger times.
+ ///
+ /// The other to compare to.
+ /// A value that indicates the relative order of the events being compared.
+ public int CompareTo(ScheduledEvent other) => Time.CompareTo(other.Time);
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs
new file mode 100644
index 0000000000..da580416f9
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs
@@ -0,0 +1,99 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio
+{
+ using System;
+
+ using Exiled.API.Structs.Audio;
+
+ using Toys;
+
+ ///
+ /// Contains global event handlers related to the audio system.
+ ///
+ public static class SpeakerEvents
+ {
+ ///
+ /// Invoked when a speaker starts playing an audio track.
+ ///
+ public static event Action PlaybackStarted;
+
+ ///
+ /// Invoked when the audio playback of a speaker is paused.
+ ///
+ public static event Action PlaybackPaused;
+
+ ///
+ /// Invoked when the audio playback of a speaker is resumed from a paused state.
+ ///
+ public static event Action PlaybackResumed;
+
+ ///
+ /// Invoked when the audio playback of a speaker loops back to the beginning.
+ ///
+ public static event Action PlaybackLooped;
+
+ ///
+ /// Invoked just before the speaker switches to the next track in the queue.
+ ///
+ public static event Action TrackSwitching;
+
+ ///
+ /// Invoked when a speaker finishes playing its current audio track.
+ ///
+ public static event Action PlaybackFinished;
+
+ ///
+ /// Invoked when a speaker's audio playback is completely stopped.
+ ///
+ public static event Action PlaybackStopped;
+
+ ///
+ /// Called when a speaker starts playing an audio track.
+ ///
+ /// The instance.
+ internal static void OnPlaybackStarted(Speaker speaker) => PlaybackStarted?.Invoke(speaker);
+
+ ///
+ /// Called when the audio playback of a speaker is paused.
+ ///
+ /// The instance.
+ internal static void OnPlaybackPaused(Speaker speaker) => PlaybackPaused?.Invoke(speaker);
+
+ ///
+ /// Called when the audio playback of a speaker is resumed from a paused state.
+ ///
+ /// The instance.
+ internal static void OnPlaybackResumed(Speaker speaker) => PlaybackResumed?.Invoke(speaker);
+
+ ///
+ /// Called when the audio playback of a speaker loops back to the beginning.
+ ///
+ /// The instance.
+ internal static void OnPlaybackLooped(Speaker speaker) => PlaybackLooped?.Invoke(speaker);
+
+ ///
+ /// Called just before the speaker switches to the next track in the queue.
+ ///
+ /// The instance.
+ /// The upcoming to be played.
+ internal static void OnTrackSwitching(Speaker speaker, QueuedTrack nextTrack) => TrackSwitching?.Invoke(speaker, nextTrack);
+
+ ///
+ /// Called when a speaker finishes playing its current audio track.
+ ///
+ /// The instance.
+ internal static void OnPlaybackFinished(Speaker speaker) => PlaybackFinished?.Invoke(speaker);
+
+ ///
+ /// Called when a speaker's audio playback is completely stopped.
+ ///
+ /// The instance.
+ internal static void OnPlaybackStopped(Speaker speaker) => PlaybackStopped?.Invoke(speaker);
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
index c1b1bc3f73..5eb818a34f 100644
--- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
@@ -13,6 +13,10 @@ namespace Exiled.API.Features.Audio
using System.IO;
using System.Runtime.InteropServices;
+ using Exiled.API.Features.Audio.PcmSources;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
+
using VoiceChat;
///
@@ -22,13 +26,46 @@ public static class WavUtility
{
private const float Divide = 1f / 32768f;
+ ///
+ /// Evaluates the given local path or URL and returns the appropriate for .wav playback.
+ ///
+ /// The local file path or web URL of the .wav file.
+ /// If true, streams local files directly from disk. If false, preloads them into memory (Ignored for web URLs).
+ /// If true, loads the audio via for zero-latency memory playback.
+ /// An initialized .
+ public static IPcmSource CreatePcmSource(string path, bool stream = false, bool cache = false)
+ {
+ if (cache)
+ return new CachedPcmSource(path, path);
+
+ if (path.StartsWith("http"))
+ return new PreloadWebWavPcmSource(path);
+
+ if (stream)
+ return new WavStreamSource(path);
+
+ return new PreloadedPcmSource(path);
+ }
+
///
/// Converts a WAV file at the specified path to a PCM float array.
///
/// The file path of the WAV file to convert.
- /// An array of floats representing the PCM data.
- public static float[] WavToPcm(string path)
+ /// A containing an array of floats representing the PCM data and its TrackData.
+ public static AudioData WavToPcm(string path)
{
+ if (!File.Exists(path))
+ {
+ Log.Error($"[WavUtility] The specified local file does not exist, path: `{path}`");
+ throw new FileNotFoundException("File does not exist");
+ }
+
+ if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
+ {
+ Log.Error($"[WavUtility] The file type '{Path.GetExtension(path)}' is not supported for wav utility. Please use .wav file.");
+ throw new InvalidDataException("Unsupported WAV format.");
+ }
+
using FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
int length = (int)fs.Length;
@@ -37,23 +74,12 @@ public static float[] WavToPcm(string path)
try
{
int bytesRead = fs.Read(rentedBuffer, 0, length);
-
using MemoryStream ms = new(rentedBuffer, 0, bytesRead);
- SkipHeader(ms);
-
- int headerOffset = (int)ms.Position;
- int dataLength = bytesRead - headerOffset;
+ AudioData result = ParseWavSpanToPcm(ms, rentedBuffer.AsSpan(0, bytesRead));
+ result.TrackInfo.Path = path;
- Span audioDataSpan = rentedBuffer.AsSpan(headerOffset, dataLength);
- Span samples = MemoryMarshal.Cast(audioDataSpan);
-
- float[] pcm = new float[samples.Length];
-
- for (int i = 0; i < samples.Length; i++)
- pcm[i] = samples[i] * Divide;
-
- return pcm;
+ return result;
}
finally
{
@@ -61,15 +87,57 @@ public static float[] WavToPcm(string path)
}
}
+ ///
+ /// Converts a WAV byte array to a PCM float array.
+ ///
+ /// The raw bytes of the WAV file.
+ /// A containing an array of floats representing the PCM data and its TrackData.
+ public static AudioData WavToPcm(byte[] data)
+ {
+ using MemoryStream ms = new(data, 0, data.Length);
+
+ return ParseWavSpanToPcm(ms, data.AsSpan());
+ }
+
+ ///
+ /// Parses the WAV header from the provided stream and converts the remaining audio data span into a PCM float array.
+ ///
+ /// The stream used to read and skip the WAV header.
+ /// The complete span of WAV audio data including the header.
+ /// A tuple containing an array of floats representing the PCM data and its TrackData.
+ public static AudioData ParseWavSpanToPcm(Stream stream, ReadOnlySpan audioData)
+ {
+ TrackData metaData = SkipHeader(stream);
+
+ int headerOffset = (int)stream.Position;
+ int dataLength = audioData.Length - headerOffset;
+
+ ReadOnlySpan samples = MemoryMarshal.Cast(audioData.Slice(headerOffset, dataLength));
+
+ float[] pcm = new float[samples.Length];
+
+ for (int i = 0; i < samples.Length; i++)
+ pcm[i] = samples[i] * Divide;
+
+ return new(pcm, metaData);
+ }
+
///
/// Skips the WAV file header and validates that the format is PCM16 mono with the specified sample rate.
///
/// The to read from.
- public static void SkipHeader(Stream stream)
+ /// A struct containing the parsed file information.
+ public static TrackData SkipHeader(Stream stream)
{
+ TrackData trackData = new();
+
Span headerBuffer = stackalloc byte[12];
stream.Read(headerBuffer);
+ int rate = 0;
+ int bits = 0;
+ int channels = 0;
+
Span chunkHeader = stackalloc byte[8];
while (true)
{
@@ -87,21 +155,72 @@ public static void SkipHeader(Stream stream)
stream.Read(fmtData);
short format = BinaryPrimitives.ReadInt16LittleEndian(fmtData[..2]);
- short channels = BinaryPrimitives.ReadInt16LittleEndian(fmtData.Slice(2, 2));
- int rate = BinaryPrimitives.ReadInt32LittleEndian(fmtData.Slice(4, 4));
- short bits = BinaryPrimitives.ReadInt16LittleEndian(fmtData.Slice(14, 2));
+ channels = BinaryPrimitives.ReadInt16LittleEndian(fmtData.Slice(2, 2));
+ rate = BinaryPrimitives.ReadInt32LittleEndian(fmtData.Slice(4, 4));
+ bits = BinaryPrimitives.ReadInt16LittleEndian(fmtData.Slice(14, 2));
if (format != 1 || channels != 1 || rate != VoiceChatSettings.SampleRate || bits != 16)
- throw new InvalidDataException($"Invalid WAV format (format={format}, channels={channels}, rate={rate}, bits={bits}). Expected PCM16, mono and {VoiceChatSettings.SampleRate}Hz.");
+ {
+ Log.Error($"[WavUtility] Invalid WAV format (format={format}, channels={channels}, rate={rate}, bits={bits}). Expected PCM16, mono and {VoiceChatSettings.SampleRate}Hz.");
+ throw new InvalidDataException("Unsupported WAV format.");
+ }
if (chunkSize > 16)
stream.Seek(chunkSize - 16, SeekOrigin.Current);
}
+ // 'LIST' chunk
+ else if (chunkId == 0x5453494C)
+ {
+ Span listType = stackalloc byte[4];
+ stream.Read(listType);
+ uint type = BinaryPrimitives.ReadUInt32LittleEndian(listType);
+
+ // 'INFO' chunk
+ if (type == 0x4F464E49)
+ {
+ int bytesToRead = chunkSize - 4;
+ byte[] infoBytes = ArrayPool.Shared.Rent(bytesToRead);
+ stream.Read(infoBytes, 0, bytesToRead);
+
+ int offset = 0;
+ while (offset < bytesToRead - 8)
+ {
+ uint infoId = BinaryPrimitives.ReadUInt32LittleEndian(infoBytes.AsSpan(offset, 4));
+ int infoSize = BinaryPrimitives.ReadInt32LittleEndian(infoBytes.AsSpan(offset + 4, 4));
+ offset += 8;
+
+ if (infoSize > 0 && offset + infoSize <= bytesToRead)
+ {
+ string value = System.Text.Encoding.UTF8.GetString(infoBytes, offset, infoSize).TrimEnd('\0');
+
+ if (infoId == 0x4D414E49)
+ trackData.Title = value;
+ else if (infoId == 0x54524149)
+ trackData.Artist = value;
+ }
+
+ offset += infoSize;
+ if (infoSize % 2 != 0)
+ offset++;
+ }
+
+ ArrayPool.Shared.Return(infoBytes);
+ }
+ else
+ {
+ stream.Seek(chunkSize - 4, SeekOrigin.Current);
+ }
+ }
+
// 'data' chunk
else if (chunkId == 0x61746164)
{
- return;
+ int bytesPerSample = bits / 8;
+ if (bytesPerSample > 0 && channels > 0 && rate > 0)
+ trackData.Duration = (double)chunkSize / (rate * channels * bytesPerSample);
+
+ return trackData;
}
else
{
@@ -109,8 +228,46 @@ public static void SkipHeader(Stream stream)
}
if (stream.Position >= stream.Length)
- throw new InvalidDataException("WAV file does not contain a 'data' chunk.");
+ {
+ Log.Error("[WavUtility] WAV file does not contain a 'data' chunk.");
+ throw new InvalidDataException("Missing 'data' chunk in WAV file.");
+ }
}
+
+ return trackData;
+ }
+
+ ///
+ /// Validates a given local file path or web URL to ensure it is suitable for WAV processing.
+ ///
+ /// The local file path or web URL to validate.
+ /// Outputs a specific error message explaining why the validation failed. Returns if successful.
+ /// true if the path is valid and safe to process; otherwise, false.
+ public static bool TryValidatePath(string path, out string errorMessage)
+ {
+ errorMessage = string.Empty;
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ errorMessage = "Provided path or URL cannot be null or empty!";
+ return false;
+ }
+
+ if (path.StartsWith("http"))
+ return true;
+
+ if (!File.Exists(path))
+ {
+ errorMessage = $"The specified local file does not exist. Path: `{path}`";
+ return false;
+ }
+
+ if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
+ {
+ errorMessage = $"Unsupported file format! Only .wav files are allowed. Path: `{path}`";
+ return false;
+ }
+
+ return true;
}
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Toys/AdminToy.cs b/EXILED/Exiled.API/Features/Toys/AdminToy.cs
index 15cbc0ab72..6f8fe5555a 100644
--- a/EXILED/Exiled.API/Features/Toys/AdminToy.cs
+++ b/EXILED/Exiled.API/Features/Toys/AdminToy.cs
@@ -101,7 +101,33 @@ public Quaternion Rotation
}
///
- /// Gets or sets the scale of the toy.
+ /// Gets or sets the local position of the toy relative to its parent.
+ ///
+ public Vector3 LocalPosition
+ {
+ get => Transform.localPosition;
+ set
+ {
+ Transform.localPosition = value;
+ AdminToyBase.NetworkPosition = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the local rotation of the toy relative to its parent.
+ ///
+ public Quaternion LocalRotation
+ {
+ get => Transform.localRotation;
+ set
+ {
+ Transform.localRotation = value;
+ AdminToyBase.NetworkRotation = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the local scale of the toy.
///
public Vector3 Scale
{
diff --git a/EXILED/Exiled.API/Features/Toys/Light.cs b/EXILED/Exiled.API/Features/Toys/Light.cs
index 9e167d1bb5..4dad774de2 100644
--- a/EXILED/Exiled.API/Features/Toys/Light.cs
+++ b/EXILED/Exiled.API/Features/Toys/Light.cs
@@ -151,13 +151,12 @@ public static Light Create(Vector3? position /*= null*/, Vector3? rotation /*= n
Position = position ?? Vector3.zero,
Rotation = Quaternion.Euler(rotation ?? Vector3.zero),
Scale = scale ?? Vector3.one,
+ Color = color ?? Color.gray,
};
if (spawn)
light.Spawn();
- light.Color = color ?? Color.gray;
-
return light;
}
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 4478c1143b..1b072e04fe 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -5,17 +5,20 @@
//
// -----------------------------------------------------------------------
+#pragma warning disable SA1129 // Do not use default value type constructor
namespace Exiled.API.Features.Toys
{
using System;
using System.Collections.Generic;
- using System.IO;
using AdminToys;
using Enums;
using Exiled.API.Features.Audio;
+ using Exiled.API.Features.Audio.PcmSources;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
using Interfaces;
@@ -23,36 +26,85 @@ namespace Exiled.API.Features.Toys
using Mirror;
+ using NorthwoodLib.Pools;
+
+ using RoundRestarting;
+
using UnityEngine;
using VoiceChat;
using VoiceChat.Codec;
using VoiceChat.Codec.Enums;
using VoiceChat.Networking;
+ using VoiceChat.Playbacks;
using Object = UnityEngine.Object;
+ using Random = UnityEngine.Random;
///
/// A wrapper class for .
///
public class Speaker : AdminToy, IWrapper
{
+ ///
+ /// The default volume level of the base SpeakerToy prefab.
+ ///
+ public const float DefaultVolume = 1f;
+
+ ///
+ /// The default minimum spatial distance of the base SpeakerToy prefab.
+ ///
+ public const float DefaultMinDistance = 1f;
+
+ ///
+ /// The default maximum spatial distance of the base SpeakerToy prefab.
+ ///
+ public const float DefaultMaxDistance = 15f;
+
+ ///
+ /// The default network controller ID of the base SpeakerToy prefab.
+ ///
+ public const byte DefaultControllerId = 0;
+
+ ///
+ /// The default spatialization setting of the base SpeakerToy prefab.
+ ///
+ public const bool DefaultSpatial = true;
+
+ ///
+ /// A queue used for object pooling of instances.
+ /// Reusing idle speakers instead of constantly creating and destroying them significantly improves server performance, especially for frequent audio events.
+ ///
+ internal static readonly Queue Pool;
+
private const int FrameSize = VoiceChatSettings.PacketSizePerChannel;
private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate;
+ private static readonly Vector3 SpeakerParkPosition = Vector3.down * 999;
+
+ private OpusEncoder encoder;
+
private float[] frame;
private byte[] encoded;
private float[] resampleBuffer;
+ private CoroutineHandle playBackRoutine;
+ private CoroutineHandle fadeRoutine;
+
private double resampleTime;
private int resampleBufferFilled;
+ private int nextScheduledEventIndex = 0;
+ private int idChangeFrame;
- private IPcmSource source;
- private OpusEncoder encoder;
- private CoroutineHandle playBackRoutine;
-
- private bool isPitchDefault = true;
private bool isPlayBackInitialized = false;
+ private bool isPitchDefault = true;
+ private bool needsSyncWait = false;
+
+ static Speaker()
+ {
+ Pool = new();
+ RoundRestart.OnRestartTriggered += Pool.Clear;
+ }
///
/// Initializes a new instance of the class.
@@ -85,13 +137,19 @@ internal Speaker(SpeakerToy speakerToy)
/// Invoked when the audio track finishes playing.
/// If looping is enabled, this triggers every time the track finished.
///
- public event Action OnPlaybackFinished;
+ public event Action OnPlaybackFinished;
///
/// Invoked when the audio playback stops completely (either manually or end of file).
///
public event Action OnPlaybackStopped;
+ ///
+ /// Invoked just before the speaker switches to the next track in the queue.
+ /// Passes the upcoming as an argument.
+ ///
+ public event Action OnTrackSwitching;
+
///
/// Gets the prefab.
///
@@ -117,6 +175,11 @@ internal Speaker(SpeakerToy speakerToy)
///
public bool DestroyAfter { get; set; }
+ ///
+ /// Gets or sets a value indicating whether the speaker should return to the pool after playback finishes.
+ ///
+ public bool ReturnToPoolAfter { get; set; }
+
///
/// Gets or sets the play mode for this speaker, determining how audio is sent to players.
///
@@ -139,7 +202,7 @@ internal Speaker(SpeakerToy speakerToy)
public Func Predicate { get; set; }
///
- /// Gets a value indicating whether gets is a sound playing on this speaker or not.
+ /// Gets a value indicating whether a sound is currently playing on this speaker.
///
public bool IsPlaying => playBackRoutine.IsRunning && !IsPaused;
@@ -162,9 +225,15 @@ public bool IsPaused
playBackRoutine.IsAliveAndPaused = value;
if (value)
+ {
OnPlaybackPaused?.Invoke();
+ SpeakerEvents.OnPlaybackPaused(this);
+ }
else
+ {
OnPlaybackResumed?.Invoke();
+ SpeakerEvents.OnPlaybackResumed(this);
+ }
}
}
@@ -174,15 +243,19 @@ public bool IsPaused
///
public double CurrentTime
{
- get => source?.CurrentTime ?? 0.0;
+ get => CurrentSource?.CurrentTime ?? 0.0;
set
{
- if (source != null)
- {
- source.CurrentTime = value;
- resampleTime = 0.0;
- resampleBufferFilled = 0;
- }
+ if (CurrentSource == null)
+ return;
+
+ CurrentSource.CurrentTime = value;
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+
+ ResetEncoder();
+ Filter?.Reset();
+ UpdateNextScheduledEventIndex();
}
}
@@ -190,12 +263,52 @@ public double CurrentTime
/// Gets the total duration of the current track in seconds.
/// Returns 0 if not playing.
///
- public double TotalDuration => source?.TotalDuration ?? 0.0;
+ public double TotalDuration => CurrentSource?.TotalDuration ?? 0.0;
+
+ ///
+ /// Gets the remaining playback time in seconds.
+ ///
+ public double TimeLeft => Math.Max(0.0, TotalDuration - CurrentTime);
+
+ ///
+ /// Gets or sets the current playback progress as a value between 0.0 and 1.0.
+ /// Returns 0 if not playing.
+ ///
+ public float PlaybackProgress
+ {
+ get => TotalDuration > 0.0 ? (float)(CurrentTime / TotalDuration) : 0f;
+ set
+ {
+ if (TotalDuration > 0.0)
+ CurrentTime = TotalDuration * Mathf.Clamp01(value);
+ }
+ }
+
+ ///
+ /// Gets the currently playing audio source.
+ /// Pre-made filters are available in the namespace.
+ ///
+ public IPcmSource CurrentSource { get; private set; }
+
+ ///
+ /// Gets the metadata information (Title, Artist, Duration) of the last played audio track.
+ ///
+ public TrackData LastTrackInfo { get; private set; }
+
+ ///
+ /// Gets or sets the custom audio filter applied to the PCM data right before encoding.
+ ///
+ public IAudioFilter Filter { get; set; }
+
+ ///
+ /// Gets the queue of audio tracks to be played sequentially.
+ ///
+ public List TrackQueue => field ??= new();
///
- /// Gets the path to the last audio file played on this speaker.
+ /// Gets the list of time-based events for the current audio track.
///
- public string LastTrack { get; private set; }
+ public List ScheduledEvents => field ??= new();
///
/// Gets or sets the playback pitch.
@@ -209,6 +322,19 @@ public float Pitch
get;
set
{
+ if (field == value)
+ return;
+
+ if (CurrentSource is ILiveSource)
+ {
+ field = 1.0f;
+ isPitchDefault = true;
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+ Log.Warn("[Speaker] Pitch adjustment is not supported for live sources. Pitch has been reset to default (1.0).");
+ return;
+ }
+
field = Mathf.Max(0.1f, Mathf.Abs(value));
isPitchDefault = Mathf.Abs(field - 1.0f) < 0.0001f;
if (isPitchDefault)
@@ -229,7 +355,11 @@ public float Pitch
public float Volume
{
get => Base.NetworkVolume;
- set => Base.NetworkVolume = value;
+ set
+ {
+ StopFade();
+ Base.NetworkVolume = value;
+ }
}
///
@@ -277,24 +407,39 @@ public float MinDistance
public byte ControllerId
{
get => Base.NetworkControllerId;
- set => Base.NetworkControllerId = value;
+ set
+ {
+ if (Base.NetworkControllerId == value)
+ return;
+
+ Base.NetworkControllerId = value;
+ needsSyncWait = true;
+ idChangeFrame = Time.frameCount;
+ }
}
///
/// Creates a new .
///
- /// The position of the .
- /// The rotation of the .
- /// The scale of the .
+ /// The parent transform to attach the to.
+ /// The local position of the .
+ /// The volume level of the audio source.
+ /// Whether the audio source is spatialized (3D sound).
+ /// The minimum distance at which the audio reaches full volume.
+ /// The maximum distance at which the audio can be heard.
+ /// The specific controller ID to assign. If null, the next available ID is used.
/// Whether the should be initially spawned.
/// The new .
- public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scale, bool spawn)
+ public static Speaker Create(Transform parent = null, Vector3? position = null, float volume = DefaultVolume, bool isSpatial = DefaultSpatial, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, byte? controllerId = null, bool spawn = true)
{
- Speaker speaker = new(Object.Instantiate(Prefab))
+ Speaker speaker = new(Object.Instantiate(Prefab, parent))
{
- Position = position ?? Vector3.zero,
- Rotation = Quaternion.Euler(rotation ?? Vector3.zero),
- Scale = scale ?? Vector3.one,
+ Volume = volume,
+ IsSpatial = isSpatial,
+ MinDistance = minDistance,
+ MaxDistance = maxDistance,
+ ControllerId = controllerId ?? GetNextFreeControllerId(),
+ LocalPosition = position ?? Vector3.zero,
};
if (spawn)
@@ -304,156 +449,695 @@ public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scal
}
///
- /// Creates a new .
+ /// Rents an available speaker from the pool or creates a new one if the pool is empty.
///
- /// The transform to create this on.
- /// Whether the should be initially spawned.
- /// Whether the should keep the same world position.
- /// The new .
- public static Speaker Create(Transform transform, bool spawn, bool worldPositionStays = true)
+ /// The parent transform to attach the to.
+ /// The local position of the .
+ /// A clean instance ready for use.
+ public static Speaker Rent(Transform parent = null, Vector3? position = null)
{
- Speaker speaker = new(Object.Instantiate(Prefab, transform, worldPositionStays))
+ Speaker speaker = null;
+
+ while (Pool.Count > 0)
{
- Position = transform.position,
- Rotation = transform.rotation,
- Scale = transform.localScale.normalized,
- };
+ speaker = Pool.Dequeue();
- if (spawn)
- speaker.Spawn();
+ if (speaker != null && speaker.Base != null)
+ break;
+
+ speaker = null;
+ }
+
+ if (speaker == null)
+ {
+ speaker = Create(parent, position);
+ }
+ else
+ {
+ speaker.IsStatic = false;
+
+ if (parent != null)
+ speaker.Transform.parent = parent;
+
+ speaker.LocalPosition = position ?? Vector3.zero;
+ speaker.ControllerId = GetNextFreeControllerId(speaker.ControllerId);
+ SpeakerToyPlaybackBase.AllInstances.Add(speaker.Base.Playback);
+ }
return speaker;
}
///
- /// Plays audio through this speaker.
+ /// Rents a speaker from the pool, plays a local wav file or web stream one time, and automatically returns it to the pool afterwards. (File must be 16 bit, mono and 48khz.)
///
- /// An instance.
- /// Targets who will hear the audio. If null, audio will be sent to all players.
- public static void Play(AudioMessage message, IEnumerable targets = null)
+ /// The path/url or custom name/key (if has set to true) to the wav file.
+ /// The local position of the speaker.
+ /// The parent transform, if any.
+ /// The optional audio and network settings. If null, default settings are used.
+ /// true if the audio file was successfully found, loaded, and playback started; otherwise, false.
+ public static bool PlayFromPool(string path, Vector3 position, Transform parent = null, in PlaybackSettings? settings = null)
{
- foreach (Player target in targets ?? Player.List)
- target.Connection.Send(message);
+ if (string.IsNullOrEmpty(path))
+ {
+ Log.Error("[Speaker] Provided path/url or name cannot be null or empty!");
+ return false;
+ }
+
+ PlaybackSettings settingsFull = settings ?? new PlaybackSettings();
+ if (!settingsFull.UseCache && !WavUtility.TryValidatePath(path, out string errorMessage))
+ {
+ Log.Error($"[Speaker] {errorMessage}");
+ return false;
+ }
+
+ IPcmSource source;
+ try
+ {
+ source = WavUtility.CreatePcmSource(path, settingsFull.Stream, settingsFull.UseCache);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to initialize audio source for PlayFromPool. Path: '{path}'.\n{ex}");
+ return false;
+ }
+
+ return PlayFromPool(source, position, parent, settingsFull);
}
///
- /// Plays audio through this speaker.
+ /// Rents a speaker from the pool, plays a custom PCM source one time, and automatically returns it to the pool afterwards.
///
- /// Audio samples.
- /// The length of the samples array.
- /// Targets who will hear the audio. If null, audio will be sent to all players.
- public void Play(byte[] samples, int? length = null, IEnumerable targets = null) => Play(new AudioMessage(ControllerId, samples, length ?? samples.Length), targets);
+ /// The custom IPcmSource to play.
+ /// The local position of the speaker.
+ /// The parent transform, if any.
+ /// The optional audio and network settings. If null, default settings are used.
+ /// true if the source is valid and playback started; otherwise, false.
+ public static bool PlayFromPool(IPcmSource source, Vector3 position, Transform parent = null, in PlaybackSettings? settings = null)
+ {
+ if (source == null)
+ {
+ Log.Error("[Speaker] Provided custom IPcmSource is null for PlayFromPool!");
+ return false;
+ }
+
+ Speaker speaker = Rent(parent, position);
+
+ PlaybackSettings settingsFull = settings ?? new PlaybackSettings();
+
+ speaker.Volume = settingsFull.Volume;
+ speaker.IsSpatial = settingsFull.IsSpatial;
+ speaker.MinDistance = settingsFull.MinDistance;
+ speaker.MaxDistance = settingsFull.MaxDistance;
+
+ speaker.Pitch = settingsFull.Pitch;
+ speaker.Channel = settingsFull.Channel;
+ speaker.PlayMode = settingsFull.PlayMode;
+ speaker.Predicate = settingsFull.Predicate;
+ speaker.TargetPlayer = settingsFull.TargetPlayer;
+ speaker.TargetPlayers = settingsFull.TargetPlayers;
+ speaker.Filter = settingsFull.Filter;
+
+ speaker.ReturnToPoolAfter = true;
+
+ if (!speaker.Play(source, true))
+ {
+ speaker.ReturnToPool();
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Gets the next available controller ID for a .
+ ///
+ /// An optional ID to check first.
+ /// The next available byte ID. If all IDs are currently in use, returns a default of 0.
+ public static byte GetNextFreeControllerId(byte? preferredId = null)
+ {
+ HashSet usedIds = HashSetPool.Shared.Rent(byte.MaxValue + 1);
+
+ foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances)
+ {
+ usedIds.Add(playbackBase.ControllerId);
+ }
+
+ if (usedIds.Count >= byte.MaxValue + 1)
+ {
+ HashSetPool.Shared.Return(usedIds);
+ Log.Warn("[Speaker] All controller IDs are in use. Default Controll Id will be use, Audio may conflict!");
+ return DefaultControllerId;
+ }
+
+ if (preferredId.HasValue && !usedIds.Contains(preferredId.Value))
+ {
+ HashSetPool.Shared.Return(usedIds);
+ return preferredId.Value;
+ }
+
+ byte id = 0;
+ while (usedIds.Contains(id))
+ {
+ id++;
+ }
+
+ HashSetPool.Shared.Return(usedIds);
+ return id;
+ }
+
+ ///
+ /// Plays a local wav file or web URL through this speaker. (File must be 16-bit, mono, and 48kHz.)
+ ///
+ /// The path/url or custom name(if is true) to the wav file.
+ /// If true, clears the upcoming tracks in the playlist before starting playback.
+ /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs).
+ /// If true, loads the audio via for optimized playback.
+ /// true if the audio file was successfully found, loaded, and playback started; otherwise, false.
+ public bool Play(string path, bool clearQueue = true, bool stream = false, bool useCache = false)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ Log.Error("[Speaker] Provided path/url or name cannot be null or empty!");
+ return false;
+ }
+
+ if (!useCache && !WavUtility.TryValidatePath(path, out string errorMessage))
+ {
+ Log.Error($"[Speaker] {errorMessage}");
+ return false;
+ }
+
+ IPcmSource newSource;
+ try
+ {
+ newSource = WavUtility.CreatePcmSource(path, stream, useCache);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to initialize audio source for file at path: '{path}'.\nException Details: {ex}");
+ return false;
+ }
+
+ return Play(newSource, clearQueue);
+ }
+
+ ///
+ /// Plays the live voice of a specific player through this speaker.
+ ///
+ /// The player whose voice will be broadcasted.
+ /// If true, prevents the player's original voice message's from being heard while broadcasting.
+ /// If true, clears the upcoming tracks in the playlist before starting playback.
+ /// true if the playback started successfully; otherwise, false.
+ public bool PlayLiveVoice(Player player, bool blockOriginalVoice = false, bool clearQueue = true)
+ {
+ if (player == null)
+ {
+ Log.Error("[Speaker] Source player cannot be null when streaming live microphone!");
+ return false;
+ }
+
+ PlayerVoiceSource source;
+ try
+ {
+ source = new PlayerVoiceSource(player, blockOriginalVoice);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to initialize live voice stream for player '{player.Nickname}' ({player.Id}).\nException Details: {ex}");
+ return false;
+ }
+
+ return Play(source, clearQueue);
+ }
///
- /// Plays a wav file through this speaker.(File must be 16 bit, mono and 48khz.)
+ /// Creates a from the provided paths/URLs and starts playing it mixed.
///
- /// The path to the wav file.
- /// Whether to stream the audio or preload it.
- /// Whether to destroy the speaker after playback.
- /// Whether to loop the audio.
- public void Play(string path, bool stream = false, bool destroyAfter = false, bool loop = false)
+ /// If true, clears the upcoming tracks in the playlist before starting playback.
+ /// An array of paths or URLs to the audio files to mix and play.
+ /// true if at least one valid source was found and playback started; otherwise, false.
+ public bool PlayMixed(bool clearQueue = true, params string[] paths)
{
- if (!File.Exists(path))
- throw new FileNotFoundException("The specified file does not exist.", path);
+ if (paths == null || paths.Length == 0)
+ {
+ Log.Error("[Speaker] No paths provided for PlayMixed!");
+ return false;
+ }
+
+ if (clearQueue)
+ TrackQueue.Clear();
+
+ bool added = false;
- if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
- throw new NotSupportedException($"The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file.");
+ foreach (string path in paths)
+ {
+ if (!WavUtility.TryValidatePath(path, out string errorMessage))
+ {
+ Log.Error($"[Speaker] Invalid path skipped for mixing: '{path}' | Reason: {errorMessage}");
+ continue;
+ }
+
+ try
+ {
+ IPcmSource newSource = WavUtility.CreatePcmSource(path, false, false);
+ if (newSource != null)
+ {
+ if (AddMixed(newSource))
+ added = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to mix path '{path}'. Exception: {ex.Message}");
+ }
+ }
+
+ return added;
+ }
+
+ ///
+ /// Plays audio directly from a provided PCM source.
+ ///
+ /// The custom IPcmSource to play.
+ /// If true, clears the upcoming tracks in the playlist before starting playback.
+ /// true if the source is valid and playback started; otherwise, false.
+ public bool Play(IPcmSource customSource, bool clearQueue = true)
+ {
+ if (customSource == null)
+ {
+ Log.Error("[Speaker] Provided custom IPcmSource is null!");
+ return false;
+ }
TryInitializePlayBack();
- Stop();
+ Stop(clearQueue);
+
+ CurrentSource = customSource;
+ LastTrackInfo = CurrentSource.TrackInfo;
+
+ if (CurrentSource is ILiveSource)
+ Pitch = 1.0f;
- Loop = loop;
- LastTrack = path;
- DestroyAfter = destroyAfter;
- source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path);
playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject));
+ return true;
+ }
+
+ ///
+ /// Dynamically mixes a new audio source into the currently playing audio without interrupting it.
+ ///
+ /// The additional to mix with the current playback.
+ /// true if the source was successfully mixed or started; otherwise, false.
+ public bool AddMixed(IPcmSource extraSource)
+ {
+ if (extraSource == null)
+ {
+ Log.Error("[Speaker] Provided extra IPcmSource for mixing is null!");
+ return false;
+ }
+
+ if (!playBackRoutine.IsRunning || CurrentSource == null || CurrentSource.Ended)
+ return Play(extraSource, false);
+
+ if (extraSource is ILiveSource)
+ Pitch = 1.0f;
+
+ if (CurrentSource is MixerSource currentMixer)
+ {
+ currentMixer.AddSource(extraSource);
+ return true;
+ }
+
+ try
+ {
+ IPcmSource oldSource = CurrentSource;
+ MixerSource newMixer = new(oldSource, extraSource);
+ CurrentSource = newMixer;
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to transition to MixerSource on the fly!\nException Details: {ex}");
+ return false;
+ }
}
///
/// Stops playback.
///
- public void Stop()
+ /// If true, clears the upcoming tracks in the playlist.
+ public void Stop(bool clearQueue = true)
{
+ if (!isPlayBackInitialized)
+ return;
+
if (playBackRoutine.IsRunning)
{
- Timing.KillCoroutines(playBackRoutine);
+ playBackRoutine.IsRunning = false;
+
OnPlaybackStopped?.Invoke();
+ SpeakerEvents.OnPlaybackStopped(this);
}
- source?.Dispose();
- source = null;
+ if (clearQueue)
+ TrackQueue.Clear();
+
+ StopFade();
+ ResetEncoder();
+ ClearScheduledEvents();
+
+ Filter?.Reset();
+ CurrentSource?.Dispose();
+ CurrentSource = null;
}
- private void TryInitializePlayBack()
+ ///
+ /// Fades the volume to a specific target over a given duration.
+ /// IMPORTANT: If the property is manually changed while a fade is in progress, the fade operation will be immediately aborted.
+ ///
+ /// The initial volume level when the fade begins.
+ /// The final volume level to reach at the end of the fade.
+ /// The time in seconds the fading process should take to complete.
+ /// If true, uses linear interpolation; if false, uses natural easing (ease-in for fade-in, ease-out for fade-out).
+ /// An optional action to invoke when the fade process is fully finished.
+ public void FadeVolume(float startVolume, float targetVolume, float duration = 3, bool linear = false, Action onComplete = null)
{
- if (isPlayBackInitialized)
+ if (fadeRoutine.IsRunning)
+ fadeRoutine.IsRunning = false;
+
+ fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, linear, onComplete).CancelWith(GameObject));
+ }
+
+ ///
+ /// Stops currently active volume fading process, leaving the volume at its exact current level.
+ ///
+ public void StopFade()
+ {
+ if (fadeRoutine.IsRunning)
+ fadeRoutine.IsRunning = false;
+ }
+
+ ///
+ /// Restarts the currently playing track from the beginning.
+ ///
+ public void RestartTrack()
+ {
+ if (!playBackRoutine.IsRunning)
return;
- isPlayBackInitialized = true;
+ CurrentTime = 0.0;
+ }
- frame = new float[FrameSize];
- resampleBuffer = Array.Empty();
- encoder = new(OpusApplicationType.Audio);
- encoded = new byte[VoiceChatSettings.MaxEncodedSize];
+ ///
+ /// Helper method to easily queue a .wav file/url with stream support.
+ ///
+ /// The path/url or custom name(if is true) to the wav file.
+ /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs).
+ /// If true, loads the audio via for optimized playback.
+ /// true if successfully queued or started.
+ public bool QueueTrack(string path, bool isStream = false, bool useCache = false)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ Log.Error("[Speaker] Provided path or cache name cannot be null or empty!");
+ return false;
+ }
- AdminToyBase.OnRemoved += OnToyRemoved;
+ if (!useCache && !WavUtility.TryValidatePath(path, out string errorMessage))
+ {
+ Log.Error($"[Speaker] {errorMessage}");
+ return false;
+ }
+
+ return QueueTrack(new QueuedTrack(path, () => WavUtility.CreatePcmSource(path, isStream, useCache)));
}
- private IEnumerator PlayBackCoroutine()
+ ///
+ /// Adds a track to the playback queue. If nothing is playing, playback starts immediately.
+ ///
+ /// The queued track containing its creation logic and optional identifier.
+ /// true if successfully queued or started.
+ public bool QueueTrack(QueuedTrack track)
{
- OnPlaybackStarted?.Invoke();
+ if (!playBackRoutine.IsRunning && !IsPaused)
+ return Play(track.SourceProvider.Invoke());
+
+ TrackQueue.Add(track);
+ return true;
+ }
+
+ ///
+ /// Skips the currently playing track and starts playing the next one in the queue.
+ ///
+ public void SkipTrack()
+ {
+ if (TrackQueue.Count == 0)
+ {
+ Stop();
+ return;
+ }
+
+ Stop(clearQueue: false);
+
+ QueuedTrack nextTrack = TrackQueue[0];
+ TrackQueue.RemoveAt(0);
+
+ try
+ {
+ IPcmSource newSource = nextTrack.SourceProvider.Invoke();
+
+ OnTrackSwitching?.Invoke(nextTrack);
+ SpeakerEvents.OnTrackSwitching(this, nextTrack);
+
+ Play(newSource, clearQueue: false);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Playlist next track failed: '{nextTrack}'.\n{ex}");
+ SkipTrack();
+ }
+ }
+
+ ///
+ /// Removes a specific track from the playback queue by its file path.
+ ///
+ /// The exact file path of the track to remove.
+ /// If true, removes the first occurrence; if false, removes the last occurrence.
+ /// true if the track was successfully found and removed; otherwise, false.
+ public bool RemoveTrack(string path, bool findFirst = true)
+ {
+ int index = findFirst ? TrackQueue.FindIndex(t => t.Name == path) : TrackQueue.FindLastIndex(t => t.Name == path);
+
+ if (index == -1)
+ return false;
+
+ TrackQueue.RemoveAt(index);
+ return true;
+ }
+
+ ///
+ /// Shuffles the tracks in the into a random order with Fisher-Yates algorithm.
+ ///
+ public void ShuffleTracks()
+ {
+ if (TrackQueue.Count <= 1)
+ return;
+
+ for (int i = TrackQueue.Count - 1; i > 0; i--)
+ {
+ int j = Random.Range(0, i + 1);
+ (TrackQueue[i], TrackQueue[j]) = (TrackQueue[j], TrackQueue[i]);
+ }
+ }
+
+ ///
+ /// Adds an action to be executed at a specific time in seconds during the current playback.
+ /// WARNING: Heavy operations can cause audio interruptions. If you need to perform heavy operations, start a MEC Coroutine inside the action.
+ ///
+ /// The exact time in seconds to trigger the action.
+ /// The action to invoke when the specified time is reached.
+ /// An optional unique string identifier for this event. If not provided, a random GUID will be assigned.
+ /// The unique string ID of the created time event, which can be used to remove it later via .
+ public string AddScheduledEvent(double timeInSeconds, Action action, string id = null)
+ {
+ ScheduledEvent timeEvent = new(timeInSeconds, action, id);
+
+ ScheduledEvents.Add(timeEvent);
+ ScheduledEvents.Sort();
+ UpdateNextScheduledEventIndex();
+
+ return timeEvent.Id;
+ }
+
+ ///
+ /// Removes a specific time-based event using its ID.
+ ///
+ /// The unique string identifier of the event to remove.
+ /// true if the event was successfully found and removed; otherwise, false.
+ public bool RemoveScheduledEvent(string id)
+ {
+ int removed = ScheduledEvents.RemoveAll(e => e.Id == id);
+
+ if (removed <= 0)
+ return false;
+
+ UpdateNextScheduledEventIndex();
+ return true;
+ }
+
+ ///
+ /// Clears all time-based events for the current playback.
+ ///
+ public void ClearScheduledEvents()
+ {
+ ScheduledEvents.Clear();
+ nextScheduledEventIndex = 0;
+ }
+
+ ///
+ /// Stops the current playback, resets all properties of the , and returns the instance to the object pool for future reuse.
+ ///
+ public void ReturnToPool()
+ {
+ if (Base == null)
+ return;
+
+ Stop();
+
+ if (Transform.parent != null || AdminToyBase._clientParentId != 0)
+ {
+ Transform.parent = null;
+ Base.RpcChangeParent(0);
+ }
+ LocalPosition = SpeakerParkPosition;
+
+ Volume = DefaultVolume;
+ IsSpatial = DefaultSpatial;
+ MinDistance = DefaultMinDistance;
+ MaxDistance = DefaultMaxDistance;
+
+ IsStatic = true;
+ Loop = false;
+ DestroyAfter = false;
+ ReturnToPoolAfter = false;
+ PlayMode = SpeakerPlayMode.Global;
+ Channel = Channels.ReliableOrdered2;
+
+ LastTrackInfo = default;
+
+ Predicate = null;
+ TargetPlayer = null;
+ TargetPlayers = null;
+
+ Pitch = 1f;
+ Filter = null;
resampleTime = 0.0;
resampleBufferFilled = 0;
+ isPitchDefault = true;
+ needsSyncWait = false;
- float timeAccumulator = 0f;
+ OnPlaybackStarted = null;
+ OnPlaybackPaused = null;
+ OnPlaybackResumed = null;
+ OnPlaybackLooped = null;
+ OnTrackSwitching = null;
+ OnPlaybackFinished = null;
+ OnPlaybackStopped = null;
- while (true)
+ SpeakerToyPlaybackBase.AllInstances.Remove(Base.Playback);
+
+ Pool.Enqueue(this);
+ }
+
+ ///
+ /// Sends the constructed audio message to the appropriate players based on the current .
+ ///
+ /// The .
+ public void SendAudioMessage(AudioMessage audioMessage)
+ {
+ switch (PlayMode)
{
- timeAccumulator += Time.deltaTime;
+ case SpeakerPlayMode.Global:
+ NetworkServer.SendToReady(audioMessage, Channel);
+ break;
- while (timeAccumulator >= FrameTime)
- {
- timeAccumulator -= FrameTime;
+ case SpeakerPlayMode.Player:
+ TargetPlayer?.Connection?.Send(audioMessage, Channel);
+ break;
- if (isPitchDefault)
+ case SpeakerPlayMode.PlayerList:
+
+ if (TargetPlayers is null)
+ break;
+
+ using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
- int read = source.Read(frame, 0, FrameSize);
- if (read < FrameSize)
- Array.Clear(frame, read, FrameSize - read);
+ NetworkMessages.Pack(audioMessage, writer);
+ ArraySegment segment = writer.ToArraySegment();
+
+ foreach (Player ply in TargetPlayers)
+ {
+ ply?.Connection?.Send(segment, Channel);
+ }
}
- else
+
+ break;
+
+ case SpeakerPlayMode.Predicate:
+ if (Predicate is null)
+ break;
+
+ using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
- ResampleFrame();
+ NetworkMessages.Pack(audioMessage, writer);
+ ArraySegment segment = writer.ToArraySegment();
+
+ foreach (Player ply in Player.List)
+ {
+ if (Predicate(ply))
+ ply.Connection?.Send(segment, Channel);
+ }
}
- int len = encoder.Encode(frame, encoded);
+ break;
+ }
+ }
- if (len > 2)
- SendPacket(len);
+ private void TryInitializePlayBack()
+ {
+ if (isPlayBackInitialized)
+ return;
- if (!source.Ended)
- continue;
+ isPlayBackInitialized = true;
- OnPlaybackFinished?.Invoke(LastTrack);
+ frame = new float[FrameSize];
+ resampleBuffer = Array.Empty();
+ encoder = new(OpusApplicationType.Audio);
+ encoded = new byte[VoiceChatSettings.MaxEncodedSize];
- if (Loop)
- {
- source.Reset();
- OnPlaybackLooped?.Invoke();
- resampleTime = resampleBufferFilled = 0;
- continue;
- }
+ // 3002 => OPUS_SIGNAL_MUSIC (https://github.com/xiph/opus/blob/2d862ea14b233e5a3f3afaf74d96050691af3cd5/include/opus_defines.h#L229)
+ OpusWrapper.SetEncoderSetting(encoder._handle, OpusCtlSetRequest.Signal, 3002);
- if (DestroyAfter)
- Destroy();
- else
- Stop();
+ AdminToyBase.OnRemoved += OnToyRemoved;
+ }
- yield break;
- }
+ private void UpdateNextScheduledEventIndex()
+ {
+ nextScheduledEventIndex = 0;
+ double current = CurrentTime;
- yield return Timing.WaitForOneFrame;
+ while (nextScheduledEventIndex < ScheduledEvents.Count && ScheduledEvents[nextScheduledEventIndex].Time <= current)
+ {
+ nextScheduledEventIndex++;
+ }
+ }
+
+ private void ResetEncoder()
+ {
+ if (encoder != null && encoder._handle != IntPtr.Zero)
+ {
+ // 4028 => OPUS_RESET_STATE (https://github.com/xiph/opus/blob/2d862ea14b233e5a3f3afaf74d96050691af3cd5/include/opus_defines.h#L710)
+ OpusWrapper.SetEncoderSetting(encoder._handle, (OpusCtlSetRequest)4028, 0);
}
}
@@ -475,7 +1159,7 @@ private void ResampleFrame()
if (resampleBufferFilled == 0)
{
int toRead = resampleBuffer.Length - 4;
- int actualRead = source.Read(resampleBuffer, 0, toRead);
+ int actualRead = CurrentSource.Read(resampleBuffer, 0, toRead);
if (actualRead == 0)
{
@@ -497,7 +1181,7 @@ private void ResampleFrame()
resampleBuffer[0] = resampleBuffer[resampleBufferFilled - 1];
int toRead = resampleBuffer.Length - 5;
- int actualRead = source.Read(resampleBuffer, 1, toRead);
+ int actualRead = CurrentSource.Read(resampleBuffer, 1, toRead);
if (actualRead == 0)
{
@@ -527,61 +1211,151 @@ private void ResampleFrame()
}
}
- private void SendPacket(int len)
+ private void OnToyRemoved(AdminToyBase toy)
{
- AudioMessage msg = new(ControllerId, encoded, len);
+ if (toy != Base)
+ return;
- switch (PlayMode)
+ AdminToyBase.OnRemoved -= OnToyRemoved;
+
+ Stop();
+ encoder?.Dispose();
+
+ OnPlaybackStarted = null;
+ OnPlaybackPaused = null;
+ OnPlaybackResumed = null;
+ OnPlaybackLooped = null;
+ OnTrackSwitching = null;
+ OnPlaybackFinished = null;
+ OnPlaybackStopped = null;
+ }
+
+ private IEnumerator PlayBackCoroutine()
+ {
+ if (needsSyncWait)
{
- case SpeakerPlayMode.Global:
- NetworkServer.SendToReady(msg, Channel);
- break;
+ int framesPassed = Time.frameCount - idChangeFrame;
+ while (framesPassed < 2)
+ {
+ yield return Timing.WaitForOneFrame;
+ framesPassed = Time.frameCount - idChangeFrame;
+ }
- case SpeakerPlayMode.Player:
- TargetPlayer?.Connection.Send(msg, Channel);
- break;
+ needsSyncWait = false;
+ }
- case SpeakerPlayMode.PlayerList:
- using (NetworkWriterPooled writer = NetworkWriterPool.Get())
- {
- NetworkMessages.Pack(msg, writer);
- ArraySegment segment = writer.ToArraySegment();
+ OnPlaybackStarted?.Invoke();
+ SpeakerEvents.OnPlaybackStarted(this);
- foreach (Player ply in TargetPlayers)
- {
- ply?.Connection.Send(segment, Channel);
- }
+ resampleTime = 0.0;
+ resampleBufferFilled = 0;
+
+ float timeAccumulator = 0f;
+
+ while (true)
+ {
+ timeAccumulator += Time.deltaTime;
+
+ while (timeAccumulator >= FrameTime)
+ {
+ timeAccumulator -= FrameTime;
+
+ if (isPitchDefault)
+ {
+ int read = CurrentSource.Read(frame, 0, FrameSize);
+ if (read < FrameSize)
+ Array.Clear(frame, read, FrameSize - read);
+ }
+ else
+ {
+ ResampleFrame();
}
- break;
+ Filter?.Process(frame);
- case SpeakerPlayMode.Predicate:
- using (NetworkWriterPooled writer = NetworkWriterPool.Get())
+ int len = encoder.Encode(frame, encoded);
+
+ if (len > 2)
+ SendAudioMessage(new AudioMessage(ControllerId, encoded, len));
+
+ if (!CurrentSource.Ended)
+ continue;
+
+ OnPlaybackFinished?.Invoke();
+ SpeakerEvents.OnPlaybackFinished(this);
+
+ yield return Timing.WaitForOneFrame;
+
+ if (Loop)
{
- NetworkMessages.Pack(msg, writer);
- ArraySegment segment = writer.ToArraySegment();
+ resampleTime = 0;
+ timeAccumulator = 0;
+ resampleBufferFilled = 0;
+ nextScheduledEventIndex = 0;
- foreach (Player ply in Player.List)
- {
- if (Predicate(ply))
- ply.Connection.Send(segment, Channel);
- }
+ ResetEncoder();
+ Filter?.Reset();
+ CurrentSource.Reset();
+
+ OnPlaybackLooped?.Invoke();
+ SpeakerEvents.OnPlaybackLooped(this);
+ continue;
}
- break;
+ if (TrackQueue.Count > 0)
+ {
+ playBackRoutine.IsRunning = false;
+ SkipTrack();
+ yield break;
+ }
+
+ if (ReturnToPoolAfter)
+ ReturnToPool();
+ else if (DestroyAfter)
+ Destroy();
+ else
+ Stop();
+
+ yield break;
+ }
+
+ while (nextScheduledEventIndex < ScheduledEvents.Count && CurrentTime >= ScheduledEvents[nextScheduledEventIndex].Time)
+ {
+ try
+ {
+ ScheduledEvents[nextScheduledEventIndex].Action?.Invoke();
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to execute scheduled time event at {ScheduledEvents[nextScheduledEventIndex].Time:F2}s.\nException Details: {ex}");
+ }
+
+ nextScheduledEventIndex++;
+ }
+
+ yield return Timing.WaitForOneFrame;
}
}
- private void OnToyRemoved(AdminToyBase toy)
+ private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, bool linear, Action onComplete)
{
- if (toy != Base)
- return;
+ float timePassed = 0f;
+ bool isFadeOut = startVolume > targetVolume;
- AdminToyBase.OnRemoved -= OnToyRemoved;
+ while (timePassed < duration)
+ {
+ timePassed += Time.deltaTime;
+ float t = timePassed / duration;
- Stop();
+ if (!linear)
+ t = isFadeOut ? 1f - ((1f - t) * (1f - t)) : t * t;
- encoder?.Dispose();
+ Base.NetworkVolume = Mathf.Lerp(startVolume, targetVolume, t);
+ yield return Timing.WaitForOneFrame;
+ }
+
+ Base.NetworkVolume = targetVolume;
+ onComplete?.Invoke();
}
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs b/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs
new file mode 100644
index 0000000000..3bd096a2a7
--- /dev/null
+++ b/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs
@@ -0,0 +1,26 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Interfaces.Audio
+{
+ ///
+ /// Represents a custom filter for the speaker.
+ ///
+ public interface IAudioFilter
+ {
+ ///
+ /// Processes the raw PCM audio frame directly before it is encoded and sending.
+ ///
+ /// The array of PCM audio samples.
+ void Process(float[] frame);
+
+ ///
+ /// Resets the internal state and buffers of the filter.
+ ///
+ void Reset();
+ }
+}
diff --git a/EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs b/EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs
new file mode 100644
index 0000000000..ad9c9caea9
--- /dev/null
+++ b/EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs
@@ -0,0 +1,16 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Interfaces.Audio
+{
+ ///
+ /// A marker interface used to identify PCM sources that are live or continuous.
+ ///
+ public interface ILiveSource
+ {
+ }
+}
diff --git a/EXILED/Exiled.API/Interfaces/IPcmSource.cs b/EXILED/Exiled.API/Interfaces/Audio/IPcmSource.cs
similarity index 89%
rename from EXILED/Exiled.API/Interfaces/IPcmSource.cs
rename to EXILED/Exiled.API/Interfaces/Audio/IPcmSource.cs
index 680f568410..6f0423b86f 100644
--- a/EXILED/Exiled.API/Interfaces/IPcmSource.cs
+++ b/EXILED/Exiled.API/Interfaces/Audio/IPcmSource.cs
@@ -5,10 +5,12 @@
//
// -----------------------------------------------------------------------
-namespace Exiled.API.Interfaces
+namespace Exiled.API.Interfaces.Audio
{
using System;
+ using Exiled.API.Structs.Audio;
+
///
/// Represents a source of PCM audio data.
///
@@ -29,6 +31,11 @@ public interface IPcmSource : IDisposable
///
double CurrentTime { get; set; }
+ ///
+ /// Gets the metadata of the streaming track.
+ ///
+ TrackData TrackInfo { get; }
+
///
/// Reads a sequence of PCM samples into the specified buffer.
///
diff --git a/EXILED/Exiled.API/Structs/Audio/AudioData.cs b/EXILED/Exiled.API/Structs/Audio/AudioData.cs
new file mode 100644
index 0000000000..e8924f8c10
--- /dev/null
+++ b/EXILED/Exiled.API/Structs/Audio/AudioData.cs
@@ -0,0 +1,36 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Structs.Audio
+{
+ ///
+ /// Represents raw audio data and its associated metadata.
+ ///
+ public struct AudioData
+ {
+ ///
+ /// Gets the raw PCM audio samples.
+ ///
+ public float[] Pcm;
+
+ ///
+ /// Gets the metadata of the audio track, including its total duration.
+ ///
+ public TrackData TrackInfo;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The raw PCM float array containing the audio data.
+ /// The metadata associated with the audio track.
+ public AudioData(float[] pcmData, TrackData trackInfo)
+ {
+ Pcm = pcmData;
+ TrackInfo = trackInfo;
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs b/EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs
new file mode 100644
index 0000000000..34d1cfbf63
--- /dev/null
+++ b/EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs
@@ -0,0 +1,40 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Structs.Audio
+{
+ using System;
+
+ using Exiled.API.Interfaces.Audio;
+
+ ///
+ /// Represents a track waiting in the queue, along with its specific playback options.
+ ///
+ public readonly struct QueuedTrack
+ {
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The name, path, or identifier of the track (used for displaying or removing from queue).
+ /// A function that returns the instantiated .
+ public QueuedTrack(string name, Func sourceFactory)
+ {
+ Name = name;
+ SourceProvider = sourceFactory;
+ }
+
+ ///
+ /// Gets the name, path, or identifier of the track.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the provider function used to create the custom audio source on demand.
+ ///
+ public Func SourceProvider { get; }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Structs/Audio/TrackData.cs b/EXILED/Exiled.API/Structs/Audio/TrackData.cs
new file mode 100644
index 0000000000..93f549369f
--- /dev/null
+++ b/EXILED/Exiled.API/Structs/Audio/TrackData.cs
@@ -0,0 +1,74 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Structs.Audio
+{
+ using System;
+
+ ///
+ /// Contains metadata about a audio track.
+ ///
+ public struct TrackData
+ {
+ ///
+ /// Gets the title of the track, if available in the metadata.
+ ///
+ public string Title { get; internal set; }
+
+ ///
+ /// Gets the artist of the track, if available in the metadata.
+ ///
+ public string Artist { get; internal set; }
+
+ ///
+ /// Gets the total duration of the track in seconds.
+ ///
+ public double Duration { get; internal set; }
+
+ ///
+ /// Gets the file path of the track.
+ ///
+ public string Path { get; internal set; }
+
+ ///
+ /// Gets a value indicating whether the track data is completely empty.
+ ///
+ public readonly bool IsEmpty => string.IsNullOrEmpty(Title) && string.IsNullOrEmpty(Artist) && Duration <= 0;
+
+ ///
+ /// Gets a formatted display name for the track.
+ ///
+ public string DisplayName
+ {
+ get
+ {
+ if (!string.IsNullOrEmpty(Artist) && !string.IsNullOrEmpty(Title))
+ return $"{Artist} - {Title}";
+
+ if (!string.IsNullOrEmpty(Title))
+ return Title;
+
+ if (!string.IsNullOrEmpty(Path))
+ return System.IO.Path.GetFileNameWithoutExtension(Path);
+
+ return "Unknown Track";
+ }
+ }
+
+ ///
+ /// Gets the duration formatted as a digital clock string.
+ ///
+ public readonly string FormattedDuration
+ {
+ get
+ {
+ TimeSpan t = TimeSpan.FromSeconds(Duration);
+ return t.Hours > 0 ? t.ToString(@"hh\:mm\:ss") : t.ToString(@"mm\:ss");
+ }
+ }
+ }
+}