| @@ -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<T>(Stream jsonStream) | |||||
| { | |||||
| using (TextReader text = new StreamReader(jsonStream)) | |||||
| using (JsonReader reader = new JsonTextReader(text)) | |||||
| return _serializer.Deserialize<T>(reader); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,9 @@ | |||||
| namespace Discord.Audio | |||||
| { | |||||
| public enum AudioMode : byte | |||||
| { | |||||
| Outgoing = 1, | |||||
| Incoming = 2, | |||||
| Both = Outgoing | Incoming | |||||
| } | |||||
| } | |||||
| @@ -7,11 +7,10 @@ | |||||
| <Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" /> | <Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" /> | ||||
| <PropertyGroup Label="Globals"> | <PropertyGroup Label="Globals"> | ||||
| <ProjectGuid>ddfcc44f-934e-478a-978c-69cdda2a1c5b</ProjectGuid> | <ProjectGuid>ddfcc44f-934e-478a-978c-69cdda2a1c5b</ProjectGuid> | ||||
| <RootNamespace>Discord.Net.Audio</RootNamespace> | |||||
| <RootNamespace>Discord.Audio</RootNamespace> | |||||
| <BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath> | <BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath> | ||||
| <OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath> | <OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath> | ||||
| </PropertyGroup> | </PropertyGroup> | ||||
| <PropertyGroup> | <PropertyGroup> | ||||
| <SchemaVersion>2.0</SchemaVersion> | <SchemaVersion>2.0</SchemaVersion> | ||||
| </PropertyGroup> | </PropertyGroup> | ||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,10 @@ | |||||
| namespace Discord.Audio.Opus | |||||
| { | |||||
| internal enum Ctl : int | |||||
| { | |||||
| SetBitrateRequest = 4002, | |||||
| GetBitrateRequest = 4003, | |||||
| SetInbandFECRequest = 4012, | |||||
| GetInbandFECRequest = 4013 | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,9 @@ | |||||
| namespace Discord.Audio.Opus | |||||
| { | |||||
| internal enum OpusApplication : int | |||||
| { | |||||
| Voice = 2048, | |||||
| MusicOrMixed = 2049, | |||||
| LowLatency = 2051 | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,41 @@ | |||||
| using System; | |||||
| namespace Discord.Audio.Opus | |||||
| { | |||||
| internal abstract class OpusConverter : IDisposable | |||||
| { | |||||
| protected IntPtr _ptr; | |||||
| /// <summary> Gets the bit rate of this converter. </summary> | |||||
| public const int BitsPerSample = 16; | |||||
| /// <summary> Gets the input sampling rate of this converter. </summary> | |||||
| 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); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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}"); | |||||
| } | |||||
| /// <summary> Produces PCM samples from Opus-encoded audio. </summary> | |||||
| /// <param name="input">PCM samples to decode.</param> | |||||
| /// <param name="inputOffset">Offset of the frame in input.</param> | |||||
| /// <param name="output">Buffer to store the decoded frame.</param> | |||||
| 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; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| /// <summary> Gets the bit rate in kbit/s. </summary> | |||||
| public int? BitRate { get; } | |||||
| /// <summary> Gets the coding mode of the encoder. </summary> | |||||
| public OpusApplication Application { get; } | |||||
| /// <summary> Gets the number of channels of this converter. </summary> | |||||
| public int InputChannels { get; } | |||||
| /// <summary> Gets the milliseconds per frame. </summary> | |||||
| public int FrameMilliseconds { get; } | |||||
| /// <summary> Gets the bytes per sample. </summary> | |||||
| public int SampleSize => (BitsPerSample / 8) * InputChannels; | |||||
| /// <summary> Gets the number of samples per frame. </summary> | |||||
| public int SamplesPerFrame => SamplingRate / 1000 * FrameMilliseconds; | |||||
| /// <summary> Gets the bytes per frame. </summary> | |||||
| 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); | |||||
| } | |||||
| /// <summary> Produces Opus encoded audio from PCM samples. </summary> | |||||
| /// <param name="input">PCM samples to encode.</param> | |||||
| /// <param name="inputOffset">Offset of the frame in pcmSamples.</param> | |||||
| /// <param name="output">Buffer to store the encoded frame.</param> | |||||
| /// <returns>Length of the frame contained in outputBuffer.</returns> | |||||
| 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; | |||||
| } | |||||
| /// <summary> Gets or sets whether Forward Error Correction is enabled. </summary> | |||||
| public void SetForwardErrorCorrection(bool value) | |||||
| { | |||||
| var result = EncoderCtl(_ptr, Ctl.SetInbandFECRequest, value ? 1 : 0); | |||||
| if (result < 0) | |||||
| throw new Exception(((OpusError)result).ToString()); | |||||
| } | |||||
| /// <summary> Gets or sets whether Forward Error Correction is enabled. </summary> | |||||
| 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; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -1,4 +1,4 @@ | |||||
| { | |||||
| { | |||||
| "version": "1.0.0-dev", | "version": "1.0.0-dev", | ||||
| "description": "A Discord.Net extension adding audio support.", | "description": "A Discord.Net extension adding audio support.", | ||||
| "authors": [ "RogueException" ], | "authors": [ "RogueException" ], | ||||
| @@ -19,7 +19,8 @@ | |||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| "Discord.Net": "1.0.0-dev" | |||||
| "Discord.Net": "1.0.0-dev", | |||||
| "System.Runtime.InteropServices": "4.1.0-rc2-24027" | |||||
| }, | }, | ||||
| "frameworks": { | "frameworks": { | ||||