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