Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions EXILED/Exiled.API/Features/Audio/WavUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ public static void SkipHeader(Stream stream)
short 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($"[Speaker] 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);
Expand All @@ -109,7 +112,10 @@ public static void SkipHeader(Stream stream)
}

if (stream.Position >= stream.Length)
throw new InvalidDataException("WAV file does not contain a 'data' chunk.");
{
Log.Error("[Speaker] WAV file does not contain a 'data' chunk.");
throw new InvalidDataException("Missing 'data' chunk in WAV file.");
}
}
}
}
Expand Down
3 changes: 1 addition & 2 deletions EXILED/Exiled.API/Features/Toys/Light.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
219 changes: 182 additions & 37 deletions EXILED/Exiled.API/Features/Toys/Speaker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,23 @@ namespace Exiled.API.Features.Toys
using Enums;

using Exiled.API.Features.Audio;
using Exiled.API.Features.Pools;

using Interfaces;

using MEC;

using Mirror;

using NorthwoodLib.Pools;

using UnityEngine;

using VoiceChat;
using VoiceChat.Codec;
using VoiceChat.Codec.Enums;
using VoiceChat.Networking;
using VoiceChat.Playbacks;

using Object = UnityEngine.Object;

Expand All @@ -37,6 +41,12 @@ namespace Exiled.API.Features.Toys
/// </summary>
public class Speaker : AdminToy, IWrapper<SpeakerToy>
{
/// <summary>
/// A queue used for object pooling of <see cref="Speaker"/> instances.
/// Reusing idle speakers instead of constantly creating and destroying them significantly improves server performance, especially for frequent audio events.
/// </summary>
internal static readonly Queue<Speaker> Pool = new();

private const int FrameSize = VoiceChatSettings.PacketSizePerChannel;
private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate;

Expand Down Expand Up @@ -280,69 +290,149 @@ public byte ControllerId
set => Base.NetworkControllerId = value;
}

/// <summary>
/// Gets or sets a value indicating whether the speaker should return to the pool after playback finishes.
/// </summary>
public bool ReturnToPoolAfter { get; set; }

/// <summary>
/// Creates a new <see cref="Speaker"/>.
/// </summary>
/// <param name="position">The position of the <see cref="Speaker"/>.</param>
/// <param name="rotation">The rotation of the <see cref="Speaker"/>.</param>
/// <param name="scale">The scale of the <see cref="Speaker"/>.</param>
/// <param name="parent">The parent transform to attach the <see cref="Speaker"/> to.</param>
/// <param name="position">The local position of the <see cref="Speaker"/>.</param>
/// <param name="controllerId">The specific controller ID to assign. If null, the next available ID is used.</param>
/// <param name="spawn">Whether the <see cref="Speaker"/> should be initially spawned.</param>
/// <returns>The new <see cref="Speaker"/>.</returns>
public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scale, bool spawn)
public static Speaker Create(Transform parent = null, Vector3? position = null, 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,
ControllerId = controllerId ?? GetNextFreeControllerId(),
};

speaker.Transform.localPosition = position ?? Vector3.zero;

if (spawn)
speaker.Spawn();

return speaker;
}

/// <summary>
/// Creates a new <see cref="Speaker"/>.
/// Rents an available speaker from the pool or creates a new one if the pool is empty.
/// </summary>
/// <param name="transform">The transform to create this <see cref="Speaker"/> on.</param>
/// <param name="spawn">Whether the <see cref="Speaker"/> should be initially spawned.</param>
/// <param name="worldPositionStays">Whether the <see cref="Speaker"/> should keep the same world position.</param>
/// <returns>The new <see cref="Speaker"/>.</returns>
public static Speaker Create(Transform transform, bool spawn, bool worldPositionStays = true)
/// <param name="position">The local position of the <see cref="Speaker"/>.</param>
/// <param name="parent">The parent transform to attach the <see cref="Speaker"/> to.</param>
/// <returns>A clean <see cref="Speaker"/> instance ready for use.</returns>
public static Speaker Rent(Vector3 position, Transform parent = 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: parent, position: position, spawn: true);
}
else
{
if (parent != null)
speaker.Transform.SetParent(parent);

speaker.Volume = 1f;
speaker.Transform.localPosition = position;
speaker.ControllerId = GetNextFreeControllerId();
}

return speaker;
}

/// <summary>
/// Plays audio through this speaker.
/// Gets the next available controller ID for a <see cref="Speaker"/>.
/// </summary>
/// <param name="message">An <see cref="AudioMessage"/> instance.</param>
/// <param name="targets">Targets who will hear the audio. If <c>null</c>, audio will be sent to all players.</param>
public static void Play(AudioMessage message, IEnumerable<Player> targets = null)
/// <returns>The next available byte ID. If all IDs are currently in use, returns a default of 0.</returns>
public static byte GetNextFreeControllerId()
{
foreach (Player target in targets ?? Player.List)
target.Connection.Send(message);
byte id = 0;
HashSet<byte> usedIds = NorthwoodLib.Pools.HashSetPool<byte>.Shared.Rent(256);

foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances)
{
usedIds.Add(playbackBase.ControllerId);
}

if (usedIds.Count >= byte.MaxValue + 1)
{
NorthwoodLib.Pools.HashSetPool<byte>.Shared.Return(usedIds);
return 0;
}

while (usedIds.Contains(id))
{
id++;
}

NorthwoodLib.Pools.HashSetPool<byte>.Shared.Return(usedIds);
return id;
}

/// <summary>
/// Plays audio through this speaker.
/// Rents a speaker from the pool, plays a wav file one time, and automatically returns it to the pool afterwards. (File must be 16 bit, mono and 48khz.)
/// </summary>
/// <param name="samples">Audio samples.</param>
/// <param name="length">The length of the samples array.</param>
/// <param name="targets">Targets who will hear the audio. If <c>null</c>, audio will be sent to all players.</param>
public void Play(byte[] samples, int? length = null, IEnumerable<Player> targets = null) => Play(new AudioMessage(ControllerId, samples, length ?? samples.Length), targets);
/// <param name="path">The path to the wav file.</param>
/// <param name="position">The position of the speaker.</param>
/// <param name="parent">The parent transform, if any.</param>
/// <param name="isSpatial">Whether the audio source is spatialized.</param>
/// <param name="volume">The volume level of the audio source.</param>
/// <param name="minDistance">The minimum distance at which the audio reaches full volume.</param>
/// <param name="maxDistance">The maximum distance at which the audio can be heard.</param>
/// <param name="pitch">The playback pitch level of the audio source.</param>
/// <param name="playMode">The play mode determining how audio is sent to players.</param>
/// <param name="stream">Whether to stream the audio or preload it.</param>
/// <param name="targetPlayer">The target player if PlayMode is Player.</param>
/// <param name="targetPlayers">The list of target players if PlayMode is PlayerList.</param>
/// <param name="predicate">The condition if PlayMode is Predicate.</param>
/// <returns>The rented <see cref="Speaker"/> instance if playback started successfully; otherwise, <c>null</c>.</returns>
public static Speaker PlayFromPool(string path, Vector3 position, Transform parent = null, bool isSpatial = true, float? volume = null, float? minDistance = null, float? maxDistance = null, float pitch = 1f, SpeakerPlayMode playMode = SpeakerPlayMode.Global, bool stream = false, Player targetPlayer = null, HashSet<Player> targetPlayers = null, Func<Player, bool> predicate = null)
{
Speaker speaker = Rent(position, parent);

if (!isSpatial)
speaker.IsSpatial = isSpatial;

if (volume.HasValue)
speaker.Volume = volume.Value;

if (minDistance.HasValue)
speaker.MinDistance = minDistance.Value;

if (maxDistance.HasValue)
speaker.MaxDistance = maxDistance.Value;

speaker.Pitch = pitch;
speaker.PlayMode = playMode;
speaker.Predicate = predicate;
speaker.TargetPlayer = targetPlayer;
speaker.TargetPlayers = targetPlayers;

speaker.ReturnToPoolAfter = true;

if (!speaker.Play(path, stream))
{
speaker.ReturnToPool();
return null;
}

return speaker;
}

/// <summary>
/// Plays a wav file through this speaker.(File must be 16 bit, mono and 48khz.)
Expand All @@ -351,22 +441,40 @@ public static void Play(AudioMessage message, IEnumerable<Player> targets = null
/// <param name="stream">Whether to stream the audio or preload it.</param>
/// <param name="destroyAfter">Whether to destroy the speaker after playback.</param>
/// <param name="loop">Whether to loop the audio.</param>
public void Play(string path, bool stream = false, bool destroyAfter = false, bool loop = false)
/// <returns><c>true</c> if the audio file was successfully found, loaded, and playback started; otherwise, <c>false</c>.</returns>
public bool Play(string path, bool stream = false, bool destroyAfter = false, bool loop = false)
{
if (!File.Exists(path))
throw new FileNotFoundException("The specified file does not exist.", path);
{
Log.Error($"[Speaker] The specified file does not exist, path: `{path}`.");
return false;
}

if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
throw new NotSupportedException($"The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file.");
{
Log.Error($"[Speaker] The file type '{Path.GetExtension(path)}' is not supported. Please use .wav file.");
return false;
}

TryInitializePlayBack();
Stop();

Loop = loop;
LastTrack = path;
DestroyAfter = destroyAfter;
source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path);

try
{
source = stream ? new WavStreamSource(path) : new PreloadedPcmSource(path);
}
catch (Exception ex)
{
Log.Error($"[Speaker] Failed to initialize audio source for file at path: '{path}'.\nException Details: {ex}");
return false;
}

playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject));
return true;
}

/// <summary>
Expand All @@ -384,6 +492,41 @@ public void Stop()
source = null;
}

/// <summary>
/// Stops the current playback, resets all properties of the <see cref="Speaker"/>, and returns the instance to the object pool for future reuse.
/// </summary>
public void ReturnToPool()
{
Stop();

Transform.SetParent(null);
Transform.localPosition = Vector3.zero;

Loop = false;
DestroyAfter = false;
ReturnToPoolAfter = false;
PlayMode = SpeakerPlayMode.Global;
Channel = Channels.ReliableOrdered2;

LastTrack = null;
Predicate = null;
TargetPlayer = null;
TargetPlayers = null;

Pitch = 1f;
Volume = 0f;
IsSpatial = true;

MinDistance = 1f;
MaxDistance = 15f;

resampleTime = 0.0;
resampleBufferFilled = 0;
isPitchDefault = true;

Pool.Enqueue(this);
}

private void TryInitializePlayBack()
{
if (isPlayBackInitialized)
Expand Down Expand Up @@ -445,7 +588,9 @@ private IEnumerator<float> PlayBackCoroutine()
continue;
}

if (DestroyAfter)
if (ReturnToPoolAfter)
ReturnToPool();
else if (DestroyAfter)
Destroy();
else
Stop();
Expand Down
3 changes: 3 additions & 0 deletions EXILED/Exiled.Events/Handlers/Internal/Round.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ namespace Exiled.Events.Handlers.Internal
using Exiled.API.Features.Items;
using Exiled.API.Features.Pools;
using Exiled.API.Features.Roles;
using Exiled.API.Features.Toys;
using Exiled.API.Structs;
using Exiled.Events.EventArgs.Player;
using Exiled.Events.EventArgs.Scp049;
Expand Down Expand Up @@ -65,6 +66,8 @@ public static void OnWaitingForPlayers()
/// <inheritdoc cref="Handlers.Server.OnRestartingRound" />
public static void OnRestartingRound()
{
Speaker.Pool.Clear();

Scp049Role.TurnedPlayers.Clear();
Scp173Role.TurnedPlayers.Clear();
Scp096Role.TurnedPlayers.Clear();
Expand Down
Loading