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