diff --git a/src/Discord.Net.Audio/AudioClient.cs b/src/Discord.Net.Audio/AudioClient.cs new file mode 100644 index 000000000..65173db55 --- /dev/null +++ b/src/Discord.Net.Audio/AudioClient.cs @@ -0,0 +1,119 @@ +using Discord.API; +using Discord.API.Voice; +using Discord.Net.Converters; +using Discord.Net.WebSockets; +using Newtonsoft.Json; +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio +{ + public class AudioClient + { + public const int MaxBitrate = 128; + + private const string Mode = "xsalsa20_poly1305"; + + private readonly JsonSerializer _serializer; + private readonly IWebSocketClient _gatewayClient; + private readonly SemaphoreSlim _connectionLock; + private CancellationTokenSource _connectCancelToken; + + public ConnectionState ConnectionState { get; private set; } + + internal AudioClient(WebSocketProvider provider, JsonSerializer serializer = null) + { + _connectionLock = new SemaphoreSlim(1, 1); + + _serializer = serializer ?? new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + } + + public Task SendAsync(VoiceOpCode opCode, object payload, RequestOptions options = null) + { + byte[] bytes = null; + payload = new WebSocketMessage { Operation = (int)opCode, Payload = payload }; + if (payload != null) + bytes = Encoding.UTF8.GetBytes(SerializeJson(payload)); + //TODO: Send + return Task.CompletedTask; + } + + //Gateway + public async Task SendHeartbeatAsync(int lastSeq, RequestOptions options = null) + { + await SendAsync(VoiceOpCode.Heartbeat, lastSeq, options: options).ConfigureAwait(false); + } + + + public async Task ConnectAsync(string url) + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await ConnectInternalAsync(url).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task ConnectInternalAsync(string url) + { + ConnectionState = ConnectionState.Connecting; + try + { + _connectCancelToken = new CancellationTokenSource(); + _gatewayClient.SetCancelToken(_connectCancelToken.Token); + await _gatewayClient.ConnectAsync(url).ConfigureAwait(false); + + ConnectionState = ConnectionState.Connected; + } + catch (Exception) + { + await DisconnectInternalAsync().ConfigureAwait(false); + throw; + } + } + + public async Task DisconnectAsync() + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync().ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task DisconnectInternalAsync() + { + if (ConnectionState == ConnectionState.Disconnected) return; + ConnectionState = ConnectionState.Disconnecting; + + try { _connectCancelToken?.Cancel(false); } + catch { } + + await _gatewayClient.DisconnectAsync().ConfigureAwait(false); + + ConnectionState = ConnectionState.Disconnected; + } + + //Helpers + private static double ToMilliseconds(Stopwatch stopwatch) => Math.Round((double)stopwatch.ElapsedTicks / (double)Stopwatch.Frequency * 1000.0, 2); + private string SerializeJson(object value) + { + var sb = new StringBuilder(256); + using (TextWriter text = new StringWriter(sb, CultureInfo.InvariantCulture)) + using (JsonWriter writer = new JsonTextWriter(text)) + _serializer.Serialize(writer, value); + return sb.ToString(); + } + private T DeserializeJson(Stream jsonStream) + { + using (TextReader text = new StreamReader(jsonStream)) + using (JsonReader reader = new JsonTextReader(text)) + return _serializer.Deserialize(reader); + } + } +} diff --git a/src/Discord.Net.Audio/AudioMode.cs b/src/Discord.Net.Audio/AudioMode.cs new file mode 100644 index 000000000..b9acdbf89 --- /dev/null +++ b/src/Discord.Net.Audio/AudioMode.cs @@ -0,0 +1,9 @@ +namespace Discord.Audio +{ + public enum AudioMode : byte + { + Outgoing = 1, + Incoming = 2, + Both = Outgoing | Incoming + } +} diff --git a/src/Discord.Net.Audio/Discord.Net.Audio.xproj b/src/Discord.Net.Audio/Discord.Net.Audio.xproj index 9f9783c6e..7434ce9ff 100644 --- a/src/Discord.Net.Audio/Discord.Net.Audio.xproj +++ b/src/Discord.Net.Audio/Discord.Net.Audio.xproj @@ -7,11 +7,10 @@ ddfcc44f-934e-478a-978c-69cdda2a1c5b - Discord.Net.Audio + Discord.Audio .\obj .\bin\ - 2.0 diff --git a/src/Discord.Net.Audio/LibSodium.cs b/src/Discord.Net.Audio/LibSodium.cs new file mode 100644 index 000000000..3b4129165 --- /dev/null +++ b/src/Discord.Net.Audio/LibSodium.cs @@ -0,0 +1,23 @@ +using System.Runtime.InteropServices; + +namespace Discord.Net.Audio +{ + public unsafe static class LibSodium + { + [DllImport("libsodium", EntryPoint = "crypto_secretbox_easy", CallingConvention = CallingConvention.Cdecl)] + private static extern int SecretBoxEasy(byte* output, byte[] input, long inputLength, byte[] nonce, byte[] secret); + [DllImport("libsodium", EntryPoint = "crypto_secretbox_open_easy", CallingConvention = CallingConvention.Cdecl)] + private static extern int SecretBoxOpenEasy(byte[] output, byte* input, long inputLength, byte[] nonce, byte[] secret); + + public static int Encrypt(byte[] input, long inputLength, byte[] output, int outputOffset, byte[] nonce, byte[] secret) + { + fixed (byte* outPtr = output) + return SecretBoxEasy(outPtr + outputOffset, input, inputLength, nonce, secret); + } + public static int Decrypt(byte[] input, int inputOffset, long inputLength, byte[] output, byte[] nonce, byte[] secret) + { + fixed (byte* inPtr = input) + return SecretBoxOpenEasy(output, inPtr + inputLength, inputLength, nonce, secret); + } + } +} diff --git a/src/Discord.Net.Audio/Opus/Ctl.cs b/src/Discord.Net.Audio/Opus/Ctl.cs new file mode 100644 index 000000000..5023782da --- /dev/null +++ b/src/Discord.Net.Audio/Opus/Ctl.cs @@ -0,0 +1,10 @@ +namespace Discord.Audio.Opus +{ + internal enum Ctl : int + { + SetBitrateRequest = 4002, + GetBitrateRequest = 4003, + SetInbandFECRequest = 4012, + GetInbandFECRequest = 4013 + } +} diff --git a/src/Discord.Net.Audio/Opus/OpusApplication.cs b/src/Discord.Net.Audio/Opus/OpusApplication.cs new file mode 100644 index 000000000..cbaa894a5 --- /dev/null +++ b/src/Discord.Net.Audio/Opus/OpusApplication.cs @@ -0,0 +1,9 @@ +namespace Discord.Audio.Opus +{ + internal enum OpusApplication : int + { + Voice = 2048, + MusicOrMixed = 2049, + LowLatency = 2051 + } +} diff --git a/src/Discord.Net.Audio/Opus/OpusConverter.cs b/src/Discord.Net.Audio/Opus/OpusConverter.cs new file mode 100644 index 000000000..f430d07f7 --- /dev/null +++ b/src/Discord.Net.Audio/Opus/OpusConverter.cs @@ -0,0 +1,41 @@ +using System; + +namespace Discord.Audio.Opus +{ + internal abstract class OpusConverter : IDisposable + { + protected IntPtr _ptr; + + /// Gets the bit rate of this converter. + public const int BitsPerSample = 16; + /// Gets the input sampling rate of this converter. + public int SamplingRate { get; } + + protected OpusConverter(int samplingRate) + { + if (samplingRate != 8000 && samplingRate != 12000 && + samplingRate != 16000 && samplingRate != 24000 && + samplingRate != 48000) + throw new ArgumentOutOfRangeException(nameof(samplingRate)); + + SamplingRate = samplingRate; + } + + private bool disposedValue = false; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + disposedValue = true; + } + ~OpusConverter() + { + Dispose(false); + } + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/Discord.Net.Audio/Opus/OpusDecoder.cs b/src/Discord.Net.Audio/Opus/OpusDecoder.cs new file mode 100644 index 000000000..2df7c2414 --- /dev/null +++ b/src/Discord.Net.Audio/Opus/OpusDecoder.cs @@ -0,0 +1,48 @@ +using System; +using System.Runtime.InteropServices; + +namespace Discord.Audio.Opus +{ + internal unsafe class OpusDecoder : OpusConverter + { + [DllImport("opus", EntryPoint = "opus_decoder_create", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr CreateDecoder(int Fs, int channels, out OpusError error); + [DllImport("opus", EntryPoint = "opus_decoder_destroy", CallingConvention = CallingConvention.Cdecl)] + private static extern void DestroyDecoder(IntPtr decoder); + [DllImport("opus", EntryPoint = "opus_decode", CallingConvention = CallingConvention.Cdecl)] + private static extern int Decode(IntPtr st, byte* data, int len, byte[] pcm, int frame_size, int decode_fec); + + public OpusDecoder(int samplingRate) + : base(samplingRate) + { + OpusError error; + _ptr = CreateDecoder(samplingRate, 2, out error); + if (error != OpusError.OK) + throw new InvalidOperationException($"Error occured while creating decoder: {error}"); + } + + /// Produces PCM samples from Opus-encoded audio. + /// PCM samples to decode. + /// Offset of the frame in input. + /// Buffer to store the decoded frame. + public unsafe int DecodeFrame(byte[] input, int inputOffset, int inputCount, byte[] output) + { + int result = 0; + fixed (byte* inPtr = input) + result = Decode(_ptr, inPtr + inputOffset, inputCount, output, inputCount, 0); + + if (result < 0) + throw new Exception(((OpusError)result).ToString()); + return result; + } + + protected override void Dispose(bool disposing) + { + if (_ptr != IntPtr.Zero) + { + DestroyDecoder(_ptr); + _ptr = IntPtr.Zero; + } + } + } +} diff --git a/src/Discord.Net.Audio/Opus/OpusEncoder.cs b/src/Discord.Net.Audio/Opus/OpusEncoder.cs new file mode 100644 index 000000000..92ac33317 --- /dev/null +++ b/src/Discord.Net.Audio/Opus/OpusEncoder.cs @@ -0,0 +1,100 @@ +using System; +using System.Runtime.InteropServices; + +namespace Discord.Audio.Opus +{ + internal unsafe class OpusEncoder : OpusConverter + { + [DllImport("opus", EntryPoint = "opus_encoder_create", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr CreateEncoder(int Fs, int channels, int application, out OpusError error); + [DllImport("opus", EntryPoint = "opus_encoder_destroy", CallingConvention = CallingConvention.Cdecl)] + private static extern void DestroyEncoder(IntPtr encoder); + [DllImport("opus", EntryPoint = "opus_encode", CallingConvention = CallingConvention.Cdecl)] + private static extern int Encode(IntPtr st, byte* pcm, int frame_size, byte[] data, int max_data_bytes); + [DllImport("opus", EntryPoint = "opus_encoder_ctl", CallingConvention = CallingConvention.Cdecl)] + private static extern int EncoderCtl(IntPtr st, Ctl request, int value); + + /// Gets the bit rate in kbit/s. + public int? BitRate { get; } + /// Gets the coding mode of the encoder. + public OpusApplication Application { get; } + /// Gets the number of channels of this converter. + public int InputChannels { get; } + /// Gets the milliseconds per frame. + public int FrameMilliseconds { get; } + + /// Gets the bytes per sample. + public int SampleSize => (BitsPerSample / 8) * InputChannels; + /// Gets the number of samples per frame. + public int SamplesPerFrame => SamplingRate / 1000 * FrameMilliseconds; + /// Gets the bytes per frame. + public int FrameSize => SamplesPerFrame * SampleSize; + + public OpusEncoder(int samplingRate, int channels, int frameMillis, + int? bitrate = null, OpusApplication application = OpusApplication.MusicOrMixed) + : base(samplingRate) + { + if (channels != 1 && channels != 2) + throw new ArgumentOutOfRangeException(nameof(channels)); + if (bitrate != null && (bitrate < 1 || bitrate > AudioClient.MaxBitrate)) + throw new ArgumentOutOfRangeException(nameof(bitrate)); + + OpusError error; + _ptr = CreateEncoder(samplingRate, channels, (int)application, out error); + if (error != OpusError.OK) + throw new InvalidOperationException($"Error occured while creating encoder: {error}"); + + + BitRate = bitrate; + Application = application; + InputChannels = channels; + FrameMilliseconds = frameMillis; + + SetForwardErrorCorrection(true); + if (bitrate != null) + SetBitrate(bitrate.Value); + } + + + /// Produces Opus encoded audio from PCM samples. + /// PCM samples to encode. + /// Offset of the frame in pcmSamples. + /// Buffer to store the encoded frame. + /// Length of the frame contained in outputBuffer. + public unsafe int EncodeFrame(byte[] input, int inputOffset, byte[] output) + { + int result = 0; + fixed (byte* inPtr = input) + result = Encode(_ptr, inPtr + inputOffset, SamplesPerFrame, output, output.Length); + + if (result < 0) + throw new Exception(((OpusError)result).ToString()); + return result; + } + + /// Gets or sets whether Forward Error Correction is enabled. + public void SetForwardErrorCorrection(bool value) + { + var result = EncoderCtl(_ptr, Ctl.SetInbandFECRequest, value ? 1 : 0); + if (result < 0) + throw new Exception(((OpusError)result).ToString()); + } + + /// Gets or sets whether Forward Error Correction is enabled. + public void SetBitrate(int value) + { + var result = EncoderCtl(_ptr, Ctl.SetBitrateRequest, value * 1000); + if (result < 0) + throw new Exception(((OpusError)result).ToString()); + } + + protected override void Dispose(bool disposing) + { + if (_ptr != IntPtr.Zero) + { + DestroyEncoder(_ptr); + _ptr = IntPtr.Zero; + } + } + } +} diff --git a/src/Discord.Net.Audio/Opus/OpusError.cs b/src/Discord.Net.Audio/Opus/OpusError.cs new file mode 100644 index 000000000..5bfb92d98 --- /dev/null +++ b/src/Discord.Net.Audio/Opus/OpusError.cs @@ -0,0 +1,14 @@ +namespace Discord.Audio.Opus +{ + internal enum OpusError : int + { + OK = 0, + BadArg = -1, + BufferToSmall = -2, + InternalError = -3, + InvalidPacket = -4, + Unimplemented = -5, + InvalidState = -6, + AllocFail = -7 + } +} diff --git a/src/Discord.Net.Audio/project.json b/src/Discord.Net.Audio/project.json index 64992a775..3d9ae74d2 100644 --- a/src/Discord.Net.Audio/project.json +++ b/src/Discord.Net.Audio/project.json @@ -1,4 +1,4 @@ -{ +{ "version": "1.0.0-dev", "description": "A Discord.Net extension adding audio support.", "authors": [ "RogueException" ], @@ -19,7 +19,8 @@ }, "dependencies": { - "Discord.Net": "1.0.0-dev" + "Discord.Net": "1.0.0-dev", + "System.Runtime.InteropServices": "4.1.0-rc2-24027" }, "frameworks": {