diff --git a/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs b/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs
index 10db73bbb4..32f75d7d6c 100644
--- a/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs
+++ b/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs
@@ -7,18 +7,12 @@
namespace Exiled.API.Features.Audio
{
- using System;
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 .
///
@@ -40,47 +34,6 @@ static AudioDataStorage()
///
public static bool ClearOnRoundRestart { get; set; } = true;
- ///
- /// Loads and stores a local .wav file under the specified name.
- ///
- /// The unique storage key to assign to this audio.
- /// The absolute path to the local .wav file.
- /// true if the file was successfully loaded and stored; otherwise, false.
- public static bool AddWav(string name, string path)
- {
- if (!ValidateName(name))
- return false;
-
- if (AudioStorage.ContainsKey(name))
- {
- Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping add.");
- return false;
- }
-
- if (path.StartsWith("http"))
- {
- Log.Error($"[AudioDataStorage] '{path}' is a URL. Use AudioDataStorage.AddUrl() for web sources.");
- return false;
- }
-
- if (!File.Exists(path))
- {
- Log.Error($"[AudioDataStorage] Local file not found: '{path}'");
- return false;
- }
-
- try
- {
- AudioData parsed = WavUtility.WavToPcm(path);
- return AudioStorage.TryAdd(name, parsed);
- }
- catch (Exception ex)
- {
- Log.Error($"[AudioDataStorage] Failed to load '{path}' into storage:\n{ex}");
- return false;
- }
- }
-
///
/// Stores raw PCM audio samples under the specified name.
///
@@ -130,58 +83,6 @@ public static bool Add(string name, AudioData audioData)
return AudioStorage.TryAdd(name, audioData);
}
- ///
- /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the storage.
- ///
- /// The unique storage key to assign.
- /// The HTTP or HTTPS URL pointing to a valid .wav file.
- /// A for the running download coroutine.
- public static CoroutineHandle AddWavUrl(string name, string url) => Timing.RunCoroutine(AddUrlCoroutine(name, url));
-
- ///
- /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the storage.
- ///
- /// The unique storage key to assign.
- /// The HTTP or HTTPS URL pointing to a valid .wav file.
- /// A MEC-compatible of .
- public static IEnumerator AddUrlCoroutine(string name, string url)
- {
- if (!ValidateName(name))
- yield break;
-
- if (string.IsNullOrEmpty(url) || !url.StartsWith("http"))
- {
- Log.Error($"[AudioDataStorage] Invalid URL for key '{name}': '{url}'. Must start with http/https.");
- yield break;
- }
-
- if (AudioStorage.ContainsKey(name))
- {
- Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping download.");
- yield break;
- }
-
- using UnityWebRequest www = UnityWebRequest.Get(url);
- yield return Timing.WaitUntilDone(www.SendWebRequest());
-
- if (www.result != UnityWebRequest.Result.Success)
- {
- Log.Error($"[AudioDataStorage] Download failed for '{url}': {www.error}");
- yield break;
- }
-
- try
- {
- AudioData parsed = WavUtility.WavToPcm(www.downloadHandler.data);
- parsed.TrackInfo.Path = url;
- AudioStorage.TryAdd(name, parsed);
- }
- catch (Exception ex)
- {
- Log.Error($"[AudioDataStorage] Failed to parse downloaded WAV from '{url}':\n{ex}");
- }
- }
-
///
/// Removes a stored audio entry by name.
///
@@ -194,7 +95,12 @@ public static IEnumerator AddUrlCoroutine(string name, string url)
///
public static void Clear() => AudioStorage.Clear();
- private static bool ValidateName(string name)
+ ///
+ /// Validates that the storage name (key) is valid.
+ ///
+ /// The storage name (key) to validate.
+ /// True when name is valid; otherwise false.
+ internal static bool ValidateName(string name)
{
if (!string.IsNullOrEmpty(name))
return true;
diff --git a/EXILED/Exiled.API/Features/Audio/Extensions/SourceExtensions.cs b/EXILED/Exiled.API/Features/Audio/Extensions/SourceExtensions.cs
new file mode 100644
index 0000000000..2c7920e6cf
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/Extensions/SourceExtensions.cs
@@ -0,0 +1,154 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+namespace Exiled.API.Features.Audio.Extensions
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+
+ using Exiled.API.Features.Audio.PcmSources;
+ using Exiled.API.Features.Toys;
+ using Exiled.API.Interfaces.Audio;
+
+ ///
+ /// Provides extension methods for playing audio for pre-made sources on Speaker instances.
+ ///
+ public static class SourceExtensions
+ {
+ ///
+ /// Plays the live voice of a specific player through this speaker.
+ ///
+ /// The speaker through which to play the audio.
+ /// 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 static bool PlayFromPlayer(this Speaker speaker, 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 speaker.Play(source, clearQueue);
+ }
+
+ ///
+ /// Plays the specified text as speech through this speaker using the VoiceRss TTS service.
+ ///
+ /// The speaker that will play the generated speech.
+ /// The text to convert to speech.(Length limited by 100KB).
+ /// Your VoiceRSS API keys. Get a free key at .
+ /// The language and locale code for the TTS voice. See for all supported language codes.
+ /// Optional specific voice name for the selected language.(See for available voices per language.)
+ /// Speech rate from -10 (slowest) to 10 (fastest). Defaults to 0 (normal speed).
+ /// If true, clears the upcoming tracks in the playlist before starting playback.
+ /// true if the TTS playback started successfully; otherwise, false.
+ public static bool PlayTts(this Speaker speaker, string text, IEnumerable apiKeys, string language = "en-us", string voice = null, int rate = 0, bool clearQueue = true)
+ {
+ VoiceRssTtsSource ttsSource;
+ try
+ {
+ ttsSource = new(text, apiKeys, language, voice, rate);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"Failed to create TTS source: {ex.Message}");
+ return false;
+ }
+
+ return speaker.Play(ttsSource, clearQueue);
+ }
+
+ ///
+ /// Plays multiple instances mixed together.
+ ///
+ /// The speaker through which to play the audio.
+ /// The collection of PCM sources to mix and play.
+ /// If true, clears the upcoming tracks in the playlist before starting playback.
+ /// true if at least one source was successfully mixed; otherwise, false.
+ public static bool PlayMixed(this Speaker speaker, IEnumerable sources, bool clearQueue = true)
+ {
+ if (sources == null || !sources.Any())
+ {
+ Log.Error("[Speaker] No sources provided for PlayMixed!");
+ return false;
+ }
+
+ if (clearQueue)
+ speaker.TrackQueue.Clear();
+
+ bool anyAdded = false;
+
+ foreach (IPcmSource source in sources)
+ {
+ if (source == null)
+ continue;
+
+ if (speaker.AddMixed(source))
+ anyAdded = true;
+ }
+
+ return anyAdded;
+ }
+
+ ///
+ /// Dynamically mixes a new audio source into the currently playing audio without interrupting it.
+ ///
+ /// The speaker through which to play the audio.
+ /// The additional to mix with the current playback.
+ /// true if the source was successfully mixed or started; otherwise, false.
+ public static bool AddMixed(this Speaker speaker, IPcmSource extraSource)
+ {
+ if (extraSource == null)
+ {
+ Log.Error("[Speaker] Provided extra IPcmSource for mixing is null!");
+ return false;
+ }
+
+ if (extraSource is ILiveSource)
+ speaker.Pitch = 1.0f;
+
+ IPcmSource currentSource = speaker.CurrentSource;
+
+ if ((!speaker.IsPlaying && !speaker.IsPaused) || currentSource == null || currentSource.Ended)
+ return speaker.Play(extraSource, false);
+
+ if (currentSource is MixerSource currentMixer)
+ {
+ currentMixer.AddSource(extraSource);
+ return true;
+ }
+
+ try
+ {
+ IPcmSource oldSource = currentSource;
+ MixerSource newMixer = new([oldSource, extraSource]);
+ speaker.CurrentSource = newMixer;
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to transition to MixerSource on the fly!\nException Details: {ex}");
+ return false;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/Extensions/WavExtensions.cs b/EXILED/Exiled.API/Features/Audio/Extensions/WavExtensions.cs
new file mode 100644
index 0000000000..d0e881f3e6
--- /dev/null
+++ b/EXILED/Exiled.API/Features/Audio/Extensions/WavExtensions.cs
@@ -0,0 +1,139 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) ExMod Team. All rights reserved.
+// Licensed under the CC BY-SA 3.0 license.
+//
+// -----------------------------------------------------------------------
+
+#pragma warning disable SA1129 // Do not use default value type constructor
+namespace Exiled.API.Features.Audio.Extensions
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+
+ using Exiled.API.Features.Audio.PcmSources;
+ using Exiled.API.Features.Toys;
+ using Exiled.API.Interfaces.Audio;
+ using Exiled.API.Structs.Audio;
+
+ ///
+ /// Provides methods to play 16-bit, mono, 48 kHz WAV files or web streams via Speaker.
+ ///
+ public static class WavExtensions
+ {
+ ///
+ /// Plays a local wav file or web URL through this speaker. (File must be 16-bit, mono, and 48kHz.)
+ ///
+ /// The speaker through which to play the audio.
+ /// 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 static bool PlayWav(this Speaker speaker, 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 speaker.Play(newSource, clearQueue);
+ }
+
+ ///
+ /// Converts provided paths/URLs to sources and plays them mixed together.
+ ///
+ /// The speaker through which to play the audio.
+ /// The collection of paths or URLs to the audio files.
+ /// If true, clears the upcoming tracks in the playlist before starting playback.
+ /// If true, streams local files from disk. (Ignored for web URLs).
+ /// If true, utilizes for the sources.
+ /// true if at least one valid path was loaded and started; otherwise, false.
+ public static bool PlayMixedWav(this Speaker speaker, IEnumerable paths, bool clearQueue = true, bool stream = false, bool useCache = false)
+ {
+ if (paths == null || !paths.Any())
+ {
+ Log.Error("[Speaker] No paths provided for PlayMixedWav!");
+ return false;
+ }
+
+ List createdSources = new();
+
+ foreach (string path in paths)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ Log.Warn("[Speaker] One of the provided paths for PlayMixedWav is null or empty. Skipping this entry.");
+ continue;
+ }
+
+ if (!useCache && !WavUtility.TryValidatePath(path, out string error))
+ {
+ Log.Error($"[Speaker] Skipping invalid path in mix: {path}. Reason: {error}");
+ continue;
+ }
+
+ try
+ {
+ IPcmSource source = WavUtility.CreatePcmSource(path, stream, useCache);
+ if (source != null)
+ createdSources.Add(source);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Failed to create source for mix from '{path}': {ex.Message}");
+ }
+ }
+
+ if (createdSources.Count == 0)
+ return false;
+
+ return speaker.PlayMixed(createdSources, clearQueue);
+ }
+
+ ///
+ /// Helper method to easily queue a .wav file/url with stream support.
+ ///
+ /// The speaker through which to queue the track.
+ /// An optional name or identifier for this track in the queue. This is only used for reference.
+ /// The path/url or custom name(if is true) to the wav file.
+ /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs).
+ /// If true, loads the audio via for optimized playback.
+ /// true if successfully queued or started.
+ public static bool QueueWavTrack(this Speaker speaker, string name, string path, bool isStream = false, bool useCache = false)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ Log.Error("[Speaker] Provided path or cache name cannot be null or empty!");
+ return false;
+ }
+
+ if (!useCache && !WavUtility.TryValidatePath(path, out string errorMessage))
+ {
+ Log.Error($"[Speaker] {errorMessage}");
+ return false;
+ }
+
+ return speaker.QueueTrack(new QueuedTrack(name, () => WavUtility.CreatePcmSource(path, isStream, useCache)));
+ }
+ }
+}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs
index 6cdcee5c0f..4856100ef9 100644
--- a/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs
+++ b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs
@@ -171,10 +171,12 @@ private void CalculateBiquad(float dampValue)
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;
+ float w0Cos = Mathf.Cos(w0);
+ float oneMinusW0Cos = 1f - w0Cos;
+ b0 = (oneMinusW0Cos / 2f) / a0;
+ b1 = oneMinusW0Cos / a0;
+ b2 = (oneMinusW0Cos / 2f) / a0;
+ a1 = (-2f * w0Cos) / a0;
a2 = (1f - alpha) / a0;
}
}
diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs
index c9b8c94430..728ccb3bc1 100644
--- a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs
@@ -46,38 +46,6 @@ public CachedPcmSource(string name)
TrackInfo = cachedAudio.TrackInfo;
}
- ///
- /// Initializes a new instance of the class. Fetches cached audio or loads a local WAV file into the cache if not present.
- ///
- /// The custom name/key to assign to this audio in the cache.
- /// The absolute path to the local audio file.
- public CachedPcmSource(string name, string path)
- {
- if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(path))
- {
- Log.Error($"[CachedPcmSource] Cannot initialize CachedPcmSource. Invalid name: '{name}' or path: '{path}'.");
- throw new ArgumentException("Name or path cannot be null or empty.");
- }
-
- if (!AudioDataStorage.AudioStorage.ContainsKey(name))
- {
- if (!AudioDataStorage.AddWav(name, path))
- {
- Log.Error($"[CachedPcmSource] Failed to load local file '{path}' into cache under the name '{name}'.");
- throw new FileNotFoundException($"Failed to cache and load '{path}'.");
- }
- }
-
- if (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio))
- {
- Log.Error($"[CachedPcmSource] Audio with name '{name}' could not be retrieved from storage after adding.");
- throw new InvalidOperationException($"Failed to retrieve '{name}' from storage after caching.");
- }
-
- data = cachedAudio.Pcm;
- TrackInfo = cachedAudio.TrackInfo;
- }
-
///
/// Initializes a new instance of the class by fetching cached audio or injecting raw PCM samples into the cache if not present.
///
@@ -110,37 +78,23 @@ public CachedPcmSource(string name, float[] 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);
@@ -150,23 +104,15 @@ public int Read(float[] buffer, int offset, int count)
return read;
}
- ///
- /// Seeks to the specified position in seconds.
- ///
- /// The target position in seconds.
+ ///
public void Seek(double seconds)
{
long targetIndex = (long)(seconds * VoiceChatSettings.SampleRate);
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 Reset() => pos = 0;
///
public void Dispose()
diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs
index b97d3181a3..d4c41ed693 100644
--- a/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs
@@ -8,9 +8,11 @@
namespace Exiled.API.Features.Audio.PcmSources
{
using System;
+ using System.Buffers;
using System.Collections.Generic;
using System.Linq;
+ using Exiled.API.Features.Pools;
using Exiled.API.Interfaces.Audio;
using Exiled.API.Structs.Audio;
@@ -25,8 +27,9 @@ namespace Exiled.API.Features.Audio.PcmSources
///
public sealed class MixerSource : IPcmSource
{
- private readonly List sources = new();
- private float[] tempBuffer;
+ private readonly object sourcesLock = new();
+
+ private volatile IPcmSource[] sources;
///
/// Initializes a new instance of the class with the specified initial sources.
@@ -34,109 +37,137 @@ public sealed class MixerSource : IPcmSource
/// An array of instances to mix.
public MixerSource(IEnumerable initialSources)
{
- if (initialSources != null)
- sources.AddRange(initialSources.Where(s => s != null));
-
+ sources = initialSources?.Where(source => source != null).ToArray() ?? Array.Empty();
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.
+ /// Gets a value indicating whether this mixer contains any live audio sources.
///
- public bool KeepAlive { get; set; } = false;
+ public bool ContainsLiveSource
+ {
+ get
+ {
+ IPcmSource[] currentSources = sources;
+ return currentSources.Any(source => source is ILiveSource || (source is MixerSource mixer && mixer.ContainsLiveSource));
+ }
+ }
///
- /// Gets the metadata of the mixer track.
+ /// 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; }
+
+ ///
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;
+ ///
+ public double TotalDuration
+ {
+ get
+ {
+ IPcmSource[] currentSources = sources;
+ return currentSources.Length > 0 ? currentSources.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;
+ get
+ {
+ IPcmSource[] currentSources = sources;
+ return currentSources.Length > 0 ? currentSources.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));
+ ///
+ public bool Ended
+ {
+ get
+ {
+ IPcmSource[] currentSources = sources;
+ return !KeepAlive && (currentSources.Length == 0 || currentSources.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];
+ IPcmSource[] currentSources = sources;
+
+ if (currentSources.Length == 0)
+ {
+ Array.Clear(buffer, offset, count);
+ return KeepAlive ? count : 0;
+ }
Array.Clear(buffer, offset, count);
- int maxRead = 0;
- for (int i = sources.Count - 1; i >= 0; i--)
+ float[] temp = ArrayPool.Shared.Rent(count);
+
+ try
{
- IPcmSource src = sources[i];
+ int maxRead = 0;
+ bool needsCleanup = false;
- if (src.Ended)
+ foreach (IPcmSource src in currentSources)
{
- src.Dispose();
- sources.RemoveAt(i);
- continue;
+ if (src.Ended)
+ {
+ needsCleanup = true;
+ continue;
+ }
+
+ int read = src.Read(temp, 0, count);
+ if (read > maxRead)
+ maxRead = read;
+
+ for (int i = 0; i < read; i++)
+ buffer[offset + i] += temp[i];
}
- 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);
- for (int i = 0; i < maxRead; i++)
- buffer[offset + i] = Mathf.Clamp(buffer[offset + i], -1f, 1f);
+ if (needsCleanup)
+ CleanupEndedSources();
- return KeepAlive ? count : maxRead;
+ return KeepAlive ? count : maxRead;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(temp);
+ }
}
- ///
- /// 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)
+ IPcmSource[] currentSources = sources;
+ foreach (IPcmSource pcmSource in currentSources)
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)
+ IPcmSource[] currentSources = sources;
+ foreach (IPcmSource pcmSource in currentSources)
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();
+ lock (sourcesLock)
+ {
+ foreach (IPcmSource pcmSource in sources)
+ pcmSource?.Dispose();
- sources.Clear();
+ sources = Array.Empty();
+ }
}
///
@@ -145,8 +176,16 @@ public void Dispose()
/// The audio source to add.
public void AddSource(IPcmSource source)
{
- if (source != null)
- sources.Add(source);
+ if (source == null)
+ return;
+
+ lock (sourcesLock)
+ {
+ List newList = ListPool.Pool.Get(sources);
+ newList.Add(source);
+ sources = newList.ToArray();
+ ListPool.Pool.Return(newList);
+ }
}
///
@@ -159,10 +198,46 @@ public void RemoveSource(IPcmSource source, bool dispose = true)
if (source == null)
return;
- if (dispose)
- source.Dispose();
+ lock (sourcesLock)
+ {
+ List newList = ListPool.Pool.Get(sources);
+ if (newList.Remove(source))
+ {
+ sources = newList.ToArray();
- sources.Remove(source);
+ if (dispose)
+ source.Dispose();
+ }
+
+ ListPool.Pool.Return(newList);
+ }
+ }
+
+ private void CleanupEndedSources()
+ {
+ lock (sourcesLock)
+ {
+ List currentSources = ListPool.Pool.Get(sources);
+ bool changed = false;
+
+ for (int i = currentSources.Count - 1; i >= 0; i--)
+ {
+ IPcmSource source = currentSources[i];
+
+ if (!source.Ended)
+ continue;
+
+ source.Dispose();
+ currentSources.RemoveAt(i);
+
+ changed = true;
+ }
+
+ if (changed)
+ sources = currentSources.ToArray();
+
+ ListPool.Pool.Return(currentSources);
+ }
}
}
}
\ 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
index ae70e5a103..bad52adcbd 100644
--- a/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs
@@ -7,8 +7,7 @@
namespace Exiled.API.Features.Audio.PcmSources
{
- using System.Buffers;
- using System.Collections.Generic;
+ using System.Collections.Concurrent;
using Exiled.API.Features;
using Exiled.API.Interfaces.Audio;
@@ -26,9 +25,8 @@ public sealed class PlayerVoiceSource : IPcmSource, ILiveSource
{
private readonly Player sourcePlayer;
private readonly OpusDecoder decoder;
- private readonly Queue pcmQueue;
-
- private float[] decodeBuffer;
+ private readonly float[] decodeBuffer;
+ private readonly ConcurrentQueue pcmQueue;
///
/// Initializes a new instance of the class.
@@ -41,8 +39,8 @@ public PlayerVoiceSource(Player player, bool blockOriginalVoice = false)
BlockOriginalVoice = blockOriginalVoice;
decoder = new OpusDecoder();
- pcmQueue = new Queue();
- decodeBuffer = ArrayPool.Shared.Rent(VoiceChatSettings.PacketSizePerChannel);
+ pcmQueue = new ConcurrentQueue();
+ decodeBuffer = new float[VoiceChatSettings.PacketSizePerChannel];
TrackInfo = new TrackData
{
@@ -56,39 +54,25 @@ public PlayerVoiceSource(Player player, bool blockOriginalVoice = false)
///
/// 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;
+ public bool BlockOriginalVoice { get; set; }
- ///
- /// Gets the metadata of the streaming track.
- ///
+ ///
public TrackData TrackInfo { get; }
- ///
- /// Gets the total duration of the audio in seconds.
- ///
+ ///
public double TotalDuration => double.PositiveInfinity;
- ///
- /// Gets or sets the current playback position in seconds.
- ///
+ ///
public double CurrentTime
{
get => 0.0;
set => Seek(value);
}
- ///
- /// Gets a value indicating whether the end of the stream has been reached.
- ///
+ ///
public bool Ended => sourcePlayer?.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)
@@ -105,29 +89,16 @@ public int Read(float[] buffer, int offset, int count)
}
///
- public void Seek(double seconds)
- {
- Log.Info("[PlayerVoiceSource] Seeking is not supported for live player voice streams.");
- }
+ 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.");
- }
+ 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)
diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs
index ea1855a9ef..3277dedee8 100644
--- a/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs
@@ -8,6 +8,7 @@
namespace Exiled.API.Features.Audio.PcmSources
{
using System;
+ using System.Threading;
using System.Threading.Tasks;
using Exiled.API.Features.Audio;
@@ -21,36 +22,15 @@ namespace Exiled.API.Features.Audio.PcmSources
///
public sealed class PreloadedPcmSource : IPcmSource, IAsyncPcmSource
{
- private float[] data;
- private int pos;
+ private readonly object trackInfoLock = new();
- private volatile bool isReady = false;
- private volatile bool isFailed = false;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The path to the audio file.
- public PreloadedPcmSource(string path)
- {
- TrackInfo = new TrackData { Path = path, Duration = 0.0 };
+ private int pos;
+ private float[] data;
+ private CancellationTokenSource cts;
- Task.Run(() =>
- {
- try
- {
- AudioData result = WavUtility.WavToPcm(path);
- data = result.Pcm;
- TrackInfo = result.TrackInfo;
- isReady = true;
- }
- catch (Exception ex)
- {
- Log.Error($"[PreloadedPcmSource] Failed to load audio from path: {path} | Error: {ex.Message}");
- isFailed = true;
- }
- });
- }
+ private volatile bool isReady;
+ private volatile bool isFailed;
+ private volatile bool isDisposed;
///
/// Initializes a new instance of the class.
@@ -64,26 +44,45 @@ public PreloadedPcmSource(float[] pcmData)
}
///
- /// Gets the metadata of the loaded track.
+ /// Initializes a new instance of the class.
///
- public TrackData TrackInfo { get; private set; }
+ /// The path to the audio file.
+ public PreloadedPcmSource(string path)
+ {
+ TrackInfo = new TrackData { Path = path, Duration = 0.0 };
- ///
- /// Gets a value indicating whether the end of the PCM data buffer has been reached.
- ///
- public bool Ended => isFailed || (isReady && pos >= data.Length);
+ cts = new CancellationTokenSource();
+ CancellationToken token = cts.Token;
- ///
- /// Gets the total duration of the audio in seconds.
- ///
+ Task.Run(() => ReadAudio(path), token);
+ }
+
+ ///
+ public TrackData TrackInfo
+ {
+ get
+ {
+ lock (trackInfoLock)
+ return field;
+ }
+
+ private set
+ {
+ lock (trackInfoLock)
+ field = value;
+ }
+ }
+
+ ///
+ public bool Ended => isFailed || isDisposed || (isReady && data != null && pos >= data.Length);
+
+ ///
public double TotalDuration => isReady && data != null ? (double)data.Length / VoiceChatSettings.SampleRate : 0.0;
- ///
- /// Gets or sets the current playback position in seconds.
- ///
+ ///
public double CurrentTime
{
- get => isReady ? (double)pos / VoiceChatSettings.SampleRate : 0.0;
+ get => isReady && data != null ? (double)pos / VoiceChatSettings.SampleRate : 0.0;
set => Seek(value);
}
@@ -93,16 +92,10 @@ public double CurrentTime
///
public bool IsReady => isReady;
- ///
- /// Reads a sequence of PCM samples from the preloaded buffer into the specified array.
- ///
- /// The destination array to copy the samples into.
- /// The zero-based index in at which to begin storing the data.
- /// The maximum number of samples to read.
- /// The number of samples read into .
+ ///
public int Read(float[] buffer, int offset, int count)
{
- if (isFailed)
+ if (isFailed || isDisposed)
return 0;
if (!isReady || data == null)
@@ -118,30 +111,49 @@ public int Read(float[] buffer, int offset, int count)
return read;
}
- ///
- /// Seeks to the specified position in seconds.
- ///
- /// The target position in seconds.
+ ///
public void Seek(double seconds)
{
- if (!isReady || data == null)
+ if (!isReady || data == null || data.Length == 0)
return;
- long targetIndex = (long)(seconds * VoiceChatSettings.SampleRate);
- pos = (int)Math.Max(0, Math.Min(targetIndex, data.Length));
+ pos = (int)Math.Clamp(seconds * VoiceChatSettings.SampleRate, 0, data.Length);
}
- ///
- /// Resets the read position to the beginning of the PCM data buffer.
- ///
- public void Reset()
- {
- pos = 0;
- }
+ ///
+ public void Reset() => pos = 0;
///
public void Dispose()
{
+ isDisposed = true;
+ isReady = false;
+
+ CancellationTokenSource localCts = Interlocked.Exchange(ref cts, null);
+ if (localCts != null)
+ {
+ localCts.Cancel();
+ localCts.Dispose();
+ }
+ }
+
+ private void ReadAudio(string path)
+ {
+ try
+ {
+ AudioData result = WavUtility.WavToPcm(path);
+ data = result.Pcm;
+ TrackInfo = result.TrackInfo;
+ isReady = true;
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[PreloadedPcmSource] Failed to load audio from path: {path} | Error: {ex.Message}");
+ isFailed = true;
+ }
}
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs
index e6a26803d8..b071481c78 100644
--- a/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs
@@ -8,8 +8,10 @@
namespace Exiled.API.Features.Audio.PcmSources
{
using System;
+ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
+ using System.Threading;
using System.Threading.Tasks;
using Exiled.API.Features;
@@ -28,14 +30,15 @@ public sealed class VoiceRssTtsSource : IPcmSource, IAsyncPcmSource
private const string ApiEndpoint = "https://api.voicerss.org/";
private const string AudioFormat = "48khz_16bit_mono";
- private static readonly Dictionary BlacklistKeys = new();
+ private static readonly ConcurrentDictionary BlacklistKeys = new();
private IPcmSource internalSource;
- private UnityWebRequest webRequest;
+ private CancellationTokenSource cts;
private CoroutineHandle downloadRoutine;
- private volatile bool isReady = false;
- private volatile bool isFailed = false;
+ private volatile bool isReady;
+ private volatile bool isFailed;
+ private volatile bool isDisposed;
///
/// Initializes a new instance of the class.
@@ -74,33 +77,26 @@ public VoiceRssTtsSource(string text, IEnumerable apiKeys, string langua
throw new ArgumentException("API key collection cannot be null or empty.", nameof(apiKeys));
}
+ cts = new CancellationTokenSource();
TrackInfo = new TrackData { Path = $"VoiceRssTts: {text}", Duration = 0.0 };
downloadRoutine = Timing.RunCoroutine(DownloadRoutine(text, apiKeys, language, voice, rate));
}
- ///
- /// Gets the metadata of the loaded track.
- ///
+ ///
public TrackData TrackInfo { get; private set; }
- ///
- /// Gets the total duration of the audio in seconds. Returns 0 while the download is in progress.
- ///
+ ///
public double TotalDuration => isReady && internalSource != null ? internalSource.TotalDuration : 0.0;
- ///
- /// Gets or sets the current playback position in seconds.
- ///
+ ///
public double CurrentTime
{
get => isReady && internalSource != null ? internalSource.CurrentTime : 0.0;
set => Seek(value);
}
- ///
- /// Gets a value indicating whether playback has ended or the download has failed.
- ///
- public bool Ended => isFailed || (isReady && internalSource != null && internalSource.Ended);
+ ///
+ public bool Ended => isFailed || isDisposed || (isReady && internalSource != null && internalSource.Ended);
///
public bool IsFailed => isFailed;
@@ -108,16 +104,10 @@ public double CurrentTime
///
public bool IsReady => isReady;
- ///
- /// Reads PCM data from the audio source into the specified buffer.
- ///
- /// The buffer to fill with PCM data.
- /// The offset in the buffer at which to begin writing.
- /// The maximum number of samples to read.
- /// The number of samples read.
+ ///
public int Read(float[] buffer, int offset, int count)
{
- if (isFailed)
+ if (isFailed || isDisposed)
return 0;
if (!isReady || internalSource == null)
@@ -129,46 +119,50 @@ public int Read(float[] buffer, int offset, int 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);
+ if (isReady)
+ internalSource?.Seek(seconds);
}
- ///
- /// Resets playback to the beginning.
- ///
+ ///
public void Reset()
{
- if (isReady && internalSource != null)
- internalSource.Reset();
+ if (isReady)
+ internalSource?.Reset();
}
- ///
- /// Releases all resources used by the .
- ///
+ ///
public void Dispose()
{
+ isReady = false;
+ isDisposed = true;
+
if (downloadRoutine.IsRunning)
downloadRoutine.IsRunning = false;
- webRequest?.Abort();
- webRequest?.Dispose();
+ CancellationTokenSource localCts = Interlocked.Exchange(ref cts, null);
+ if (localCts != null)
+ {
+ localCts.Cancel();
+ localCts.Dispose();
+ }
+
internalSource?.Dispose();
+ internalSource = null;
}
private IEnumerator DownloadRoutine(string text, IEnumerable apiKeys, string language, string voice, int rate)
{
+ CancellationToken token = cts.Token;
string clampedRate = Math.Clamp(rate, -10, 10).ToString();
string textEscaped = Uri.EscapeDataString(text);
string langEscaped = Uri.EscapeDataString(language);
string voiceEscaped = string.IsNullOrEmpty(voice) ? string.Empty : $"&v={Uri.EscapeDataString(voice)}";
bool successfulDownload = false;
+ byte[] rawBytes = null;
foreach (string apiKey in apiKeys)
{
@@ -180,67 +174,72 @@ private IEnumerator DownloadRoutine(string text, IEnumerable apiK
if (DateTime.UtcNow.Day == exhaustedAt.Day)
continue;
- BlacklistKeys.Remove(apiKey);
+ BlacklistKeys.Remove(apiKey, out _);
}
string url = $"{ApiEndpoint}?key={Uri.EscapeDataString(apiKey)}&hl={langEscaped}&c=WAV&f={AudioFormat}&r={clampedRate}&src={textEscaped}{voiceEscaped}";
- webRequest?.Dispose();
+ UnityWebRequest request;
try
{
- webRequest = UnityWebRequest.Get(url);
+ request = UnityWebRequest.Get(url);
}
catch (Exception ex)
{
- Log.Error($"[VoiceRssTtsSource] Failed to get Url '{url}. Error: {ex.Message}");
+ Log.Error($"[VoiceRssTtsSource] Failed to create request for URL '{url}'. Error: {ex.Message}");
break;
}
- yield return Timing.WaitUntilDone(webRequest.SendWebRequest());
-
- if (webRequest.result != UnityWebRequest.Result.Success)
+ using (request)
{
- Log.Error($"[VoiceRssTtsSource] Network Error: {webRequest.error}.");
- break;
- }
+ yield return Timing.WaitUntilDone(request.SendWebRequest());
- string responseText = webRequest.downloadHandler.text;
- if (!string.IsNullOrEmpty(responseText) && responseText.StartsWith("ERROR: "))
- {
- string errorMessage = responseText[7..].Trim();
+ if (isDisposed || token.IsCancellationRequested)
+ yield break;
- if (errorMessage.Contains("limit") || errorMessage.Contains("expired") || errorMessage.Contains("inactive") || errorMessage.Contains("API key"))
+ if (request.result != UnityWebRequest.Result.Success)
{
- Log.Warn($"[VoiceRssTtsSource] Key issue, key: '{apiKey}', Error : {errorMessage}. Switching to another key...");
- BlacklistKeys[apiKey] = DateTime.UtcNow;
- continue;
+ Log.Error($"[VoiceRssTtsSource] Network error: {request.error}");
+ break;
}
- else
+
+ string responseText = request.downloadHandler.text;
+ if (!string.IsNullOrEmpty(responseText) && responseText.StartsWith("ERROR: "))
{
- Log.Error($"[VoiceRssTtsSource] API Error: {errorMessage}");
+ string errorMessage = responseText[7..].Trim();
+
+ if (errorMessage.Contains("limit") || errorMessage.Contains("expired") || errorMessage.Contains("inactive") || errorMessage.Contains("API key"))
+ {
+ Log.Warn($"[VoiceRssTtsSource] Key exhausted: '{apiKey}'. Error: {errorMessage}. Trying next key...");
+ BlacklistKeys[apiKey] = DateTime.UtcNow;
+ continue;
+ }
+
+ Log.Error($"[VoiceRssTtsSource] API error: {errorMessage}");
break;
}
- }
- successfulDownload = true;
- break;
+ rawBytes = request.downloadHandler.data;
+ successfulDownload = true;
+ break;
+ }
}
if (!successfulDownload)
{
isFailed = true;
- webRequest?.Dispose();
- webRequest = null;
yield break;
}
- byte[] rawBytes = webRequest.downloadHandler.data;
- webRequest.Dispose();
- webRequest = null;
+ if (isDisposed || token.IsCancellationRequested)
+ yield break;
- Task toPcmTask = Task.Run(() => WavUtility.WavToPcm(rawBytes));
+ Task toPcmTask = Task.Run(() => WavUtility.WavToPcm(rawBytes), token);
- yield return Timing.WaitUntilTrue(() => toPcmTask.IsCompleted);
+ yield return Timing.WaitUntilTrue(() => toPcmTask.IsCompleted || isDisposed || token.IsCancellationRequested);
+
+ if (isDisposed || token.IsCancellationRequested)
+ yield break;
if (toPcmTask.IsFaulted)
{
diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs
index 32a219a2b8..9ac1c09a9c 100644
--- a/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs
@@ -11,6 +11,7 @@ namespace Exiled.API.Features.Audio.PcmSources
using System.Buffers;
using System.IO;
using System.Runtime.InteropServices;
+ using System.Threading;
using Exiled.API.Features.Audio;
using Exiled.API.Interfaces.Audio;
@@ -30,6 +31,7 @@ public sealed class WavStreamSource : IPcmSource
private readonly FileStream stream;
private byte[] internalBuffer;
+ private volatile bool isDisposed;
///
/// Initializes a new instance of the class.
@@ -44,75 +46,75 @@ public WavStreamSource(string path)
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.
- ///
+ ///
public double TotalDuration => (endPosition - startPosition) / 2.0 / VoiceChatSettings.SampleRate;
- ///
- /// Gets or sets the current playback position in seconds.
- ///
+ ///
public double CurrentTime
{
- get => (stream.Position - startPosition) / 2.0 / VoiceChatSettings.SampleRate;
+ get => isDisposed ? 0.0 : (stream.Position - startPosition) / 2.0 / VoiceChatSettings.SampleRate;
set => Seek(value);
}
- ///
- /// Gets a value indicating whether the end of the stream has been reached.
- ///
- public bool Ended => stream.Position >= endPosition;
+ ///
+ public bool Ended => isDisposed || stream.Position >= endPosition;
- ///
- /// Reads PCM data from the stream into the specified buffer.
- ///
- /// The buffer to fill with PCM data.
- /// The offset in the buffer at which to begin writing.
- /// The maximum number of samples to read.
- /// The number of samples read.
+ ///
public int Read(float[] buffer, int offset, int count)
{
- count = Math.Min(count, buffer.Length - offset);
-
- if (count <= 0)
+ if (isDisposed)
+ {
+ Array.Clear(buffer, offset, count);
return 0;
+ }
- int bytesNeeded = count * 2;
-
- if (internalBuffer.Length < bytesNeeded)
+ try
{
- ArrayPool.Shared.Return(internalBuffer);
- internalBuffer = ArrayPool.Shared.Rent(bytesNeeded);
- }
+ count = Math.Min(count, buffer.Length - offset);
- int bytesRead = stream.Read(internalBuffer, 0, bytesNeeded);
+ if (count <= 0)
+ return 0;
- if (bytesRead == 0)
- return 0;
+ int bytesNeeded = count * 2;
+
+ if (internalBuffer.Length < bytesNeeded)
+ {
+ ArrayPool.Shared.Return(internalBuffer);
+ internalBuffer = ArrayPool.Shared.Rent(bytesNeeded);
+ }
+
+ int bytesRead = stream.Read(internalBuffer, 0, bytesNeeded);
- if (bytesRead % 2 != 0)
- bytesRead--;
+ if (bytesRead == 0)
+ return 0;
- Span byteSpan = internalBuffer.AsSpan(0, bytesRead);
- Span shortSpan = MemoryMarshal.Cast(byteSpan);
+ if (bytesRead % 2 != 0)
+ bytesRead--;
- for (int i = 0; i < shortSpan.Length; i++)
- buffer[offset + i] = shortSpan[i] * Divide;
+ Span byteSpan = internalBuffer.AsSpan(0, bytesRead);
+ Span shortSpan = MemoryMarshal.Cast(byteSpan);
- return shortSpan.Length;
+ for (int i = 0; i < shortSpan.Length; i++)
+ buffer[offset + i] = shortSpan[i] * Divide;
+
+ return shortSpan.Length;
+ }
+ catch (ObjectDisposedException)
+ {
+ Array.Clear(buffer, offset, count);
+ return 0;
+ }
}
- ///
- /// Seeks to the specified position in the stream.
- ///
- /// The position in seconds to seek to.
+ ///
public void Seek(double seconds)
{
+ if (isDisposed)
+ return;
+
long newPos = Math.Clamp(startPosition + ((long)(seconds * VoiceChatSettings.SampleRate) * 2), startPosition, endPosition);
if (newPos % 2 != 0)
@@ -121,25 +123,18 @@ public void Seek(double seconds)
stream.Position = newPos;
}
- ///
- /// Resets the stream position to the start.
- ///
- public void Reset()
- {
- stream.Position = startPosition;
- }
+ ///
+ public void Reset() => stream.Position = startPosition;
- ///
- /// Releases all resources used by the .
- ///
+ ///
public void Dispose()
{
+ isDisposed = true;
stream?.Dispose();
- if (internalBuffer != null)
- {
- ArrayPool.Shared.Return(internalBuffer);
- internalBuffer = null;
- }
+
+ byte[] buf = Interlocked.Exchange(ref internalBuffer, null);
+ if (buf != null)
+ ArrayPool.Shared.Return(buf);
}
}
}
\ No newline at end of file
diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/WebWavPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/WebWavPcmSource.cs
index 64f6fff699..3376e0d25b 100644
--- a/EXILED/Exiled.API/Features/Audio/PcmSources/WebWavPcmSource.cs
+++ b/EXILED/Exiled.API/Features/Audio/PcmSources/WebWavPcmSource.cs
@@ -9,6 +9,7 @@ namespace Exiled.API.Features.Audio.PcmSources
{
using System;
using System.Collections.Generic;
+ using System.Threading;
using System.Threading.Tasks;
using Exiled.API.Features;
@@ -25,11 +26,12 @@ namespace Exiled.API.Features.Audio.PcmSources
public sealed class WebWavPcmSource : IPcmSource, IAsyncPcmSource
{
private IPcmSource internalSource;
- private UnityWebRequest webRequest;
+ private CancellationTokenSource cts;
private CoroutineHandle downloadRoutine;
- private volatile bool isReady = false;
- private volatile bool isFailed = false;
+ private volatile bool isReady;
+ private volatile bool isFailed;
+ private volatile bool isDisposed;
///
/// Initializes a new instance of the class.
@@ -38,32 +40,25 @@ public sealed class WebWavPcmSource : IPcmSource, IAsyncPcmSource
public WebWavPcmSource(string url)
{
TrackInfo = default;
+ cts = new CancellationTokenSource();
downloadRoutine = Timing.RunCoroutine(Download(url));
}
- ///
- /// Gets the metadata of the preloaded track.
- ///
+ ///
public TrackData TrackInfo { get; private set; }
- ///
- /// Gets the total duration of the audio in seconds.
- ///
+ ///
public double TotalDuration => isReady && internalSource != null ? internalSource.TotalDuration : 0.0;
- ///
- /// Gets or sets the current playback position in seconds.
- ///
+ ///
public double CurrentTime
{
get => isReady && internalSource != null ? internalSource.CurrentTime : 0.0;
set => Seek(value);
}
- ///
- /// Gets a value indicating whether the end of the playback has been reached.
- ///
- public bool Ended => isFailed || (isReady && internalSource != null && internalSource.Ended);
+ ///
+ public bool Ended => isFailed || isDisposed || (isReady && internalSource != null && internalSource.Ended);
///
public bool IsFailed => isFailed;
@@ -71,16 +66,10 @@ public double CurrentTime
///
public bool IsReady => isReady;
- ///
- /// Reads PCM data from the audio source into the specified buffer.
- ///
- /// The buffer to fill with PCM data.
- /// The offset in the buffer at which to begin writing.
- /// The maximum number of samples to read.
- /// The number of samples read.
+ ///
public int Read(float[] buffer, int offset, int count)
{
- if (isFailed)
+ if (isFailed || isDisposed)
return 0;
if (!isReady || internalSource == null)
@@ -92,75 +81,87 @@ public int Read(float[] buffer, int offset, int 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;
+ if (isReady)
+ internalSource?.Seek(seconds);
}
- ///
- /// Resets the playback position to the start.
- ///
+ ///
public void Reset()
{
- if (isReady && internalSource != null)
- internalSource.Reset();
+ if (isReady)
+ internalSource?.Reset();
}
- ///
- /// Releases all resources used by the .
- ///
+ ///
public void Dispose()
{
+ isDisposed = true;
+ isReady = false;
+
if (downloadRoutine.IsRunning)
downloadRoutine.IsRunning = false;
- webRequest?.Abort();
- webRequest?.Dispose();
+ CancellationTokenSource localCts = Interlocked.Exchange(ref cts, null);
+ if (localCts != null)
+ {
+ localCts.Cancel();
+ localCts.Dispose();
+ }
+
internalSource?.Dispose();
+ internalSource = null;
}
private IEnumerator Download(string url)
{
+ CancellationToken token = cts.Token;
+ byte[] rawBytes = null;
+
+ UnityWebRequest request;
try
{
- webRequest = UnityWebRequest.Get(url);
+ request = UnityWebRequest.Get(url);
}
catch (Exception ex)
{
- Log.Error($"[WebWavPcmSource] Failed to download audio! URL: {url} | Error: {ex.Message}");
+ Log.Error($"[WebWavPcmSource] Failed to create request. URL: {url} | Error: {ex.Message}");
isFailed = true;
- webRequest?.Dispose();
- webRequest = null;
yield break;
}
- yield return Timing.WaitUntilDone(webRequest.SendWebRequest());
-
- if (webRequest.result != UnityWebRequest.Result.Success)
+ using (request)
{
- Log.Error($"[WebWavPcmSource] Failed to download audio! URL: {url} | Error: {webRequest.error}");
- isFailed = true;
- webRequest?.Dispose();
- webRequest = null;
- yield break;
+ yield return Timing.WaitUntilDone(request.SendWebRequest());
+
+ if (isDisposed || token.IsCancellationRequested)
+ yield break;
+
+ if (request.result != UnityWebRequest.Result.Success)
+ {
+ Log.Error($"[WebWavPcmSource] Failed to download! URL: {url} | Error: {request.error}");
+ isFailed = true;
+ yield break;
+ }
+
+ rawBytes = request.downloadHandler.data;
}
- byte[] rawBytes = webRequest.downloadHandler.data;
- webRequest.Dispose();
- webRequest = null;
+ if (isDisposed || token.IsCancellationRequested)
+ yield break;
+
+ Task toPcmTask = Task.Run(() => WavUtility.WavToPcm(rawBytes), token);
- Task toPcmTask = Task.Run(() => WavUtility.WavToPcm(rawBytes));
+ yield return Timing.WaitUntilTrue(() => toPcmTask.IsCompleted || token.IsCancellationRequested);
- yield return Timing.WaitUntilTrue(() => toPcmTask.IsCompleted);
+ if (isDisposed || token.IsCancellationRequested)
+ yield break;
if (toPcmTask.IsFaulted)
{
- Log.Error($"[WebPreloadWavPcmSource] Failed to read the downloaded file! Ensure the link points to a valid .WAV file. Error: {toPcmTask.Exception?.InnerException?.Message ?? toPcmTask.Exception?.Message}");
+ Log.Error($"[WebWavPcmSource] Failed to parse WAV. Ensure URL points to a valid 16-bit mono 48kHz WAV.\nError: {toPcmTask.Exception?.InnerException?.Message ?? toPcmTask.Exception?.Message}");
isFailed = true;
yield break;
}
@@ -176,7 +177,7 @@ private IEnumerator Download(string url)
}
catch (Exception ex)
{
- Log.Error($"[WebPreloadWavPcmSource] Failed to read the downloaded file! Ensure the link points to a valid .WAV file. Error: {ex.InnerException?.Message ?? ex.Message}");
+ Log.Error($"[WebWavPcmSource] Failed to create internal source.\nError: {ex.InnerException?.Message ?? ex.Message}");
isFailed = true;
}
}
diff --git a/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs
index 6ca303970e..85a614ce91 100644
--- a/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs
+++ b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs
@@ -15,8 +15,6 @@ namespace Exiled.API.Features.Audio
using Exiled.API.Features.Toys;
using Exiled.API.Interfaces.Audio;
- using Mirror;
-
///
/// Represents all configurable audio and network settings for play from pool method.
///
@@ -68,7 +66,7 @@ public PlaybackSettings()
///
/// Gets or sets the Mirror network channel used for sending audio packets.
///
- public int Channel { get; set; } = Channels.ReliableOrdered2;
+ public int Channel { get; set; } = Speaker.DefaultChannel;
///
/// Gets or sets the play mode determining how the audio is sent to players.
diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
index 7e8df6dbea..0fbe9a7bf9 100644
--- a/EXILED/Exiled.API/Features/Audio/WavUtility.cs
+++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs
@@ -10,6 +10,7 @@ namespace Exiled.API.Features.Audio
using System;
using System.Buffers;
using System.Buffers.Binary;
+ using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
@@ -17,6 +18,10 @@ namespace Exiled.API.Features.Audio
using Exiled.API.Interfaces.Audio;
using Exiled.API.Structs.Audio;
+ using MEC;
+
+ using UnityEngine.Networking;
+
using VoiceChat;
///
@@ -36,7 +41,12 @@ public static class WavUtility
public static IPcmSource CreatePcmSource(string path, bool stream = false, bool cache = false)
{
if (cache)
- return new CachedPcmSource(path, path);
+ {
+ if (!AudioDataStorage.AudioStorage.ContainsKey(path))
+ CacheWav(path, path);
+
+ return new CachedPcmSource(path);
+ }
if (path.StartsWith("http"))
return new WebWavPcmSource(path);
@@ -47,6 +57,99 @@ public static IPcmSource CreatePcmSource(string path, bool stream = false, bool
return new PreloadedPcmSource(path);
}
+ ///
+ /// 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 CacheWav(string name, string path)
+ {
+ if (!AudioDataStorage.ValidateName(name))
+ return false;
+
+ if (AudioDataStorage.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 = WavToPcm(path);
+ return AudioDataStorage.AudioStorage.TryAdd(name, parsed);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[AudioDataStorage] Failed to load '{path}' into storage:\n{ex}");
+ return false;
+ }
+ }
+
+ ///
+ /// 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 CacheWavUrl(string name, string url) => Timing.RunCoroutine(CacheUrlCoroutine(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 CacheUrlCoroutine(string name, string url)
+ {
+ if (!AudioDataStorage.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 (AudioDataStorage.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 = WavToPcm(www.downloadHandler.data);
+ parsed.TrackInfo.Path = url;
+ AudioDataStorage.AudioStorage.TryAdd(name, parsed);
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[AudioDataStorage] Failed to parse downloaded WAV from '{url}':\n{ex}");
+ }
+ }
+
///
/// Converts a WAV file at the specified path to a PCM float array.
///
@@ -95,7 +198,6 @@ public static AudioData WavToPcm(string path)
public static AudioData WavToPcm(byte[] data)
{
using MemoryStream ms = new(data, 0, data.Length);
-
return ParseWavSpanToPcm(ms, data.AsSpan());
}
diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs
index 5edb538af2..4729624c37 100644
--- a/EXILED/Exiled.API/Features/Toys/Speaker.cs
+++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs
@@ -4,14 +4,13 @@
// Licensed under the CC BY-SA 3.0 license.
//
// -----------------------------------------------------------------------
-
-#pragma warning disable SA1129 // Do not use default value type constructor
+#pragma warning disable SA1124 // DoNotUseRegions
namespace Exiled.API.Features.Toys
{
using System;
+ using System.Collections.Concurrent;
using System.Collections.Generic;
- using System.Linq;
- using System.Threading.Tasks;
+ using System.Threading;
using AdminToys;
@@ -73,31 +72,37 @@ public class Speaker : AdminToy, IWrapper
///
public const bool DefaultSpatial = true;
+ ///
+ /// Default channel used when sending data if no channel is specified.
+ ///
+ public const int DefaultChannel = Channels.Unreliable;
+
+ private const int PacketQueueCapacity = 8;
+ private const int ResampleBufferPadding = 10;
+ private const float PitchTolerance = 0.0001f;
private const int FrameSize = VoiceChatSettings.PacketSizePerChannel;
- private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate;
+ private const double FrameTime = (double)FrameSize / VoiceChatSettings.SampleRate;
private static readonly Queue Pool;
-
private static readonly Vector3 SpeakerParkPosition = Vector3.down * 999;
- private OpusEncoder encoder;
-
- private float[] frame;
- private byte[] encoded;
- private float[] resampleBuffer;
- private Func processNextFrame;
+ private readonly object opusLock = new();
- private CoroutineHandle playBackRoutine;
+ private OpusEncoder encoder;
private CoroutineHandle fadeRoutine;
+ private CoroutineHandle playBackRoutine;
+ private CancellationTokenSource processCts;
- private double resampleTime;
- private int resampleBufferFilled;
- private int nextScheduledEventIndex = 0;
- private int idChangeFrame;
+ private volatile IPcmSource currentSource;
+ private volatile IAudioFilter activeFilter;
+ private volatile BlockingCollection<(byte[] Data, int Length)> packetQueue;
- private bool isPlayBackInitialized = false;
+ private int nextScheduledEventIndex;
+ private int idChangeFrame;
+ private bool needsSyncWait;
+ private double nextSendTime;
private bool isPitchDefault = true;
- private bool needsSyncWait = false;
+ private bool isPlayBackInitialized;
static Speaker()
{
@@ -162,7 +167,7 @@ internal Speaker(SpeakerToy speakerToy)
///
/// Gets or sets the network channel used for sending audio packets from this speaker .
///
- public int Channel { get; set; } = Channels.Unreliable;
+ public int Channel { get; set; } = DefaultChannel;
///
/// Gets or sets a value indicating whether the audio playback should loop when it reaches the end.
@@ -179,6 +184,11 @@ internal Speaker(SpeakerToy speakerToy)
///
public bool ReturnToPoolAfter { get; set; }
+ ///
+ /// Gets a value indicating whether this speaker is currently pooled in the speaker pool.
+ ///
+ public bool IsPooled { get; private set; }
+
///
/// Gets or sets the play mode for this speaker, determining how audio is sent to players.
///
@@ -222,14 +232,23 @@ public bool IsPaused
if (playBackRoutine.IsAliveAndPaused == value)
return;
+ if (value && currentSource is ILiveSource)
+ {
+ Log.Warn("[Speaker] Cannot pause or resume playback of a live source.");
+ return;
+ }
+
playBackRoutine.IsAliveAndPaused = value;
if (value)
{
+ StopProccesThread();
OnPlaybackPaused?.Invoke();
SpeakerEvents.OnPlaybackPaused(this);
}
else
{
+ nextSendTime = Time.unscaledTimeAsDouble;
+ StartProccesThread();
OnPlaybackResumed?.Invoke();
SpeakerEvents.OnPlaybackResumed(this);
}
@@ -242,19 +261,21 @@ public bool IsPaused
///
public double CurrentTime
{
- get => CurrentSource?.CurrentTime ?? 0.0;
+ get => currentSource?.CurrentTime ?? 0.0;
set
{
- if (CurrentSource == null)
+ if (currentSource == null || value < 0.0)
return;
- CurrentSource.CurrentTime = value;
- resampleTime = 0.0;
- resampleBufferFilled = 0;
+ StopProccesThread();
+
+ currentSource.CurrentTime = value;
- ResetEncoder();
- Filter?.Reset();
+ activeFilter?.Reset();
UpdateNextScheduledEventIndex();
+
+ if (playBackRoutine.IsRunning)
+ StartProccesThread();
}
}
@@ -262,7 +283,7 @@ public double CurrentTime
/// Gets the total duration of the current track in seconds.
/// Returns 0 if not playing.
///
- public double TotalDuration => CurrentSource?.TotalDuration ?? 0.0;
+ public double TotalDuration => currentSource?.TotalDuration ?? 0.0;
///
/// Gets the remaining playback time in seconds.
@@ -284,10 +305,14 @@ public float PlaybackProgress
}
///
- /// Gets the currently playing audio source.
- /// Pre-made filters are available in the namespace.
+ /// Gets or sets the currently playing audio source.
+ /// Pre-made sources are available in the namespace.
///
- public IPcmSource CurrentSource { get; private set; }
+ public IPcmSource CurrentSource
+ {
+ get => currentSource;
+ set => currentSource = value;
+ }
///
/// Gets the metadata information (Title, Artist, Duration) of the last played audio track.
@@ -296,8 +321,13 @@ public float PlaybackProgress
///
/// Gets or sets the custom audio filter applied to the PCM data right before encoding.
+ /// Pre-made filters are available in the namespace.
///
- public IAudioFilter Filter { get; set; }
+ public IAudioFilter Filter
+ {
+ get => activeFilter;
+ set => activeFilter = value;
+ }
///
/// Gets the queue of audio tracks to be played sequentially.
@@ -324,22 +354,17 @@ public float Pitch
if (field == value)
return;
- if (Mathf.Abs(value - 1f) > 0.0001f && CurrentSource is ILiveSource)
- {
- field = 1f;
- isPitchDefault = true;
- resampleTime = 0.0;
- resampleBufferFilled = 0;
- Log.Warn("[Speaker] Pitch adjustment is not supported for live sources. Pitch has been reset to default (1.0).");
- return;
- }
-
field = Mathf.Max(0.1f, Mathf.Abs(value));
- isPitchDefault = Mathf.Abs(field - 1.0f) < 0.0001f;
+ isPitchDefault = Mathf.Abs(field - 1f) < PitchTolerance;
+
if (isPitchDefault)
+ return;
+
+ if (currentSource != null && (currentSource is ILiveSource || (currentSource is MixerSource mixer && mixer.ContainsLiveSource)))
{
- resampleTime = 0.0;
- resampleBufferFilled = 0;
+ field = 1f;
+ isPitchDefault = true;
+ Log.Warn("[Speaker] Pitch adjustment is not supported for live sources. Pitch has been reset to default value (1).");
}
}
}
@@ -358,7 +383,9 @@ public float Volume
get => Base.NetworkVolume;
set
{
- StopFade();
+ if (isPlayBackInitialized)
+ StopFade();
+
Base.NetworkVolume = value;
}
}
@@ -419,6 +446,43 @@ public byte ControllerId
}
}
+ ///
+ /// 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;
+ }
+
///
/// Creates a new .
///
@@ -463,7 +527,7 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null)
{
speaker = Pool.Dequeue();
- if (speaker != null && speaker.Base != null)
+ if (speaker?.Base != null)
break;
speaker = null;
@@ -475,10 +539,11 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null)
}
else
{
+ speaker.IsPooled = false;
speaker.IsStatic = false;
if (parent != null)
- speaker.Transform.parent = parent;
+ speaker.Transform.SetParent(parent);
speaker.LocalPosition = position ?? Vector3.zero;
speaker.ControllerId = GetNextFreeControllerId(speaker.ControllerId);
@@ -488,43 +553,6 @@ public static Speaker Rent(Transform parent = null, Vector3? position = null)
return 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.)
- ///
- /// The path/url or custom name/key (if has set to true) to the wav file.
- /// The parent transform, if any.
- /// The local position of the speaker.
- /// The optional audio and network settings. If null, default settings are used.
- /// true if the audio file was successfully found, loaded, and playback started; otherwise, false.
- public static bool PlayWavFromPool(string path, Transform parent = null, Vector3? position = null, in PlaybackSettings? settings = null)
- {
- if (string.IsNullOrEmpty(path))
- {
- Log.Error("[Speaker] Provided path/url or name cannot be null or empty!");
- return false;
- }
-
- PlaybackSettings settingsFull = settings ?? new PlaybackSettings();
- if (!settingsFull.UseCache && !WavUtility.TryValidatePath(path, out string errorMessage))
- {
- Log.Error($"[Speaker] {errorMessage}");
- return false;
- }
-
- IPcmSource source;
- try
- {
- source = WavUtility.CreatePcmSource(path, settingsFull.Stream, settingsFull.UseCache);
- }
- catch (Exception ex)
- {
- Log.Error($"[Speaker] Failed to initialize audio source for PlayFromPool. Path: '{path}'.\n{ex}");
- return false;
- }
-
- return PlayFromPool(source, parent, position, settingsFull);
- }
-
///
/// Rents a speaker from the pool, plays a custom PCM source one time, and automatically returns it to the pool afterwards.
///
@@ -556,7 +584,7 @@ public static bool PlayFromPool(IPcmSource source, Transform parent = null, Vect
speaker.Predicate = settingsFull.Predicate;
speaker.TargetPlayer = settingsFull.TargetPlayer;
speaker.TargetPlayers = settingsFull.TargetPlayers;
- speaker.Filter = settingsFull.Filter;
+ speaker.activeFilter = settingsFull.Filter;
speaker.ReturnToPoolAfter = true;
@@ -569,52 +597,17 @@ public static bool PlayFromPool(IPcmSource source, Transform parent = null, Vect
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;
- }
+ #region Format Supported PlayFromPool Overloads
///
- /// Plays a local wav file or web URL through this speaker. (File must be 16-bit, mono, and 48kHz.)
+ /// Rents a speaker from the pool, plays a local wav file or web stream one time, and automatically returns it to the pool afterwards. (File must be 16 bit, mono and 48khz.)
///
- /// The path/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.
+ /// The path/url or custom name/key (if has set to true) to the wav file.
+ /// The parent transform, if any.
+ /// The local position of the speaker.
+ /// The optional audio and network settings. If null, default settings are used.
/// true if the audio file was successfully found, loaded, and playback started; otherwise, false.
- public bool PlayWav(string path, bool clearQueue = true, bool stream = false, bool useCache = false)
+ public static bool PlayWavFromPool(string path, Transform parent = null, Vector3? position = null, in PlaybackSettings? settings = null)
{
if (string.IsNullOrEmpty(path))
{
@@ -622,104 +615,28 @@ public bool PlayWav(string path, bool clearQueue = true, bool stream = false, bo
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);
- }
-
- ///
- /// Converts provided paths/URLs to sources and plays them mixed together.
- ///
- /// The collection of paths or URLs to the audio files.
- /// If true, clears the upcoming tracks in the playlist before starting playback.
- /// If true, streams local files from disk. (Ignored for web URLs).
- /// If true, utilizes for the sources.
- /// true if at least one valid path was loaded and started; otherwise, false.
- public bool PlayMixedWav(IEnumerable paths, bool clearQueue = true, bool stream = false, bool useCache = false)
- {
- if (paths == null || !paths.Any())
- {
- Log.Error("[Speaker] No paths provided for PlayMixedWav!");
- return false;
- }
-
- List createdSources = new();
-
- foreach (string path in paths)
- {
- if (string.IsNullOrEmpty(path))
- {
- Log.Warn("[Speaker] One of the provided paths for PlayMixedWav is null or empty. Skipping this entry.");
- continue;
- }
-
- if (!WavUtility.TryValidatePath(path, out string error))
- {
- Log.Error($"[Speaker] Skipping invalid path in mix: {path}. Reason: {error}");
- continue;
- }
-
- try
- {
- IPcmSource source = WavUtility.CreatePcmSource(path, stream, useCache);
- if (source != null)
- createdSources.Add(source);
- }
- catch (Exception ex)
- {
- Log.Error($"[Speaker] Failed to create source for mix from '{path}': {ex.Message}");
- }
- }
-
- if (createdSources.Count == 0)
- return false;
-
- return PlayMixed(createdSources, clearQueue);
- }
+ PlaybackSettings settingsFull = settings ?? new PlaybackSettings();
- ///
- /// Plays the live voice of a specific player through this speaker.
- ///
- /// The player whose voice will be broadcasted.
- /// If true, prevents the player's original voice message's from being heard while broadcasting.
- /// If true, clears the upcoming tracks in the playlist before starting playback.
- /// true if the playback started successfully; otherwise, false.
- public bool PlayFromPlayer(Player player, bool blockOriginalVoice = false, bool clearQueue = true)
- {
- if (player == null)
+ if (!settingsFull.UseCache && !WavUtility.TryValidatePath(path, out string errorMessage))
{
- Log.Error("[Speaker] Source player cannot be null when streaming live microphone!");
+ Log.Error($"[Speaker] {errorMessage}");
return false;
}
- PlayerVoiceSource source;
+ IPcmSource source;
try
{
- source = new PlayerVoiceSource(player, blockOriginalVoice);
+ source = WavUtility.CreatePcmSource(path, settingsFull.Stream, settingsFull.UseCache);
}
catch (Exception ex)
{
- Log.Error($"[Speaker] Failed to initialize live voice stream for player '{player.Nickname}' ({player.Id}).\nException Details: {ex}");
+ Log.Error($"[Speaker] Failed to initialize audio source for PlayFromPool. Path: '{path}'.\n{ex}");
return false;
}
- return Play(source, clearQueue);
+ return PlayFromPool(source, parent, position, settingsFull);
}
+ #endregion
///
/// Plays audio directly from a provided PCM source.
@@ -729,6 +646,12 @@ public bool PlayFromPlayer(Player player, bool blockOriginalVoice = false, bool
/// true if the source is valid and playback started; otherwise, false.
public bool Play(IPcmSource customSource, bool clearQueue = true)
{
+ if (IsPooled)
+ {
+ Log.Warn("[Speaker] Cannot play audio on a speaker that is currently in the pool!");
+ return false;
+ }
+
if (customSource == null)
{
Log.Error("[Speaker] Provided custom IPcmSource is null!");
@@ -738,86 +661,16 @@ public bool Play(IPcmSource customSource, bool clearQueue = true)
TryInitializePlayBack();
Stop(clearQueue);
- CurrentSource = customSource;
- LastTrackInfo = CurrentSource.TrackInfo;
+ currentSource = customSource;
+ LastTrackInfo = currentSource.TrackInfo;
- if (CurrentSource is ILiveSource)
+ if (currentSource is ILiveSource)
Pitch = 1.0f;
playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject));
return true;
}
- ///
- /// Plays multiple instances mixed together.
- ///
- /// The collection of PCM sources to mix and play.
- /// If true, clears the upcoming tracks in the playlist before starting playback.
- /// true if at least one source was successfully mixed; otherwise, false.
- public bool PlayMixed(IEnumerable sources, bool clearQueue = true)
- {
- if (sources == null || !sources.Any())
- {
- Log.Error("[Speaker] No sources provided for PlayMixed!");
- return false;
- }
-
- if (clearQueue)
- TrackQueue.Clear();
-
- bool anyAdded = false;
-
- foreach (IPcmSource source in sources)
- {
- if (source == null)
- continue;
-
- if (AddMixed(source))
- anyAdded = true;
- }
-
- return anyAdded;
- }
-
- ///
- /// 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.
///
@@ -838,13 +691,13 @@ public void Stop(bool clearQueue = true)
if (clearQueue)
TrackQueue.Clear();
- StopFade();
ResetEncoder();
+ StopProccesThread();
ClearScheduledEvents();
- Filter?.Reset();
- CurrentSource?.Dispose();
- CurrentSource = null;
+ activeFilter?.Reset();
+ currentSource?.Dispose();
+ currentSource = null;
}
///
@@ -858,7 +711,13 @@ public void Stop(bool clearQueue = true)
/// An optional action to invoke when the fade process is fully finished.
public void FadeVolume(float startVolume, float targetVolume, float duration = 3, bool linear = false, Action onComplete = null)
{
- if (fadeRoutine.IsRunning)
+ if (IsPooled)
+ {
+ Log.Warn("[Speaker] Cannot fade volume on a speaker that is currently in the pool!");
+ return;
+ }
+
+ if (fadeRoutine.IsRunning)
fadeRoutine.IsRunning = false;
fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, linear, onComplete).CancelWith(GameObject));
@@ -881,45 +740,57 @@ public void RestartTrack()
if (!playBackRoutine.IsRunning)
return;
- CurrentTime = 0.0;
+ nextScheduledEventIndex = 0;
+
+ ResetEncoder();
+ activeFilter?.Reset();
+ currentSource?.Reset();
}
///
- /// Helper method to easily queue a .wav file/url with stream support.
+ /// Adds a track to the playback queue. If nothing is playing, playback starts immediately.
///
- /// An optional name or identifier for this track in the queue. This is only used for reference.
- /// The path/url or custom name(if is true) to the wav file.
- /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs).
- /// If true, loads the audio via for optimized playback.
+ /// The queued track containing its creation logic and optional identifier.
/// true if successfully queued or started.
- public bool QueueWavTrack(string name, string path, bool isStream = false, bool useCache = false)
+ public bool QueueTrack(QueuedTrack track)
{
- if (string.IsNullOrEmpty(path))
+ if (IsPooled)
{
- Log.Error("[Speaker] Provided path or cache name cannot be null or empty!");
+ Log.Warn("[Speaker] Cannot queue tracks on a speaker that is currently in the pool!");
return false;
}
- if (!useCache && !WavUtility.TryValidatePath(path, out string errorMessage))
- {
- Log.Error($"[Speaker] {errorMessage}");
- return false;
- }
+ if (!playBackRoutine.IsRunning && !IsPaused)
+ return Play(track.SourceProvider.Invoke());
- return QueueTrack(new QueuedTrack(name, () => WavUtility.CreatePcmSource(path, isStream, useCache)));
+ TrackQueue.Add(track);
+ return true;
}
///
- /// Adds a track to the playback queue. If nothing is playing, playback starts immediately.
+ /// Removes a specific track from the playback queue by its file path.
///
- /// The queued track containing its creation logic and optional identifier.
- /// true if successfully queued or started.
- public bool QueueTrack(QueuedTrack track)
+ /// The exact file path of the track to remove.
+ /// If true, removes the first occurrence; if false, removes the last occurrence.
+ /// If true, removes all occurrences; if false, removes only the first or last occurrence based on .
+ /// true if the track was successfully found and removed; otherwise, false.
+ public bool RemoveTrack(string path, bool findFirst = true, bool removeAll = false)
{
- if (!playBackRoutine.IsRunning && !IsPaused)
- return Play(track.SourceProvider.Invoke());
+ if (removeAll)
+ {
+ int removed = TrackQueue.RemoveAll(t => t.Name == path);
+ if (removed > 0)
+ return true;
- TrackQueue.Add(track);
+ return false;
+ }
+
+ 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;
}
@@ -955,23 +826,6 @@ public void SkipTrack()
}
}
- ///
- /// Removes a specific track from the playback queue by its file path.
- ///
- /// The exact file path of the track to remove.
- /// If true, removes the first occurrence; if false, removes the last occurrence.
- /// true if the track was successfully found and removed; otherwise, false.
- public bool RemoveTrack(string path, bool findFirst = true)
- {
- int index = findFirst ? TrackQueue.FindIndex(t => t.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.
///
@@ -997,6 +851,12 @@ public void ShuffleTracks()
/// 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)
{
+ if (IsPooled)
+ {
+ Log.Warn("[Speaker] Cannot add scheduled events on a speaker that is currently in the pool!");
+ return null;
+ }
+
ScheduledEvent timeEvent = new(timeInSeconds, action, id);
ScheduledEvents.Add(timeEvent);
@@ -1036,17 +896,15 @@ public void ClearScheduledEvents()
///
public void ReturnToPool()
{
- if (Base == null)
+ if (Base == null || IsPooled)
return;
Stop();
+ StopFade();
+ ClearEvents();
+ Transform.SetParent(null);
- if (Transform.parent != null || AdminToyBase._clientParentId != 0)
- {
- Transform.parent = null;
- Base.RpcChangeParent(0);
- }
-
+ IsPooled = true;
LocalPosition = SpeakerParkPosition;
Volume = DefaultVolume;
@@ -1059,7 +917,7 @@ public void ReturnToPool()
DestroyAfter = false;
ReturnToPoolAfter = false;
PlayMode = SpeakerPlayMode.Global;
- Channel = Channels.Unreliable;
+ Channel = DefaultChannel;
LastTrackInfo = default;
@@ -1068,20 +926,10 @@ public void ReturnToPool()
TargetPlayers = null;
Pitch = 1f;
- Filter = null;
- resampleTime = 0.0;
- resampleBufferFilled = 0;
+ activeFilter = null;
isPitchDefault = true;
needsSyncWait = false;
- OnPlaybackStarted = null;
- OnPlaybackPaused = null;
- OnPlaybackResumed = null;
- OnPlaybackLooped = null;
- OnTrackSwitching = null;
- OnPlaybackFinished = null;
- OnPlaybackStopped = null;
-
SpeakerToyPlaybackBase.AllInstances.Remove(Base.Playback);
Pool.Enqueue(this);
@@ -1148,11 +996,7 @@ private void TryInitializePlayBack()
isPlayBackInitialized = true;
- frame = new float[FrameSize];
- processNextFrame = ProcessNextFrame;
- resampleBuffer = Array.Empty();
encoder = new(OpusApplicationType.Audio);
- encoded = new byte[VoiceChatSettings.MaxEncodedSize];
// 3002 => OPUS_SIGNAL_MUSIC (https://github.com/xiph/opus/blob/2d862ea14b233e5a3f3afaf74d96050691af3cd5/include/opus_defines.h#L229)
OpusWrapper.SetEncoderSetting(encoder._handle, OpusCtlSetRequest.Signal, 3002);
@@ -1160,68 +1004,22 @@ private void TryInitializePlayBack()
AdminToyBase.OnRemoved += OnToyRemoved;
}
- private void OnToyRemoved(AdminToyBase toy)
- {
- if (toy != Base)
- return;
-
- AdminToyBase.OnRemoved -= OnToyRemoved;
-
- Stop();
- encoder?.Dispose();
-
- OnPlaybackStarted = null;
- OnPlaybackPaused = null;
- OnPlaybackResumed = null;
- OnPlaybackLooped = null;
- OnTrackSwitching = null;
- OnPlaybackFinished = null;
- OnPlaybackStopped = null;
- }
-
- private void UpdateNextScheduledEventIndex()
- {
- nextScheduledEventIndex = 0;
- double current = CurrentTime;
-
- while (nextScheduledEventIndex < ScheduledEvents.Count && ScheduledEvents[nextScheduledEventIndex].Time <= current)
- {
- nextScheduledEventIndex++;
- }
- }
-
private void ResetEncoder()
{
- if (encoder != null && encoder._handle != IntPtr.Zero)
- {
- // 4028 => OPUS_RESET_STATE (https://github.com/xiph/opus/blob/2d862ea14b233e5a3f3afaf74d96050691af3cd5/include/opus_defines.h#L710)
- OpusWrapper.SetEncoderSetting(encoder._handle, (OpusCtlSetRequest)4028, 0);
- }
- }
-
- private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, bool linear, Action onComplete)
- {
- float timePassed = 0f;
- bool isFadeOut = startVolume > targetVolume;
-
- while (timePassed < duration)
+ lock (opusLock)
{
- timePassed += Time.deltaTime;
- float t = timePassed / duration;
-
- if (!linear)
- t = isFadeOut ? 1f - ((1f - t) * (1f - t)) : t * t;
-
- Base.NetworkVolume = Mathf.Lerp(startVolume, targetVolume, t);
- yield return Timing.WaitForOneFrame;
+ 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);
+ }
}
-
- Base.NetworkVolume = targetVolume;
- onComplete?.Invoke();
}
private IEnumerator PlayBackCoroutine()
{
+ StartProccesThread();
+
if (needsSyncWait)
{
int framesPassed = Time.frameCount - idChangeFrame;
@@ -1237,54 +1035,23 @@ private IEnumerator PlayBackCoroutine()
OnPlaybackStarted?.Invoke();
SpeakerEvents.OnPlaybackStarted(this);
- resampleTime = 0.0;
- resampleBufferFilled = 0;
-
- float timeAccumulator = 0f;
-
- ReadNextFrame();
- int firstLen = processNextFrame();
-
- if (firstLen > 2)
- SendAudioMessage(new AudioMessage(ControllerId, encoded, firstLen));
-
- if (CurrentSource.Ended)
- {
- OnPlaybackFinished?.Invoke();
- SpeakerEvents.OnPlaybackFinished(this);
- EndingPlayBack();
- yield break;
- }
-
- Task encodeTask = PrepareNextFrameAsync();
+ nextSendTime = Time.unscaledTimeAsDouble;
while (true)
{
- timeAccumulator += Time.deltaTime;
+ double currentTime = Time.unscaledTimeAsDouble;
- while (timeAccumulator >= FrameTime)
+ while (currentTime >= nextSendTime)
{
- timeAccumulator -= FrameTime;
-
- if (encodeTask.IsFaulted)
- {
- Log.Error($"[Speaker] An error occurred during audio encoding.\nException Details: {encodeTask.Exception}");
- Stop();
- yield break;
- }
+ nextSendTime += FrameTime;
- int len = encodeTask.Result;
+ if (packetQueue != null && packetQueue.TryTake(out (byte[] Data, int Length) packet) && packet.Length > 2)
+ SendAudioMessage(new AudioMessage(ControllerId, packet.Data, packet.Length));
- if (len > 2)
- SendAudioMessage(new AudioMessage(ControllerId, encoded, len));
-
- if (!CurrentSource.Ended)
- {
- encodeTask = PrepareNextFrameAsync();
+ if (packetQueue != null && !packetQueue.IsCompleted)
continue;
- }
- bool trackFailed = CurrentSource is IAsyncPcmSource asyncSource && asyncSource.IsFailed;
+ bool trackFailed = currentSource is IAsyncPcmSource asyncSource && asyncSource.IsFailed;
if (!trackFailed)
{
@@ -1295,25 +1062,19 @@ private IEnumerator PlayBackCoroutine()
if (Loop)
{
- resampleTime = 0.0;
- timeAccumulator = 0;
- resampleBufferFilled = 0;
- nextScheduledEventIndex = 0;
-
- ResetEncoder();
- Filter?.Reset();
- CurrentSource.Reset();
+ RestartTrack();
OnPlaybackLooped?.Invoke();
SpeakerEvents.OnPlaybackLooped(this);
- encodeTask = PrepareNextFrameAsync();
+ nextSendTime = Time.unscaledTimeAsDouble;
+
+ StartProccesThread();
continue;
}
}
EndingPlayBack();
-
yield break;
}
@@ -1335,121 +1096,230 @@ private IEnumerator PlayBackCoroutine()
}
}
- private void ReadNextFrame()
+ private void StartProccesThread()
{
- if (isPitchDefault)
+ StopProccesThread();
+
+ BlockingCollection<(byte[], int)> localQueue = new(PacketQueueCapacity);
+ packetQueue = localQueue;
+ processCts = new CancellationTokenSource();
+ CancellationToken token = processCts.Token;
+
+ new Thread(() => ProcessLoop(localQueue, token))
{
- int read = CurrentSource.Read(frame, 0, FrameSize);
- if (read < FrameSize)
- Array.Clear(frame, read, FrameSize - read);
+ IsBackground = true,
+ Priority = System.Threading.ThreadPriority.BelowNormal,
+ Name = $"[Exiled Speaker Api] Speaker.ProcessThread Id:[{ControllerId}]",
+ }.Start();
+ }
+
+ private void ProcessLoop(BlockingCollection<(byte[] Data, int Lenght)> localQueue, CancellationToken token)
+ {
+ float[] localFrame = new float[FrameSize];
+ byte[] localEncoded = new byte[VoiceChatSettings.MaxEncodedSize];
+ float[] localResampleBuffer = Array.Empty();
+ double localResampleTime = 0.0;
+ int localResampleBufferFilled = 0;
+
+ try
+ {
+ while (!token.IsCancellationRequested)
+ {
+ IPcmSource source = currentSource;
+ if (source == null || source.Ended)
+ break;
+
+ if (isPitchDefault)
+ {
+ int read = source.Read(localFrame, 0, FrameSize);
+ if (read < FrameSize)
+ Array.Clear(localFrame, read, FrameSize - read);
+ }
+ else
+ {
+ ResampleFrame(source, localFrame, ref localResampleBuffer, ref localResampleTime, ref localResampleBufferFilled);
+ }
+
+ activeFilter?.Process(localFrame);
+
+ int length;
+ lock (opusLock)
+ {
+ if (encoder == null)
+ break;
+
+ length = encoder.Encode(localFrame, localEncoded);
+ }
+
+ byte[] packet = new byte[length];
+ Array.Copy(localEncoded, packet, length);
+
+ localQueue.TryAdd((packet, length), -1, token);
+ }
}
- else
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[Speaker] Encode worker error.\nException Details: {ex}");
+ }
+ finally
{
- ResampleFrame();
+ localQueue.CompleteAdding();
}
}
- private int ProcessNextFrame()
+ private void StopProccesThread()
{
- Filter?.Process(frame);
- return encoder.Encode(frame, encoded);
+ CancellationTokenSource localCts = Interlocked.Exchange(ref processCts, null);
+ if (localCts != null)
+ {
+ localCts.Cancel();
+ localCts.Dispose();
+ }
+
+ packetQueue = null;
}
- private Task PrepareNextFrameAsync()
+ private void UpdateNextScheduledEventIndex()
{
- ReadNextFrame();
- return Task.Run(processNextFrame);
+ nextScheduledEventIndex = 0;
+ double current = CurrentTime;
+
+ while (nextScheduledEventIndex < ScheduledEvents.Count && ScheduledEvents[nextScheduledEventIndex].Time <= current)
+ {
+ nextScheduledEventIndex++;
+ }
}
- private void ResampleFrame()
+ private void EndingPlayBack()
{
- int requiredSize = (int)(FrameSize * Mathf.Abs(Pitch) * 2) + 10;
+ if (TrackQueue.Count > 0)
+ {
+ playBackRoutine.IsRunning = false;
+ SkipTrack();
+ }
+ else if (ReturnToPoolAfter)
+ {
+ ReturnToPool();
+ }
+ else if (DestroyAfter)
+ {
+ Destroy();
+ }
+ else
+ {
+ Stop();
+ }
+ }
+
+ private void ResampleFrame(IPcmSource source, float[] outFrame, ref float[] buffer, ref double time, ref int filled)
+ {
+ int requiredSize = (int)(FrameSize * Mathf.Abs(Pitch) * 2) + ResampleBufferPadding;
- if (resampleBuffer.Length < requiredSize)
+ if (buffer.Length < requiredSize)
{
- resampleBuffer = new float[requiredSize];
- resampleTime = 0.0;
- resampleBufferFilled = 0;
+ buffer = new float[requiredSize];
+ time = 0.0;
+ filled = 0;
}
int outputIdx = 0;
while (outputIdx < FrameSize)
{
- if (resampleBufferFilled == 0)
+ if (filled == 0)
{
- int toRead = resampleBuffer.Length - 4;
- int actualRead = CurrentSource.Read(resampleBuffer, 0, toRead);
-
+ int actualRead = source.Read(buffer, 0, buffer.Length - ResampleBufferPadding);
if (actualRead == 0)
{
- while (outputIdx < FrameSize)
- frame[outputIdx++] = 0f;
+ Array.Clear(outFrame, outputIdx, FrameSize - outputIdx);
return;
}
- resampleBufferFilled = actualRead;
- resampleTime = 0.0;
+ filled = actualRead;
+ time = 0.0;
}
- int currentSample = (int)resampleTime;
+ int currentSample = (int)time;
- if (currentSample >= resampleBufferFilled - 1)
+ if (currentSample >= filled - 1)
{
- if (resampleBufferFilled > 0)
+ if (filled > 0)
{
- resampleBuffer[0] = resampleBuffer[resampleBufferFilled - 1];
-
- int toRead = resampleBuffer.Length - 5;
- int actualRead = CurrentSource.Read(resampleBuffer, 1, toRead);
-
+ buffer[0] = buffer[filled - 1];
+ int actualRead = source.Read(buffer, 1, buffer.Length - ResampleBufferPadding - 1);
if (actualRead == 0)
{
- while (outputIdx < FrameSize)
- frame[outputIdx++] = 0f;
+ Array.Clear(outFrame, outputIdx, FrameSize - outputIdx);
return;
}
- resampleBufferFilled = actualRead + 1;
- resampleTime -= currentSample;
+ filled = actualRead + 1;
+ time -= currentSample;
}
else
{
- resampleBufferFilled = 0;
+ filled = 0;
}
continue;
}
- double frac = resampleTime - currentSample;
- float sample1 = resampleBuffer[currentSample];
- float sample2 = resampleBuffer[currentSample + 1];
-
- frame[outputIdx++] = (float)(sample1 + ((sample2 - sample1) * frac));
-
- resampleTime += Pitch;
+ double frac = time - currentSample;
+ outFrame[outputIdx++] = (float)(buffer[currentSample] + ((buffer[currentSample + 1] - buffer[currentSample]) * frac));
+ time += Pitch;
}
}
- private void EndingPlayBack()
+ private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, bool linear, Action onComplete)
{
- if (TrackQueue.Count > 0)
- {
- playBackRoutine.IsRunning = false;
- SkipTrack();
- }
- else if (ReturnToPoolAfter)
- {
- ReturnToPool();
- }
- else if (DestroyAfter)
+ float timePassed = 0f;
+ bool isFadeOut = startVolume > targetVolume;
+
+ while (timePassed < duration)
{
- Destroy();
+ timePassed += Time.deltaTime;
+ float t = timePassed / duration;
+
+ if (!linear)
+ t = isFadeOut ? 1f - ((1f - t) * (1f - t)) : t * t;
+
+ Base.NetworkVolume = Mathf.Lerp(startVolume, targetVolume, t);
+ yield return Timing.WaitForOneFrame;
}
- else
+
+ Base.NetworkVolume = targetVolume;
+ onComplete?.Invoke();
+ }
+
+ private void OnToyRemoved(AdminToyBase toy)
+ {
+ if (toy != Base)
+ return;
+
+ AdminToyBase.OnRemoved -= OnToyRemoved;
+
+ Stop();
+ ClearEvents();
+
+ lock (opusLock)
{
- Stop();
+ encoder?.Dispose();
+ encoder = null;
}
}
+
+ private void ClearEvents()
+ {
+ OnPlaybackStarted = null;
+ OnPlaybackPaused = null;
+ OnPlaybackResumed = null;
+ OnPlaybackLooped = null;
+ OnTrackSwitching = null;
+ OnPlaybackFinished = null;
+ OnPlaybackStopped = null;
+ }
}
}
\ No newline at end of file