@@ -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)' != ''" /> | |||
<PropertyGroup Label="Globals"> | |||
<ProjectGuid>ddfcc44f-934e-478a-978c-69cdda2a1c5b</ProjectGuid> | |||
<RootNamespace>Discord.Net.Audio</RootNamespace> | |||
<RootNamespace>Discord.Audio</RootNamespace> | |||
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath> | |||
<OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath> | |||
</PropertyGroup> | |||
<PropertyGroup> | |||
<SchemaVersion>2.0</SchemaVersion> | |||
</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", | |||
"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": { | |||