Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Speaker Toy #250

Open
wants to merge 32 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
17c64b3
Merge branch 'ExMod-Team:scpsl14' into scpsl14
NotZer0Two Nov 27, 2024
269c2be
FIrst
NotZer0Two Nov 28, 2024
3498248
Completed the Speaker API Wrapper w/ NotZer0Two
NotZer0Two Nov 29, 2024
511a504
Merge pull request #1 from Site-12/toysupdate
NotZer0Two Nov 30, 2024
9532760
dwadadad
SticksDeveloper Nov 30, 2024
7b71da0
IT WORKS
SticksDeveloper Nov 30, 2024
34110a4
Merge pull request #2 from Site-12/toysupdate
NotZer0Two Nov 30, 2024
222778b
Made isPlaying public for everyone to see
SticksDeveloper Dec 1, 2024
55f3ffa
Outdated Documentation
SticksDeveloper Dec 1, 2024
4c7d237
Merge pull request #3 from Site-12/toysupdate
NotZer0Two Dec 1, 2024
61e15a6
Fixes
NotZer0Two Dec 1, 2024
bc7e84b
Merge remote-tracking branch 'origin/toysupdate' into toysupdate
NotZer0Two Dec 1, 2024
a1186bc
Fixes
NotZer0Two Dec 1, 2024
e723806
BroadcastTo and removed Info Messages
NotZer0Two Dec 1, 2024
9e4575c
Made all the required changes
SticksDeveloper Dec 5, 2024
1a39261
Merge pull request #4 from Site-12/toysupdate
NotZer0Two Dec 6, 2024
461ce94
Speaker Prefab
NotZer0Two Dec 8, 2024
5321aa9
Hoping i fixed them
NotZer0Two Dec 8, 2024
4ae034f
Hoping that i fixed it
NotZer0Two Dec 8, 2024
c9ae12c
Merge branch 'scpsl14' into toysupdate
NotZer0Two Dec 8, 2024
f323cef
Conflits + Error Build Solved
NotZer0Two Dec 8, 2024
3614921
Removed Round.IsEnded
NotZer0Two Dec 10, 2024
1a0f639
Round Restarting For admintoys
NotZer0Two Dec 10, 2024
6fc52e8
Build Error fixed
NotZer0Two Dec 10, 2024
391846b
Updated the Create method, we don't need to have Scale, or Rotation.
SticksDeveloper Dec 10, 2024
da25416
Merge pull request #5 from Site-12/toysupdate
NotZer0Two Dec 10, 2024
7f7d066
Merge branch 'scpsl14' into toysupdate
VALERA771 Dec 22, 2024
8c69fdf
Merge Dev Branch To PR/250
louis1706 Dec 30, 2024
0ee371c
Apply suggestions from code review
louis1706 Dec 30, 2024
b81d2e3
fix
louis1706 Dec 30, 2024
e95b978
ReAdd This Method
louis1706 Dec 30, 2024
3b34af6
Merge branch 'dev' into toysupdate
VALERA771 Jan 1, 2025
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
1 change: 1 addition & 0 deletions EXILED/Exiled.API/Exiled.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="NVorbis" Version="0.10.5" />
<PackageReference Include="StyleCop.Analyzers" Version="$(StyleCopVersion)" IncludeAssets="All" PrivateAssets="All" />
<PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="Lib.Harmony" Version="$(HarmonyVersion)" />
Expand Down
180 changes: 168 additions & 12 deletions EXILED/Exiled.API/Features/Toys/Speaker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,28 @@
namespace Exiled.API.Features.Toys
{
using System.Collections.Generic;
using System.IO;
using System.Linq;

using AdminToys;
using Enums;
using Exiled.API.Interfaces;
using Features;
using Interfaces;
using MEC;
using NVorbis;
using UnityEngine;
using VoiceChat.Codec;
using VoiceChat.Networking;
using VoiceChat.Playbacks;

using Object = UnityEngine.Object;

/// <summary>
/// A wrapper class for <see cref="SpeakerToy"/>.
/// </summary>
public class Speaker : AdminToy, IWrapper<SpeakerToy>
{
private bool stopPlayback;

/// <summary>
/// Initializes a new instance of the <see cref="Speaker"/> class.
/// </summary>
Expand Down Expand Up @@ -91,29 +100,39 @@ public float MinDistance
}

/// <summary>
/// Gets or sets the controller ID of speaker.
/// Gets or sets the controller ID of the SpeakerToy.
/// </summary>
public byte ControllerId
public byte ControllerID
louis1706 marked this conversation as resolved.
Show resolved Hide resolved
louis1706 marked this conversation as resolved.
Show resolved Hide resolved
{
get => Base.NetworkControllerId;
set => Base.NetworkControllerId = value;
}

/// <summary>
/// Gets a value indicating whether the <see cref="Speaker"/> is playing an audio or not. (Use method Stop() to stop the playback).
/// </summary>
public bool IsPlaying { get; internal set; }

/// <summary>
/// Gets or Sets a list of players that can hear this speaker.
/// </summary>
public List<Player> BroadcastTo { get; set; }

/// <summary>
/// Creates a new <see cref="Speaker"/>.
/// </summary>
/// <param name="controllerId">The Identification of the <see cref="Speaker"/>. Playing two speakers with the same identification will cause them to play the same audio.</param>
/// <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="isSpatial">Whether the <see cref="Speaker"/> should be a 3d spaced audio, or a 2d spaced audio.</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(byte controllerId, Vector3? position, bool isSpatial, bool spawn)
louis1706 marked this conversation as resolved.
Show resolved Hide resolved
{
Speaker speaker = new(UnityEngine.Object.Instantiate(Prefab))
Speaker speaker = new(Object.Instantiate(Prefab))
{
Position = position ?? Vector3.zero,
Rotation = Quaternion.Euler(rotation ?? Vector3.zero),
Scale = scale ?? Vector3.one,
IsSpatial = isSpatial,
Base = { ControllerId = controllerId },
louis1706 marked this conversation as resolved.
Show resolved Hide resolved
};

if (spawn)
Expand All @@ -122,6 +141,17 @@ public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scal
return speaker;
}

/// <summary>
/// Gets the <see cref="Speaker"/> associated with a given <see cref="SpeakerToy"/>.
/// </summary>
/// <param name="speakerToy">The SpeakerToy instance.</param>
/// <returns>The corresponding Speaker instance.</returns>
public static Speaker Get(SpeakerToy speakerToy)
{
AdminToy adminToy = List.FirstOrDefault(x => x.AdminToyBase == speakerToy);
return adminToy is not null ? adminToy as Speaker : new(speakerToy);
}

/// <summary>
/// Plays audio through this speaker.
/// </summary>
Expand All @@ -139,6 +169,132 @@ public static void Play(AudioMessage message, IEnumerable<Player> targets = null
/// <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);
public void Play(byte[] samples, int? length = null, IEnumerable<Player> targets = null) => Play(new AudioMessage(ControllerID, samples, length ?? samples.Length), targets);

/// <summary>
/// Plays a single audio file through the speaker system. (No Arguments given (assuming you already preset those)).
/// </summary>
/// <param name="path">Path to the audio file to play.</param>
/// <param name="destroyAfter">Whether the Speaker gets destroyed after it's done playing.</param>
/// <returns>Return's whether the path was correct or not.</returns>
public bool Play(string path, bool destroyAfter = false) => Play(path, Volume, MinDistance, MaxDistance, BroadcastTo, destroyAfter);

/// <summary>
/// Plays a single audio file through the speaker system.
/// </summary>
/// <param name="path">The file path of the audio file.</param>
/// <param name="volume">The desired playback volume. (0 to <see cref="float"/>) max limit.</param>
/// <param name="minDistance">The minimum distance at which the audio's max volume is able to be heard.</param>
/// <param name="maxDistance">The maximum distance at which the audio is audible.</param>
/// <param name="playersToPlayTo">Whether to play it to a specific list of players. Keep null if you want to play it to all.</param>
/// <param name="destroyAfter">Whether the Speaker gets destroyed after it's done playing.</param>
/// <returns>Return's whether the inputted path was correct or not.</returns>
public bool Play(string path, float volume, float minDistance, float maxDistance, List<Player> playersToPlayTo = null, bool destroyAfter = false)
{
if (IsPlaying)
Stop();

if (!File.Exists(path))
{
Log.Warn($"Tried playing audio at {path} but no file was found.");
return false;
}

Volume = volume;
MinDistance = minDistance;
MaxDistance = maxDistance;
BroadcastTo = playersToPlayTo;

IsPlaying = true;
Timing.RunCoroutine(PlaybackRoutine(path, destroyAfter));
return true;
}

/// <summary>
/// Stops the current playback.
/// </summary>
public void Stop()
{
stopPlayback = true;
IsPlaying = false;
}

private IEnumerator<float> PlaybackRoutine(string filePath, bool destroyAfter)
{
stopPlayback = false;

string fileExtension = Path.GetExtension(filePath).ToLower();
Log.Debug($"Detected file: {filePath}, Extension: {fileExtension}");

const int sampleRate = 48000; // Enforce 48kHz
const int channels = 1; // Enforce mono audio
const int frameSize = 480; // Frame size for 10ms of audio at 48kHz

Queue<float> streamBuffer = new();
float[] readBuffer = new float[frameSize * 4];
float[] sendBuffer = new float[frameSize];
byte[] encodedBuffer = new byte[512];
OpusEncoder encoder = new(VoiceChat.Codec.Enums.OpusApplicationType.Voip);

float playbackInterval = frameSize / (float)sampleRate;
float nextPlaybackTime = Timing.LocalTime;

if (fileExtension != ".ogg")
{
Log.Error($"Unsupported file format: {fileExtension}");
yield break;
}

using VorbisReader vorbisReader = new(filePath);

if (vorbisReader.SampleRate != sampleRate || vorbisReader.Channels != channels)
{
Log.Error($"Invalid OGG file. Expected {sampleRate / 1000}kHz mono, got {vorbisReader.SampleRate / 1000}kHz {vorbisReader.Channels} channel(s).");
yield break;
}

Log.Debug($"Playing OGG file with Sample Rate: {sampleRate}, Channels: {channels}");

while (streamBuffer.Count < frameSize * 2 && !stopPlayback && Base.gameObject != null)
{
int samplesRead = vorbisReader.ReadSamples(readBuffer, 0, readBuffer.Length);
if (samplesRead <= 0)
break;

foreach (float sample in readBuffer.Take(samplesRead))
streamBuffer.Enqueue(sample);

while (!stopPlayback && streamBuffer.Count > 0)
{
if (Timing.LocalTime < nextPlaybackTime)
{
yield return Timing.WaitForOneFrame;
continue;
}

for (int i = 0; i < frameSize && streamBuffer.Count > 0; i++)
sendBuffer[i] = streamBuffer.Dequeue();

int dataLen = encoder.Encode(sendBuffer, encodedBuffer);
AudioMessage audioMessage = new(ControllerID, encodedBuffer, dataLen);

foreach (Player p in BroadcastTo ?? Player.List)
p.ReferenceHub.connectionToClient.Send(audioMessage);

nextPlaybackTime += playbackInterval;

if (streamBuffer.Count >= frameSize || vorbisReader.IsEndOfStream)
continue;
samplesRead = vorbisReader.ReadSamples(readBuffer, 0, readBuffer.Length);
foreach (float sample in readBuffer.Take(samplesRead))
streamBuffer.Enqueue(sample);
}
}

Log.Debug("Playback completed.");
IsPlaying = false;
if (destroyAfter && Base.gameObject != null)
Destroy();
}
}
}
}
9 changes: 9 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 @@ -69,6 +70,14 @@ public static void OnRestartingRound()
TeslaGate.IgnoredTeams.Clear();

API.Features.Round.IgnoredPlayers.Clear();

foreach (AdminToy admin in AdminToy.List)
{
if (admin is Speaker speaker && speaker.IsPlaying)
{
speaker.Stop();
}
}
}

/// <inheritdoc cref="Handlers.Server.OnRoundStarted" />
Expand Down
Loading