diff --git a/src/ayu/Ayu.Discord.Voice/Ayu.Discord.Voice.csproj b/src/ayu/Ayu.Discord.Voice/Ayu.Discord.Voice.csproj
new file mode 100644
index 0000000..c2b80e1
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/Ayu.Discord.Voice.csproj
@@ -0,0 +1,14 @@
+
+
+ netstandard2.1
+ 9.0
+ true
+ CS8632
+ 1.0.2
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/CloseCodes.cs b/src/ayu/Ayu.Discord.Voice/CloseCodes.cs
new file mode 100644
index 0000000..6102a19
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/CloseCodes.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace Ayu.Discord.Gateway
+{
+ public static class CloseCodes
+ {
+ private static IReadOnlyDictionary _closeCodes = new ReadOnlyDictionary(
+ new Dictionary()
+ {
+ { 4000, ("Unknown error", "We're not sure what went wrong. Try reconnecting?")},
+ { 4001, ("Unknown opcode", "You sent an invalid Gateway opcode or an invalid payload for an opcode. Don't do that!")},
+ { 4002, ("Decode error", "You sent an invalid payload to us. Don't do that!")},
+ { 4003, ("Not authenticated", "You sent us a payload prior to identifying.")},
+ { 4004, ("Authentication failed", "The account token sent with your identify payload is incorrect.")},
+ { 4005, ("Already authenticated", "You sent more than one identify payload. Don't do that!")},
+ { 4007, ("Invalid seq", "The sequence sent when resuming the session was invalid. Reconnect and start a new session.")},
+ { 4008, ("Rate limited", "Woah nelly! You're sending payloads to us too quickly. Slow it down! You will be disconnected on receiving this.")},
+ { 4009, ("Session timed out", "Your session timed out. Reconnect and start a new one.")},
+ { 4010, ("Invalid shard", "You sent us an invalid shard when identifying.")},
+ { 4011, ("Sharding required", "The session would have handled too many guilds - you are required to shard your connection in order to connect.")},
+ { 4012, ("Invalid API version", "You sent an invalid version for the gateway.")},
+ { 4013, ("Invalid intent(s)", "You sent an invalid intent for a Gateway Intent. You may have incorrectly calculated the bitwise value.")},
+ { 4014, ("Disallowed intent(s)", "You sent a disallowed intent for a Gateway Intent. You may have tried to specify an intent that you have not enabled or are not whitelisted for.")}
+ });
+
+ public static (string Error, string Message) GetErrorCodeMessage(int closeCode)
+ {
+ if (_closeCodes.TryGetValue(closeCode, out var data))
+ return data;
+
+ return ("Unknown error", closeCode.ToString());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/LibOpus.cs b/src/ayu/Ayu.Discord.Voice/LibOpus.cs
new file mode 100644
index 0000000..313f86c
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/LibOpus.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Ayu.Discord.Voice
+{
+ internal static unsafe class LibOpus
+ {
+ public const string OPUS = "data/lib/opus";
+
+ [DllImport(OPUS, EntryPoint = "opus_encoder_create", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern IntPtr CreateEncoder(int Fs, int channels, int application, out OpusError error);
+
+ [DllImport(OPUS, EntryPoint = "opus_encoder_destroy", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void DestroyEncoder(IntPtr encoder);
+
+ [DllImport(OPUS, EntryPoint = "opus_encode", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern int Encode(IntPtr st, byte* pcm, int frame_size, byte* data, int max_data_bytes);
+
+ [DllImport(OPUS, EntryPoint = "opus_encode_float", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern int EncodeFloat(IntPtr st, byte* pcm, int frame_size, byte* data, int max_data_bytes);
+
+ [DllImport(OPUS, EntryPoint = "opus_encoder_ctl", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern int EncoderCtl(IntPtr st, OpusCtl request, int value);
+ }
+
+ public enum OpusApplication
+ {
+ VOIP = 2048,
+ Audio = 2049,
+ RestrictedLowdelay = 2051
+ }
+
+ public unsafe class LibOpusEncoder : IDisposable
+ {
+ private readonly IntPtr _encoderPtr;
+
+ private readonly int _sampleRate;
+
+ // private readonly int _channels;
+ // private readonly int _bitRate;
+ private readonly int _frameDelay;
+
+ private readonly int _frameSizePerChannel;
+ public int FrameSizePerChannel => _frameSizePerChannel;
+
+ public const int MaxData = 1276;
+
+ public LibOpusEncoder(int sampleRate, int channels, int bitRate, int frameDelay)
+ {
+ _sampleRate = sampleRate;
+ // _channels = channels;
+ // _bitRate = bitRate;
+ _frameDelay = frameDelay;
+ _frameSizePerChannel = _sampleRate * _frameDelay / 1000;
+
+ _encoderPtr = LibOpus.CreateEncoder(sampleRate, channels, (int)OpusApplication.Audio, out var error);
+ if (error != OpusError.OK)
+ throw new ExternalException(error.ToString());
+
+ LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetSignal, (int)OpusSignal.Music);
+ LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetInbandFEC, 1);
+ LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetBitrate, bitRate);
+ LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetPacketLossPerc, 2);
+ }
+
+ public int SetControl(OpusCtl ctl, int value)
+ => LibOpus.EncoderCtl(_encoderPtr, ctl, value);
+
+ public int Encode(Span input, byte[] output)
+ {
+ fixed (byte* inPtr = input)
+ fixed (byte* outPtr = output)
+ return LibOpus.Encode(_encoderPtr, inPtr, FrameSizePerChannel, outPtr, output.Length);
+ }
+
+ public int EncodeFloat(Span input, byte[] output)
+ {
+ fixed (byte* inPtr = input)
+ fixed (byte* outPtr = output)
+ return LibOpus.EncodeFloat(_encoderPtr, inPtr, FrameSizePerChannel, outPtr, output.Length);
+ }
+
+
+ public void Dispose()
+ => LibOpus.DestroyEncoder(_encoderPtr);
+ }
+
+ public enum OpusCtl
+ {
+ SetBitrate = 4002,
+ GetBitrate = 4003,
+ SetBandwidth = 4008,
+ GetBandwidth = 4009,
+ SetComplexity = 4010,
+ GetComplexity = 4011,
+ SetInbandFEC = 4012,
+ GetInbandFEC = 4013,
+ SetPacketLossPerc = 4014,
+ GetPacketLossPerc = 4015,
+ SetLsbDepth = 4036,
+ GetLsbDepth = 4037,
+ SetDtx = 4016,
+ GetDtx = 4017,
+ SetSignal = 4024
+ }
+
+ public enum OpusError
+ {
+ OK = 0,
+ BadArg = -1,
+ BufferToSmall = -2,
+ InternalError = -3,
+ InvalidPacket = -4,
+ Unimplemented = -5,
+ InvalidState = -6,
+ AllocFail = -7
+ }
+
+ public enum OpusSignal
+ {
+ Auto = -1000,
+ Voice = 3001,
+ Music = 3002,
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/LibSodium.cs b/src/ayu/Ayu.Discord.Voice/LibSodium.cs
new file mode 100644
index 0000000..5bf43c0
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/LibSodium.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Ayu.Discord.Voice
+{
+ internal static unsafe class Sodium
+ {
+ private const string SODIUM = "data/lib/libsodium";
+
+ [DllImport(SODIUM, EntryPoint = "crypto_secretbox_easy", CallingConvention = CallingConvention.Cdecl)]
+ private static extern int SecretBoxEasy(byte* output, byte* input, long inputLength, byte* nonce, byte* secret);
+ [DllImport(SODIUM, EntryPoint = "crypto_secretbox_open_easy", CallingConvention = CallingConvention.Cdecl)]
+ private static extern int SecretBoxOpenEasy(byte* output, byte* input, ulong inputLength, byte* nonce, byte* secret);
+
+ public static int Encrypt(byte[] input, int inputOffset, long inputLength, byte[] output, int outputOffset, in ReadOnlySpan nonce, byte[] secret)
+ {
+ fixed (byte* inPtr = input)
+ fixed (byte* outPtr = output)
+ fixed (byte* noncePtr = nonce)
+ fixed (byte* secretPtr = secret)
+ return SecretBoxEasy(outPtr + outputOffset, inPtr + inputOffset, inputLength - inputOffset, noncePtr, secretPtr);
+ }
+ public static int Decrypt(byte[] input, ulong inputLength, byte[] output, in ReadOnlySpan nonce, byte[] secret)
+ {
+ fixed (byte* outPtr = output)
+ fixed (byte* inPtr = input)
+ fixed (byte* noncePtr = nonce)
+ fixed (byte* secretPtr = secret)
+ return SecretBoxOpenEasy(outPtr, inPtr, inputLength, noncePtr, secretPtr);
+ }
+ }
+}
diff --git a/src/ayu/Ayu.Discord.Voice/Models/SelectProtocol.cs b/src/ayu/Ayu.Discord.Voice/Models/SelectProtocol.cs
new file mode 100644
index 0000000..4e25b1c
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/Models/SelectProtocol.cs
@@ -0,0 +1,23 @@
+using Newtonsoft.Json;
+
+namespace Ayu.Discord.Voice.Models
+{
+ public sealed class SelectProtocol
+ {
+ [JsonProperty("protocol")]
+ public string Protocol { get; set; }
+
+ [JsonProperty("data")]
+ public ProtocolData Data { get; set; }
+
+ public sealed class ProtocolData
+ {
+ [JsonProperty("address")]
+ public string Address { get; set; }
+ [JsonProperty("port")]
+ public int Port { get; set; }
+ [JsonProperty("mode")]
+ public string Mode { get; set; }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceHello.cs b/src/ayu/Ayu.Discord.Voice/Models/VoiceHello.cs
new file mode 100644
index 0000000..37e407c
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/Models/VoiceHello.cs
@@ -0,0 +1,10 @@
+using Newtonsoft.Json;
+
+namespace Ayu.Discord.Voice.Models
+{
+ public sealed class VoiceHello
+ {
+ [JsonProperty("heartbeat_interval")]
+ public int HeartbeatInterval { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceIdentify.cs b/src/ayu/Ayu.Discord.Voice/Models/VoiceIdentify.cs
new file mode 100644
index 0000000..53b1af4
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/Models/VoiceIdentify.cs
@@ -0,0 +1,20 @@
+using Newtonsoft.Json;
+
+namespace Ayu.Discord.Voice.Models
+{
+ public sealed class VoiceIdentify
+ {
+ [JsonProperty("server_id")]
+ public string ServerId { get; set; }
+
+ [JsonProperty("user_id")]
+ public string UserId { get; set; }
+
+ [JsonProperty("session_id")]
+ public string SessionId { get; set; }
+
+ [JsonProperty("token")]
+ public string Token { get; set; }
+
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoicePayload.cs b/src/ayu/Ayu.Discord.Voice/Models/VoicePayload.cs
new file mode 100644
index 0000000..5ac642e
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/Models/VoicePayload.cs
@@ -0,0 +1,29 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace Discord.Models.Gateway
+{
+ public sealed class VoicePayload
+ {
+ [JsonProperty("op")]
+ public VoiceOpCode OpCode { get; set; }
+
+ [JsonProperty("d")]
+ public JToken Data { get; set; }
+ }
+
+ public enum VoiceOpCode
+ {
+ Identify = 0,
+ SelectProtocol = 1,
+ Ready = 2,
+ Heartbeat = 3,
+ SessionDescription = 4,
+ Speaking = 5,
+ HeartbeatAck = 6,
+ Resume = 7,
+ Hello = 8,
+ Resumed = 9,
+ ClientDisconnect = 13,
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceReady.cs b/src/ayu/Ayu.Discord.Voice/Models/VoiceReady.cs
new file mode 100644
index 0000000..8cae4bb
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/Models/VoiceReady.cs
@@ -0,0 +1,22 @@
+using Newtonsoft.Json;
+
+namespace Ayu.Discord.Voice.Models
+{
+ public sealed class VoiceReady
+ {
+ [JsonProperty("ssrc")]
+ public uint Ssrc { get; set; }
+
+ [JsonProperty("ip")]
+ public string Ip { get; set; }
+
+ [JsonProperty("port")]
+ public int Port { get; set; }
+
+ [JsonProperty("modes")]
+ public string[] Modes { get; set; }
+
+ [JsonProperty("heartbeat_interval")]
+ public string HeartbeatInterval { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceResume.cs b/src/ayu/Ayu.Discord.Voice/Models/VoiceResume.cs
new file mode 100644
index 0000000..b5875b8
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/Models/VoiceResume.cs
@@ -0,0 +1,16 @@
+using Newtonsoft.Json;
+
+namespace Ayu.Discord.Voice.Models
+{
+ public sealed class VoiceResume
+ {
+ [JsonProperty("server_id")]
+ public string ServerId { get; set; }
+
+ [JsonProperty("session_id")]
+ public string SessionId { get; set; }
+
+ [JsonProperty("token")]
+ public string Token { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceSessionDescription.cs b/src/ayu/Ayu.Discord.Voice/Models/VoiceSessionDescription.cs
new file mode 100644
index 0000000..ca02029
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/Models/VoiceSessionDescription.cs
@@ -0,0 +1,13 @@
+using Newtonsoft.Json;
+
+namespace Ayu.Discord.Voice.Models
+{
+ public sealed class VoiceSessionDescription
+ {
+ [JsonProperty("mode")]
+ public string Mode { get; set; }
+
+ [JsonProperty("secret_key")]
+ public byte[] SecretKey { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceSpeaking.cs b/src/ayu/Ayu.Discord.Voice/Models/VoiceSpeaking.cs
new file mode 100644
index 0000000..909c10d
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/Models/VoiceSpeaking.cs
@@ -0,0 +1,26 @@
+using Newtonsoft.Json;
+using System;
+
+namespace Ayu.Discord.Voice.Models
+{
+ public sealed class VoiceSpeaking
+ {
+ [JsonProperty("speaking")]
+ public int Speaking { get; set; }
+
+ [JsonProperty("delay")]
+ public int Delay { get; set; }
+
+ [JsonProperty("ssrc")]
+ public uint Ssrc { get; set; }
+
+ [Flags]
+ public enum State
+ {
+ None = 0,
+ Microphone = 1 << 0,
+ Soundshare = 1 << 1,
+ Priority = 1 << 2
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/PoopyBufferImmortalized.cs b/src/ayu/Ayu.Discord.Voice/PoopyBufferImmortalized.cs
new file mode 100644
index 0000000..2399ce7
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/PoopyBufferImmortalized.cs
@@ -0,0 +1,136 @@
+#nullable enable
+using System;
+using System.Buffers;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Ayu.Discord.Voice
+{
+ public sealed class PoopyBufferImmortalized : ISongBuffer
+ {
+ private readonly byte[] _buffer;
+ private readonly byte[] _outputArray;
+ private CancellationToken _cancellationToken;
+ private bool _isStopped;
+
+ public int ReadPosition { get; private set; }
+ public int WritePosition { get; private set; }
+
+ public int ContentLength => WritePosition >= ReadPosition
+ ? WritePosition - ReadPosition
+ : (_buffer.Length - ReadPosition) + WritePosition;
+
+ public int FreeSpace => _buffer.Length - ContentLength;
+
+ public bool Stopped => _cancellationToken.IsCancellationRequested || _isStopped;
+
+ public PoopyBufferImmortalized(int frameSize)
+ {
+ _buffer = ArrayPool.Shared.Rent(1_000_000);
+ _outputArray = new byte[frameSize];
+
+ ReadPosition = 0;
+ WritePosition = 0;
+ }
+
+ public void Stop()
+ => _isStopped = true;
+
+ // this method needs a rewrite
+ public Task BufferAsync(ITrackDataSource source, CancellationToken cancellationToken)
+ {
+ _cancellationToken = cancellationToken;
+ var bufferingCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ Task.Run(async () =>
+ {
+ var output = ArrayPool.Shared.Rent(38400);
+ try
+ {
+ int read;
+ while (!Stopped && (read = source.Read(output)) > 0)
+ {
+ while (!Stopped && FreeSpace <= read)
+ {
+ bufferingCompleted.TrySetResult(true);
+ await Task.Delay(100, cancellationToken).ConfigureAwait(false);
+ }
+
+ if (Stopped)
+ break;
+
+ Write(output, read);
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(output);
+ bufferingCompleted.TrySetResult(true);
+ }
+ }, cancellationToken);
+
+ return bufferingCompleted.Task;
+ }
+
+ private void Write(byte[] input, int writeCount)
+ {
+ if (WritePosition + writeCount < _buffer.Length)
+ {
+ Buffer.BlockCopy(input, 0, _buffer, WritePosition, writeCount);
+ WritePosition += writeCount;
+ return;
+ }
+
+ var wroteNormally = _buffer.Length - WritePosition;
+ Buffer.BlockCopy(input, 0, _buffer, WritePosition, wroteNormally);
+ var wroteFromStart = writeCount - wroteNormally;
+ Buffer.BlockCopy(input, wroteNormally, _buffer, 0, wroteFromStart);
+ WritePosition = wroteFromStart;
+ }
+
+ public Span Read(int count, out int length)
+ {
+ var toRead = Math.Min(ContentLength, count);
+ var wp = WritePosition;
+
+ if (ContentLength == 0)
+ {
+ length = 0;
+ return Span.Empty;
+ }
+
+ if (wp > ReadPosition || ReadPosition + toRead <= _buffer.Length)
+ {
+ // thsi can be achieved without copying if
+ // writer never writes until the end,
+ // but leaves a single chunk free
+ Span toReturn = _outputArray;
+ ((Span)_buffer).Slice(ReadPosition, toRead).CopyTo(toReturn);
+ ReadPosition += toRead;
+ length = toRead;
+ return toReturn;
+ }
+ else
+ {
+ Span toReturn = _outputArray;
+ var toEnd = _buffer.Length - ReadPosition;
+ var bufferSpan = (Span)_buffer;
+
+ bufferSpan.Slice(ReadPosition, toEnd).CopyTo(toReturn);
+ var fromStart = toRead - toEnd;
+ bufferSpan.Slice(0, fromStart).CopyTo(toReturn.Slice(toEnd));
+ ReadPosition = fromStart;
+ length = toEnd + fromStart;
+ return toReturn;
+ }
+ }
+
+ public void Dispose()
+ => ArrayPool.Shared.Return(_buffer);
+
+ public void Reset()
+ {
+ ReadPosition = 0;
+ WritePosition = 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/SocketClient.cs b/src/ayu/Ayu.Discord.Voice/SocketClient.cs
new file mode 100644
index 0000000..7c32af1
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/SocketClient.cs
@@ -0,0 +1,154 @@
+using Serilog;
+using System;
+using System.Buffers;
+using System.Net.WebSockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Ayu.Discord.Gateway
+{
+ public class SocketClient : IDisposable
+ {
+ private ClientWebSocket? _ws;
+
+ public event Func? PayloadReceived = delegate { return Task.CompletedTask; };
+ public event Func? WebsocketClosed = delegate { return Task.CompletedTask; };
+
+ const int CHUNK_SIZE = 1024 * 16;
+
+ public async Task RunAndBlockAsync(Uri url, CancellationToken cancel)
+ {
+ var error = "Error.";
+ var bufferWriter = new ArrayBufferWriter(CHUNK_SIZE);
+ try
+ {
+ using (_ws = new())
+ {
+ await _ws.ConnectAsync(url, cancel).ConfigureAwait(false);
+ // WebsocketConnected!.Invoke(this);
+
+ while (true)
+ {
+ var result = await _ws.ReceiveAsync(bufferWriter.GetMemory(CHUNK_SIZE), cancel);
+ bufferWriter.Advance(result.Count);
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ var closeMessage = CloseCodes.GetErrorCodeMessage((int?)_ws.CloseStatus ?? 0).Message;
+ error = $"Websocket closed ({_ws.CloseStatus}): {_ws.CloseStatusDescription} {closeMessage}";
+ break;
+ }
+
+ if (result.EndOfMessage)
+ {
+ var pr = PayloadReceived;
+ var data = bufferWriter.WrittenMemory.ToArray();
+ bufferWriter.Clear();
+
+ if (pr is not null)
+ {
+ await pr.Invoke(data);
+ }
+ }
+ }
+ }
+ }
+ catch (WebSocketException ex)
+ {
+ Log.Warning("Disconnected, check your internet connection...");
+ Log.Debug(ex, "Websocket Exception in websocket client");
+ }
+ catch (OperationCanceledException)
+ {
+ // ignored
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Error in websocket client. {Message}", ex.Message);
+ }
+ finally
+ {
+ bufferWriter.Clear();
+ _ws = null;
+ await ClosedAsync(error).ConfigureAwait(false);
+ }
+ }
+
+ private async Task ClosedAsync(string msg = "Error")
+ {
+ try
+ {
+ await WebsocketClosed!.Invoke(msg).ConfigureAwait(false);
+ }
+ catch
+ {
+ }
+ }
+
+ private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
+
+ public async Task SendAsync(byte[] data)
+ {
+ await _sendLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ var ws = _ws;
+ if (ws is null)
+ throw new WebSocketException("Websocket is disconnected.");
+ for (var i = 0; i < data.Length; i += 4096)
+ {
+ var count = i + 4096 > data.Length ? data.Length - i : 4096;
+ await ws.SendAsync(new(data, i, count),
+ WebSocketMessageType.Text,
+ i + count >= data.Length,
+ CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ _sendLock.Release();
+ }
+ }
+
+ public async Task SendBulkAsync(byte[] data)
+ {
+ var ws = _ws;
+ if (ws is null)
+ throw new WebSocketException("Websocket is disconnected.");
+
+ await ws.SendAsync(new(data, 0, data.Length),
+ WebSocketMessageType.Binary,
+ true,
+ CancellationToken.None).ConfigureAwait(false);
+ }
+
+ public async Task CloseAsync(string msg = "Stop")
+ {
+ if (_ws is not null && _ws.State != WebSocketState.Closed)
+ {
+ try
+ {
+ await _ws.CloseAsync(WebSocketCloseStatus.InternalServerError, msg, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ return true;
+ }
+ catch
+ {
+ }
+ }
+
+ return false;
+ }
+
+ public void Dispose()
+ {
+ PayloadReceived = null;
+ WebsocketClosed = null;
+ var ws = _ws;
+ if (ws is null)
+ return;
+
+ ws.Dispose();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/SongBuffer.cs b/src/ayu/Ayu.Discord.Voice/SongBuffer.cs
new file mode 100644
index 0000000..b5f09ef
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/SongBuffer.cs
@@ -0,0 +1,95 @@
+using Serilog;
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Ayu.Discord.Voice
+{
+ public interface ISongBuffer : IDisposable
+ {
+ Span Read(int toRead, out int read);
+ Task BufferAsync(ITrackDataSource source, CancellationToken cancellationToken);
+ void Reset();
+ void Stop();
+ }
+
+ public interface ITrackDataSource
+ {
+ public int Read(byte[] output);
+ }
+
+ public sealed class FfmpegTrackDataSource : ITrackDataSource, IDisposable
+ {
+ private Process _p;
+
+ private readonly string _streamUrl;
+ private readonly bool _isLocal;
+ private readonly string _pcmType;
+
+ private FfmpegTrackDataSource(int bitDepth, string streamUrl, bool isLocal)
+ {
+ this._pcmType = bitDepth == 16 ? "s16le" : "f32le";
+ this._streamUrl = streamUrl;
+ this._isLocal = isLocal;
+ }
+
+ public static FfmpegTrackDataSource CreateAsync(int bitDepth, string streamUrl, bool isLocal)
+ {
+ try
+ {
+ var source = new FfmpegTrackDataSource(bitDepth, streamUrl, isLocal);
+ source.StartFFmpegProcess();
+ return source;
+ }
+ catch (System.ComponentModel.Win32Exception)
+ {
+ Log.Error(@"You have not properly installed or configured FFMPEG.
+Please install and configure FFMPEG to play music.
+Check the guides for your platform on how to setup ffmpeg correctly:
+ Windows Guide: https://goo.gl/OjKk8F
+ Linux Guide: https://goo.gl/ShjCUo");
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (InvalidOperationException)
+ {
+ }
+ catch (Exception ex)
+ {
+ Log.Information(ex, "Error starting ffmpeg: {ErrorMessage}", ex.Message);
+ }
+
+ return null;
+ }
+
+ private Process StartFFmpegProcess()
+ {
+ var args = $"-err_detect ignore_err -i {_streamUrl} -f {_pcmType} -ar 48000 -vn -ac 2 pipe:1 -loglevel error";
+ if (!_isLocal)
+ args = $"-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5 {args}";
+
+ return _p = Process.Start(new ProcessStartInfo
+ {
+ FileName = "ffmpeg",
+ Arguments = args,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = false,
+ CreateNoWindow = true,
+ });
+ }
+
+ public int Read(byte[] output)
+ => _p.StandardOutput.BaseStream.Read(output);
+
+ public void Dispose()
+ {
+ try { _p?.Kill(); } catch { }
+
+ try { _p?.Dispose(); } catch { }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/VoiceClient.cs b/src/ayu/Ayu.Discord.Voice/VoiceClient.cs
new file mode 100644
index 0000000..ba8e85e
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/VoiceClient.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Buffers;
+
+namespace Ayu.Discord.Voice
+{
+ public sealed class VoiceClient : IDisposable
+ {
+ delegate int EncodeDelegate(Span input, byte[] output);
+
+ private readonly int sampleRate;
+ private readonly int bitRate;
+ private readonly int channels;
+ private readonly int frameDelay;
+ private readonly int bitDepth;
+
+ public LibOpusEncoder Encoder { get; }
+ private readonly ArrayPool _arrayPool;
+
+ public int BitDepth => bitDepth * 8;
+ public int Delay => frameDelay;
+
+ private int FrameSizePerChannel => Encoder.FrameSizePerChannel;
+ public int InputLength => FrameSizePerChannel * channels * bitDepth;
+
+ EncodeDelegate Encode;
+
+ // https://github.com/xiph/opus/issues/42 w
+ public VoiceClient(SampleRate sampleRate = SampleRate._48k,
+ Bitrate bitRate = Bitrate._192k,
+ Channels channels = Channels.Two,
+ FrameDelay frameDelay = FrameDelay.Delay20,
+ BitDepthEnum bitDepthEnum = BitDepthEnum.Float32)
+ {
+ this.frameDelay = (int)frameDelay;
+ this.sampleRate = (int)sampleRate;
+ this.bitRate = (int)bitRate;
+ this.channels = (int)channels;
+ this.bitDepth = (int)bitDepthEnum;
+
+ this.Encoder = new(this.sampleRate, this.channels, this.bitRate, this.frameDelay);
+
+ Encode = bitDepthEnum switch
+ {
+ BitDepthEnum.Float32 => Encoder.EncodeFloat,
+ BitDepthEnum.UInt16 => Encoder.Encode,
+ _ => throw new NotSupportedException(nameof(BitDepth))
+ };
+
+ if (bitDepthEnum == BitDepthEnum.Float32)
+ {
+ Encode = Encoder.EncodeFloat;
+ }
+ else
+ {
+ Encode = Encoder.Encode;
+ }
+
+ _arrayPool = ArrayPool.Shared;
+ }
+
+ public int SendPcmFrame(VoiceGateway gw, Span data, int offset, int count)
+ {
+ var secretKey = gw.SecretKey;
+ if (secretKey.Length == 0)
+ {
+ return (int)SendPcmError.SecretKeyUnavailable;
+ }
+
+ // encode using opus
+ var encodeOutput = _arrayPool.Rent(LibOpusEncoder.MaxData);
+ try
+ {
+ var encodeOutputLength = Encode(data, encodeOutput);
+ return SendOpusFrame(gw, encodeOutput, 0, encodeOutputLength);
+ }
+ finally
+ {
+ _arrayPool.Return(encodeOutput);
+ }
+ }
+
+ public int SendOpusFrame(VoiceGateway gw, byte[] data, int offset, int count)
+ {
+ var secretKey = gw.SecretKey;
+ if (secretKey is null)
+ {
+ return (int)SendPcmError.SecretKeyUnavailable;
+ }
+
+ // form RTP header
+ var headerLength = 1 // version + flags
+ + 1 // payload type
+ + 2 // sequence
+ + 4 // timestamp
+ + 4; // ssrc
+
+ var header = new byte[headerLength];
+
+ header[0] = 0x80; // version + flags
+ header[1] = 0x78; // payload type
+
+ // get byte values for header data
+ var seqBytes = BitConverter.GetBytes(gw.Sequence); // 2
+ var nonceBytes = BitConverter.GetBytes(gw.NonceSequence); // 2
+ var timestampBytes = BitConverter.GetBytes(gw.Timestamp); // 4
+ var ssrcBytes = BitConverter.GetBytes(gw.Ssrc); // 4
+
+ gw.Timestamp += (uint)FrameSizePerChannel;
+ gw.Sequence++;
+ gw.NonceSequence++;
+
+ if (BitConverter.IsLittleEndian)
+ {
+ Array.Reverse(seqBytes);
+ Array.Reverse(nonceBytes);
+ Array.Reverse(timestampBytes);
+ Array.Reverse(ssrcBytes);
+ }
+
+ // copy headers
+ Buffer.BlockCopy(seqBytes, 0, header, 2, 2);
+ Buffer.BlockCopy(timestampBytes, 0, header, 4, 4);
+ Buffer.BlockCopy(ssrcBytes, 0, header, 8, 4);
+
+ //// encryption part
+ //// create a byte array where to store the encrypted data
+ //// it has to be inputLength + crypto_secretbox_MACBYTES (constant with value 16)
+ var encryptedBytes = new byte[count + 16];
+
+ //// form nonce with header + 12 empty bytes
+ //var nonce = new byte[24];
+ //Buffer.BlockCopy(rtpHeader, 0, nonce, 0, rtpHeader.Length);
+
+ var nonce = new byte[4];
+ Buffer.BlockCopy(seqBytes, 0, nonce, 2, 2);
+
+ Sodium.Encrypt(data, 0, count, encryptedBytes, 0, nonce, secretKey);
+
+ var rtpDataLength = headerLength + encryptedBytes.Length + nonce.Length;
+ var rtpData = _arrayPool.Rent(rtpDataLength);
+ try
+ {
+ //copy headers
+ Buffer.BlockCopy(header, 0, rtpData, 0, header.Length);
+ //copy audio data
+ Buffer.BlockCopy(encryptedBytes, 0, rtpData, header.Length, encryptedBytes.Length);
+ Buffer.BlockCopy(nonce, 0, rtpData, rtpDataLength - 4, 4);
+
+ gw.SendRtpData(rtpData, rtpDataLength);
+ // FUTURE When there's a break in the sent data,
+ // the packet transmission shouldn't simply stop.
+ // Instead, send five frames of silence (0xF8, 0xFF, 0xFE)
+ // before stopping to avoid unintended Opus interpolation
+ // with subsequent transmissions.
+
+ return rtpDataLength;
+ }
+ finally
+ {
+ _arrayPool.Return(rtpData);
+ }
+ }
+
+ public void Dispose()
+ => Encoder.Dispose();
+ }
+
+ public enum SendPcmError
+ {
+ SecretKeyUnavailable = -1,
+ }
+
+
+ public enum FrameDelay
+ {
+ Delay5 = 5,
+ Delay10 = 10,
+ Delay20 = 20,
+ Delay40 = 40,
+ Delay60 = 60,
+ }
+
+ public enum BitDepthEnum
+ {
+ UInt16 = sizeof(UInt16),
+ Float32 = sizeof(float),
+ }
+
+ public enum SampleRate
+ {
+ _48k = 48_000,
+ }
+
+ public enum Bitrate
+ {
+ _64k = 64 * 1024,
+ _96k = 96 * 1024,
+ _128k = 128 * 1024,
+ _192k = 192 * 1024,
+ }
+
+ public enum Channels
+ {
+ One = 1,
+ Two = 2,
+ }
+}
\ No newline at end of file
diff --git a/src/ayu/Ayu.Discord.Voice/VoiceGateway.cs b/src/ayu/Ayu.Discord.Voice/VoiceGateway.cs
new file mode 100644
index 0000000..b42c721
--- /dev/null
+++ b/src/ayu/Ayu.Discord.Voice/VoiceGateway.cs
@@ -0,0 +1,375 @@
+using Ayu.Discord.Voice.Models;
+using Discord.Models.Gateway;
+using Newtonsoft.Json.Linq;
+using Serilog;
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Channels;
+using System.Threading.Tasks;
+using Ayu.Discord.Gateway;
+using Newtonsoft.Json;
+
+namespace Ayu.Discord.Voice
+{
+ public class VoiceGateway
+ {
+ private class QueueItem
+ {
+ public VoicePayload Payload { get; }
+ public TaskCompletionSource Result { get; }
+
+ public QueueItem(VoicePayload payload, TaskCompletionSource result)
+ {
+ Payload = payload;
+ Result = result;
+ }
+ }
+
+ private readonly ulong _guildId;
+ private readonly ulong _userId;
+ private readonly string _sessionId;
+ private readonly string _token;
+ private readonly string _endpoint;
+ private readonly Uri _websocketUrl;
+ private readonly Channel _channel;
+
+ public TaskCompletionSource ConnectingFinished { get; }
+
+ private readonly Random _rng;
+ private readonly SocketClient _ws;
+ private readonly UdpClient _udpClient;
+ private Timer? _heartbeatTimer;
+ private bool _receivedAck;
+ private IPEndPoint? _udpEp;
+
+ public uint Ssrc { get; private set; }
+ public string Ip { get; private set; } = string.Empty;
+ public int Port { get; private set; } = 0;
+ public byte[] SecretKey { get; private set; } = Array.Empty();
+ public string Mode { get; private set; } = string.Empty;
+ public ushort Sequence { get; set; }
+ public uint NonceSequence { get; set; }
+ public uint Timestamp { get; set; }
+ public string MyIp { get; private set; } = string.Empty;
+ public ushort MyPort { get; private set; }
+ private bool _shouldResume;
+
+ private readonly CancellationTokenSource _stopCancellationSource;
+ private readonly CancellationToken _stopCancellationToken;
+ public bool Stopped => _stopCancellationToken.IsCancellationRequested;
+
+ public event Func OnClosed = delegate { return Task.CompletedTask; };
+
+ public VoiceGateway(ulong guildId, ulong userId, string session, string token, string endpoint)
+ {
+ this._guildId = guildId;
+ this._userId = userId;
+ this._sessionId = session;
+ this._token = token;
+ this._endpoint = endpoint;
+
+ //Log.Information("g: {GuildId} u: {UserId} sess: {Session} tok: {Token} ep: {Endpoint}",
+ // guildId, userId, session, token, endpoint);
+
+ this._websocketUrl = new($"wss://{_endpoint.Replace(":80", "")}?v=4");
+ this._channel = Channel.CreateUnbounded(new()
+ {
+ SingleReader = true,
+ SingleWriter = false,
+ AllowSynchronousContinuations = false,
+ });
+
+ ConnectingFinished = new();
+
+ _rng = new();
+
+ _ws = new();
+ _udpClient = new();
+ _stopCancellationSource = new();
+ _stopCancellationToken = _stopCancellationSource.Token;
+
+ _ws.PayloadReceived += _ws_PayloadReceived;
+ _ws.WebsocketClosed += _ws_WebsocketClosed;
+ }
+
+ public Task WaitForReadyAsync()
+ => ConnectingFinished.Task;
+
+ private async Task SendLoop()
+ {
+ while (!_stopCancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ var qi = await _channel.Reader.ReadAsync(_stopCancellationToken);
+ //Log.Information("Sending payload with opcode {OpCode}", qi.Payload.OpCode);
+
+ var json = JsonConvert.SerializeObject(qi.Payload);
+
+ if (!_stopCancellationToken.IsCancellationRequested)
+ await _ws.SendAsync(Encoding.UTF8.GetBytes(json));
+ _ = Task.Run(() => qi.Result.TrySetResult(true));
+ }
+ catch (ChannelClosedException)
+ {
+ Log.Warning("Voice gateway send channel is closed");
+ }
+ }
+ }
+
+ private async Task _ws_PayloadReceived(byte[] arg)
+ {
+ var payload = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(arg));
+ if (payload is null)
+ return;
+ try
+ {
+ //Log.Information("Received payload with opcode {OpCode}", payload.OpCode);
+
+ switch (payload.OpCode)
+ {
+ case VoiceOpCode.Identify:
+ // sent, not received.
+ break;
+ case VoiceOpCode.SelectProtocol:
+ // sent, not received
+ break;
+ case VoiceOpCode.Ready:
+ var ready = payload.Data.ToObject();
+ await HandleReadyAsync(ready!);
+ _shouldResume = true;
+ break;
+ case VoiceOpCode.Heartbeat:
+ // sent, not received
+ break;
+ case VoiceOpCode.SessionDescription:
+ var sd = payload.Data.ToObject();
+ await HandleSessionDescription(sd!);
+ break;
+ case VoiceOpCode.Speaking:
+ // ignore for now
+ break;
+ case VoiceOpCode.HeartbeatAck:
+ _receivedAck = true;
+ break;
+ case VoiceOpCode.Resume:
+ // sent, not received
+ break;
+ case VoiceOpCode.Hello:
+ var hello = payload.Data.ToObject();
+ await HandleHelloAsync(hello!);
+ break;
+ case VoiceOpCode.Resumed:
+ _shouldResume = true;
+ break;
+ case VoiceOpCode.ClientDisconnect:
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Error handling payload with opcode {OpCode}: {Message}", payload.OpCode, ex.Message);
+ }
+ }
+ private Task _ws_WebsocketClosed(string arg)
+ {
+ if (!string.IsNullOrWhiteSpace(arg))
+ {
+ Log.Warning("Voice Websocket closed: {Arg}", arg);
+ }
+
+ var hbt = _heartbeatTimer;
+ hbt?.Change(Timeout.Infinite, Timeout.Infinite);
+ _heartbeatTimer = null;
+
+ if (!_stopCancellationToken.IsCancellationRequested && _shouldResume)
+ {
+ _ = _ws.RunAndBlockAsync(_websocketUrl, _stopCancellationToken);
+ return Task.CompletedTask;
+ }
+
+ _ws.WebsocketClosed -= _ws_WebsocketClosed;
+ _ws.PayloadReceived -= _ws_PayloadReceived;
+
+ if (!_stopCancellationToken.IsCancellationRequested)
+ _stopCancellationSource.Cancel();
+
+ return this.OnClosed(this);
+ }
+
+ public void SendRtpData(byte[] rtpData, int length)
+ => _udpClient.Send(rtpData, length, _udpEp);
+
+ private Task HandleSessionDescription(VoiceSessionDescription sd)
+ {
+ SecretKey = sd.SecretKey;
+ Mode = sd.Mode;
+
+ _ = Task.Run(() => ConnectingFinished.TrySetResult(true));
+
+ return Task.CompletedTask;
+ }
+
+ private Task ResumeAsync()
+ {
+ _shouldResume = false;
+ return SendCommandPayloadAsync(new()
+ {
+ OpCode = VoiceOpCode.Resume,
+ Data = JToken.FromObject(new VoiceResume
+ {
+ ServerId = this._guildId.ToString(),
+ SessionId = this._sessionId,
+ Token = this._token,
+ })
+ });
+ }
+
+ private async Task HandleReadyAsync(VoiceReady ready)
+ {
+ Ssrc = ready.Ssrc;
+
+ //Log.Information("Received ready {GuildId}, {Session}, {Token}", guildId, session, token);
+
+ _udpEp = new(IPAddress.Parse(ready.Ip), ready.Port);
+
+ var ssrcBytes = BitConverter.GetBytes(Ssrc);
+ Array.Reverse(ssrcBytes);
+ var ipDiscoveryData = new byte[74];
+ Buffer.BlockCopy(ssrcBytes, 0, ipDiscoveryData, 4, ssrcBytes.Length);
+ ipDiscoveryData[0] = 0x00;
+ ipDiscoveryData[1] = 0x01;
+ ipDiscoveryData[2] = 0x00;
+ ipDiscoveryData[3] = 0x46;
+ await _udpClient.SendAsync(ipDiscoveryData, ipDiscoveryData.Length, _udpEp);
+ while (true)
+ {
+ var buffer = _udpClient.Receive(ref _udpEp);
+
+ if (buffer.Length == 74)
+ {
+ //Log.Information("Received IP discovery data.");
+
+ var myIp = Encoding.UTF8.GetString(buffer, 8, buffer.Length - 10);
+ MyIp = myIp.TrimEnd('\0');
+ MyPort = (ushort)((buffer[^2] << 8) | buffer[^1]);
+
+ //Log.Information("{MyIp}:{MyPort}", MyIp, MyPort);
+
+ await SelectProtocol();
+ return;
+ }
+
+ //Log.Information("Received voice data");
+ }
+ }
+
+ private Task HandleHelloAsync(VoiceHello data)
+ {
+ _receivedAck = true;
+ _heartbeatTimer = new(async _ =>
+ {
+ await SendHeartbeatAsync();
+ }, default, data.HeartbeatInterval, data.HeartbeatInterval);
+
+ if (_shouldResume)
+ {
+ return ResumeAsync();
+ }
+
+ return IdentifyAsync();
+ }
+
+ private Task IdentifyAsync()
+ => SendCommandPayloadAsync(new()
+ {
+ OpCode = VoiceOpCode.Identify,
+ Data = JToken.FromObject(new VoiceIdentify
+ {
+ ServerId = _guildId.ToString(),
+ SessionId = _sessionId,
+ Token = _token,
+ UserId = _userId.ToString(),
+ })
+ });
+
+ private Task SelectProtocol()
+ => SendCommandPayloadAsync(new()
+ {
+ OpCode = VoiceOpCode.SelectProtocol,
+ Data = JToken.FromObject(new SelectProtocol
+ {
+ Protocol = "udp",
+ Data = new()
+ {
+ Address = MyIp,
+ Port = MyPort,
+ Mode = "xsalsa20_poly1305_lite",
+ }
+ })
+ });
+
+ private async Task SendHeartbeatAsync()
+ {
+ if (!_receivedAck)
+ {
+ Log.Warning("Voice gateway didn't receive HearbeatAck - closing");
+ var success = await _ws.CloseAsync();
+ if (!success)
+ await _ws_WebsocketClosed(null);
+ return;
+ }
+
+ _receivedAck = false;
+ await SendCommandPayloadAsync(new()
+ {
+ OpCode = VoiceOpCode.Heartbeat,
+ Data = JToken.FromObject(_rng.Next())
+ });
+ }
+
+ public Task SendSpeakingAsync(VoiceSpeaking.State speaking)
+ => SendCommandPayloadAsync(new()
+ {
+ OpCode = VoiceOpCode.Speaking,
+ Data = JToken.FromObject(new VoiceSpeaking
+ {
+ Delay = 0,
+ Ssrc = Ssrc,
+ Speaking = (int)speaking
+ })
+ });
+
+ public Task StopAsync()
+ {
+ Started = false;
+ _shouldResume = false;
+ if (!_stopCancellationSource.IsCancellationRequested)
+ try { _stopCancellationSource.Cancel(); } catch { }
+ return _ws.CloseAsync("Stopped by the user.");
+ }
+
+ public Task Start()
+ {
+ Started = true;
+ _ = SendLoop();
+ return _ws.RunAndBlockAsync(_websocketUrl, _stopCancellationToken);
+ }
+
+ public bool Started { get; set; }
+
+ public async Task SendCommandPayloadAsync(VoicePayload payload)
+ {
+ var complete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var queueItem = new QueueItem(payload, complete);
+
+ if (!_channel.Writer.TryWrite(queueItem))
+ await _channel.Writer.WriteAsync(queueItem);
+
+ await complete.Task;
+ }
+ }
+}
\ No newline at end of file