Initial commit of audio isolation
This commit is contained in:
95
src/Discord.Net.Audio/API/Voice.cs
Normal file
95
src/Discord.Net.Audio/API/Voice.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
//Ignore unused/unassigned variable warnings
|
||||
#pragma warning disable CS0649
|
||||
#pragma warning disable CS0169
|
||||
|
||||
using Discord.API.Converters;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Discord.API
|
||||
{
|
||||
//Commands
|
||||
internal sealed class VoiceLoginCommand : WebSocketMessage<VoiceLoginCommand.Data>
|
||||
{
|
||||
public VoiceLoginCommand() : base(0) { }
|
||||
public class Data
|
||||
{
|
||||
[JsonProperty("server_id")]
|
||||
[JsonConverter(typeof(LongStringConverter))]
|
||||
public long ServerId;
|
||||
[JsonProperty("user_id")]
|
||||
[JsonConverter(typeof(LongStringConverter))]
|
||||
public long UserId;
|
||||
[JsonProperty("session_id")]
|
||||
public string SessionId;
|
||||
[JsonProperty("token")]
|
||||
public string Token;
|
||||
}
|
||||
}
|
||||
internal sealed class VoiceLogin2Command : WebSocketMessage<VoiceLogin2Command.Data>
|
||||
{
|
||||
public VoiceLogin2Command() : base(1) { }
|
||||
public class Data
|
||||
{
|
||||
public class SocketInfo
|
||||
{
|
||||
[JsonProperty("address")]
|
||||
public string Address;
|
||||
[JsonProperty("port")]
|
||||
public int Port;
|
||||
[JsonProperty("mode")]
|
||||
public string Mode = "xsalsa20_poly1305";
|
||||
}
|
||||
[JsonProperty("protocol")]
|
||||
public string Protocol = "udp";
|
||||
[JsonProperty("data")]
|
||||
public SocketInfo SocketData = new SocketInfo();
|
||||
}
|
||||
}
|
||||
internal sealed class VoiceKeepAliveCommand : WebSocketMessage<long>
|
||||
{
|
||||
public VoiceKeepAliveCommand() : base(3, EpochTime.GetMilliseconds()) { }
|
||||
}
|
||||
internal sealed class IsTalkingCommand : WebSocketMessage<IsTalkingCommand.Data>
|
||||
{
|
||||
public IsTalkingCommand() : base(5) { }
|
||||
public class Data
|
||||
{
|
||||
[JsonProperty("delay")]
|
||||
public int Delay;
|
||||
[JsonProperty("speaking")]
|
||||
public bool IsSpeaking;
|
||||
}
|
||||
}
|
||||
|
||||
//Events
|
||||
public class VoiceReadyEvent
|
||||
{
|
||||
[JsonProperty("ssrc")]
|
||||
public uint SSRC;
|
||||
[JsonProperty("port")]
|
||||
public ushort Port;
|
||||
[JsonProperty("modes")]
|
||||
public string[] Modes;
|
||||
[JsonProperty("heartbeat_interval")]
|
||||
public int HeartbeatInterval;
|
||||
}
|
||||
|
||||
public class JoinServerEvent
|
||||
{
|
||||
[JsonProperty("secret_key")]
|
||||
public byte[] SecretKey;
|
||||
[JsonProperty("mode")]
|
||||
public string Mode;
|
||||
}
|
||||
|
||||
public class IsTalkingEvent
|
||||
{
|
||||
[JsonProperty("user_id")]
|
||||
[JsonConverter(typeof(LongStringConverter))]
|
||||
public long UserId;
|
||||
[JsonProperty("ssrc")]
|
||||
public uint SSRC;
|
||||
[JsonProperty("speaking")]
|
||||
public bool IsSpeaking;
|
||||
}
|
||||
}
|
||||
8
src/Discord.Net.Audio/AudioExtensions.cs
Normal file
8
src/Discord.Net.Audio/AudioExtensions.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Discord.Audio
|
||||
{
|
||||
public static class AudioExtensions
|
||||
{
|
||||
public static AudioService Audio(this DiscordClient client, bool required = true)
|
||||
=> client.GetService<AudioService>(required);
|
||||
}
|
||||
}
|
||||
199
src/Discord.Net.Audio/AudioService.cs
Normal file
199
src/Discord.Net.Audio/AudioService.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using Discord.Net.WebSockets;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Discord.Audio
|
||||
{
|
||||
public class VoiceDisconnectedEventArgs : DisconnectedEventArgs
|
||||
{
|
||||
public readonly long ServerId;
|
||||
|
||||
public VoiceDisconnectedEventArgs(long serverId, DisconnectedEventArgs e)
|
||||
: base(e.WasUnexpected, e.Error)
|
||||
{
|
||||
ServerId = serverId;
|
||||
}
|
||||
}
|
||||
public class UserIsSpeakingEventArgs : UserEventArgs
|
||||
{
|
||||
public readonly bool IsSpeaking;
|
||||
|
||||
public UserIsSpeakingEventArgs(User user, bool isSpeaking)
|
||||
: base(user)
|
||||
{
|
||||
IsSpeaking = isSpeaking;
|
||||
}
|
||||
}
|
||||
public class VoicePacketEventArgs : EventArgs
|
||||
{
|
||||
public readonly long UserId;
|
||||
public readonly long ChannelId;
|
||||
public readonly byte[] Buffer;
|
||||
public readonly int Offset;
|
||||
public readonly int Count;
|
||||
|
||||
public VoicePacketEventArgs(long userId, long channelId, byte[] buffer, int offset, int count)
|
||||
{
|
||||
UserId = userId;
|
||||
ChannelId = channelId;
|
||||
Buffer = buffer;
|
||||
Offset = offset;
|
||||
Count = count;
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioService : IService
|
||||
{
|
||||
private DiscordAudioClient _defaultClient;
|
||||
private ConcurrentDictionary<long, DiscordAudioClient> _voiceClients;
|
||||
private ConcurrentDictionary<User, bool> _talkingUsers;
|
||||
private int _nextClientId;
|
||||
|
||||
internal DiscordClient Client => _client;
|
||||
private DiscordClient _client;
|
||||
|
||||
public AudioServiceConfig Config => _config;
|
||||
private readonly AudioServiceConfig _config;
|
||||
|
||||
public event EventHandler Connected;
|
||||
private void RaiseConnected()
|
||||
{
|
||||
if (Connected != null)
|
||||
Connected(this, EventArgs.Empty);
|
||||
}
|
||||
public event EventHandler<VoiceDisconnectedEventArgs> Disconnected;
|
||||
private void RaiseDisconnected(long serverId, DisconnectedEventArgs e)
|
||||
{
|
||||
if (Disconnected != null)
|
||||
Disconnected(this, new VoiceDisconnectedEventArgs(serverId, e));
|
||||
}
|
||||
public event EventHandler<VoicePacketEventArgs> OnPacket;
|
||||
internal void RaiseOnPacket(VoicePacketEventArgs e)
|
||||
{
|
||||
if (OnPacket != null)
|
||||
OnPacket(this, e);
|
||||
}
|
||||
public event EventHandler<UserIsSpeakingEventArgs> UserIsSpeakingUpdated;
|
||||
private void RaiseUserIsSpeakingUpdated(User user, bool isSpeaking)
|
||||
{
|
||||
if (UserIsSpeakingUpdated != null)
|
||||
UserIsSpeakingUpdated(this, new UserIsSpeakingEventArgs(user, isSpeaking));
|
||||
}
|
||||
|
||||
public AudioService(AudioServiceConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_config.Lock();
|
||||
}
|
||||
public void Install(DiscordClient client)
|
||||
{
|
||||
_client = client;
|
||||
if (Config.EnableMultiserver)
|
||||
_voiceClients = new ConcurrentDictionary<long, DiscordAudioClient>();
|
||||
else
|
||||
{
|
||||
var logger = Client.Log().CreateLogger("Voice");
|
||||
var voiceSocket = new VoiceWebSocket(Client.Config, _config, logger);
|
||||
_defaultClient = new DiscordAudioClient(this, 0, logger, _client.WebSocket, voiceSocket);
|
||||
}
|
||||
_talkingUsers = new ConcurrentDictionary<User, bool>();
|
||||
|
||||
client.Disconnected += async (s, e) =>
|
||||
{
|
||||
if (Config.EnableMultiserver)
|
||||
{
|
||||
var tasks = _voiceClients
|
||||
.Select(x => x.Value.Disconnect())
|
||||
.ToArray();
|
||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
_voiceClients.Clear();
|
||||
}
|
||||
foreach (var member in _talkingUsers)
|
||||
{
|
||||
bool ignored;
|
||||
if (_talkingUsers.TryRemove(member.Key, out ignored))
|
||||
RaiseUserIsSpeakingUpdated(member.Key, false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public DiscordAudioClient GetVoiceClient(Server server)
|
||||
{
|
||||
if (server == null) throw new ArgumentNullException(nameof(server));
|
||||
|
||||
if (!Config.EnableMultiserver)
|
||||
{
|
||||
if (server.Id == _defaultClient.ServerId)
|
||||
return _defaultClient;
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
DiscordAudioClient client;
|
||||
if (_voiceClients.TryGetValue(server.Id, out client))
|
||||
return client;
|
||||
else
|
||||
return null;
|
||||
}
|
||||
private Task<DiscordAudioClient> CreateVoiceClient(Server server)
|
||||
{
|
||||
if (!Config.EnableMultiserver)
|
||||
{
|
||||
_defaultClient.SetServerId(server.Id);
|
||||
return Task.FromResult(_defaultClient);
|
||||
}
|
||||
|
||||
var client = _voiceClients.GetOrAdd(server.Id, _ =>
|
||||
{
|
||||
int id = unchecked(++_nextClientId);
|
||||
var logger = Client.Log().CreateLogger($"Voice #{id}");
|
||||
DataWebSocket dataSocket = null;
|
||||
var voiceSocket = new VoiceWebSocket(Client.Config, _config, logger);
|
||||
var voiceClient = new DiscordAudioClient(this, id, logger, dataSocket, voiceSocket);
|
||||
voiceClient.SetServerId(server.Id);
|
||||
|
||||
voiceSocket.OnPacket += (s, e) =>
|
||||
{
|
||||
RaiseOnPacket(e);
|
||||
};
|
||||
voiceSocket.IsSpeaking += (s, e) =>
|
||||
{
|
||||
var user = Client.GetUser(server, e.UserId);
|
||||
RaiseUserIsSpeakingUpdated(user, e.IsSpeaking);
|
||||
};
|
||||
|
||||
return voiceClient;
|
||||
});
|
||||
//await client.Connect(dataSocket.Host, _client.Token).ConfigureAwait(false);
|
||||
return Task.FromResult(client);
|
||||
}
|
||||
|
||||
public async Task<DiscordAudioClient> JoinVoiceServer(Channel channel)
|
||||
{
|
||||
if (channel == null) throw new ArgumentNullException(nameof(channel));
|
||||
//CheckReady(true);
|
||||
|
||||
var client = await CreateVoiceClient(channel.Server).ConfigureAwait(false);
|
||||
await client.Join(channel).ConfigureAwait(false);
|
||||
return client;
|
||||
}
|
||||
|
||||
public async Task LeaveVoiceServer(Server server)
|
||||
{
|
||||
if (server == null) throw new ArgumentNullException(nameof(server));
|
||||
//CheckReady(true);
|
||||
|
||||
if (Config.EnableMultiserver)
|
||||
{
|
||||
//client.CheckReady();
|
||||
DiscordAudioClient client;
|
||||
if (_voiceClients.TryRemove(server.Id, out client))
|
||||
await client.Disconnect().ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
await _defaultClient.Disconnect().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/Discord.Net.Audio/AudioServiceConfig.cs
Normal file
57
src/Discord.Net.Audio/AudioServiceConfig.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
using System;
|
||||
|
||||
namespace Discord.Audio
|
||||
{
|
||||
public enum AudioMode : byte
|
||||
{
|
||||
Outgoing = 1,
|
||||
Incoming = 2,
|
||||
Both = Outgoing | Incoming
|
||||
}
|
||||
|
||||
public class AudioServiceConfig
|
||||
{
|
||||
/// <summary> Max time in milliseconds to wait for DiscordAudioClient to connect and initialize. </summary>
|
||||
public int ConnectionTimeout { get { return _connectionTimeout; } set { SetValue(ref _connectionTimeout, value); } }
|
||||
private int _connectionTimeout = 30000;
|
||||
|
||||
//Experimental Features
|
||||
/// <summary> (Experimental) Enables the voice websocket and UDP client and specifies how it will be used. </summary>
|
||||
public AudioMode Mode { get { return _mode; } set { SetValue(ref _mode, value); } }
|
||||
private AudioMode _mode = AudioMode.Outgoing;
|
||||
|
||||
/// <summary> (Experimental) Enables the voice websocket and UDP client. This option requires the libsodium .dll or .so be in the local or system folder. </summary>
|
||||
public bool EnableEncryption { get { return _enableEncryption; } set { SetValue(ref _enableEncryption, value); } }
|
||||
private bool _enableEncryption = true;
|
||||
|
||||
/// <summary> (Experimental) Enables the client to be simultaneously connected to multiple channels at once (Discord still limits you to one channel per server). </summary>
|
||||
public bool EnableMultiserver { get { return _enableMultiserver; } set { SetValue(ref _enableMultiserver, value); } }
|
||||
private bool _enableMultiserver = false;
|
||||
|
||||
/// <summary> Gets or sets the max buffer length (in milliseconds) for outgoing voice packets. This value is the target maximum but is not guaranteed, the buffer will often go slightly above this value. </summary>
|
||||
public int BufferLength { get { return _bufferLength; } set { SetValue(ref _bufferLength, value); } }
|
||||
private int _bufferLength = 1000;
|
||||
|
||||
/// <summary> Gets or sets the bitrate used (in kbit/s, between 1 and 512 inclusively) for outgoing voice packets. A null value will use default Opus settings. </summary>
|
||||
public int? Bitrate { get { return _bitrate; } set { SetValue(ref _bitrate, value); } }
|
||||
private int? _bitrate = null;
|
||||
|
||||
//Lock
|
||||
protected bool _isLocked;
|
||||
internal void Lock() { _isLocked = true; }
|
||||
protected void SetValue<T>(ref T storage, T value)
|
||||
{
|
||||
if (_isLocked)
|
||||
throw new InvalidOperationException("Unable to modify a service's configuration after it has been created.");
|
||||
storage = value;
|
||||
}
|
||||
|
||||
public AudioServiceConfig Clone()
|
||||
{
|
||||
var config = MemberwiseClone() as AudioServiceConfig;
|
||||
config._isLocked = false;
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/Discord.Net.Audio/Discord.Net.Audio.xproj
Normal file
21
src/Discord.Net.Audio/Discord.Net.Audio.xproj
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0</VisualStudioVersion>
|
||||
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VSToolsPath)\DNX\Microsoft.DNX.Props" Condition="'$(VSToolsPath)' != ''" />
|
||||
<PropertyGroup Label="Globals">
|
||||
<ProjectGuid>dff7afe3-ca77-4109-bade-b4b49a4f6648</ProjectGuid>
|
||||
<RootNamespace>Discord.Audio</RootNamespace>
|
||||
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">..\..\artifacts\obj\$(MSBuildProjectName)</BaseIntermediateOutputPath>
|
||||
<OutputPath Condition="'$(OutputPath)'=='' ">..\..\artifacts\bin\$(MSBuildProjectName)\</OutputPath>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<SchemaVersion>2.0</SchemaVersion>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<ProduceOutputsOnBuild>True</ProduceOutputsOnBuild>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VSToolsPath)\DNX\Microsoft.DNX.targets" Condition="'$(VSToolsPath)' != ''" />
|
||||
</Project>
|
||||
143
src/Discord.Net.Audio/DiscordAudioClient.cs
Normal file
143
src/Discord.Net.Audio/DiscordAudioClient.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using Discord.Net.WebSockets;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Discord.Audio
|
||||
{
|
||||
public partial class DiscordAudioClient
|
||||
{
|
||||
private readonly int _id;
|
||||
public int Id => _id;
|
||||
|
||||
private readonly AudioService _service;
|
||||
private readonly DataWebSocket _dataSocket;
|
||||
private readonly VoiceWebSocket _voiceSocket;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public long? ServerId => _voiceSocket.ServerId;
|
||||
public long? ChannelId => _voiceSocket.ChannelId;
|
||||
|
||||
public DiscordAudioClient(AudioService service, int id, Logger logger, DataWebSocket dataSocket, VoiceWebSocket voiceSocket)
|
||||
{
|
||||
_service = service;
|
||||
_id = id;
|
||||
_logger = logger;
|
||||
_dataSocket = dataSocket;
|
||||
_voiceSocket = voiceSocket;
|
||||
|
||||
/*_voiceSocket.Connected += (s, e) => RaiseVoiceConnected();
|
||||
_voiceSocket.Disconnected += async (s, e) =>
|
||||
{
|
||||
_voiceSocket.CurrentServerId;
|
||||
if (voiceServerId != null)
|
||||
_dataSocket.SendLeaveVoice(voiceServerId.Value);
|
||||
await _voiceSocket.Disconnect().ConfigureAwait(false);
|
||||
RaiseVoiceDisconnected(socket.CurrentServerId.Value, e);
|
||||
if (e.WasUnexpected)
|
||||
await socket.Reconnect().ConfigureAwait(false);
|
||||
};*/
|
||||
|
||||
/*_voiceSocket.IsSpeaking += (s, e) =>
|
||||
{
|
||||
if (_voiceSocket.State == WebSocketState.Connected)
|
||||
{
|
||||
var user = _users[e.UserId, socket.CurrentServerId];
|
||||
bool value = e.IsSpeaking;
|
||||
if (user.IsSpeaking != value)
|
||||
{
|
||||
user.IsSpeaking = value;
|
||||
var channel = _channels[_voiceSocket.CurrentChannelId];
|
||||
RaiseUserIsSpeaking(user, channel, value);
|
||||
if (Config.TrackActivity)
|
||||
user.UpdateActivity();
|
||||
}
|
||||
}
|
||||
};*/
|
||||
|
||||
/*this.Connected += (s, e) =>
|
||||
{
|
||||
_voiceSocket.ParentCancelToken = _cancelToken;
|
||||
};*/
|
||||
|
||||
_dataSocket.ReceivedEvent += async (s, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (e.Type)
|
||||
{
|
||||
case "VOICE_SERVER_UPDATE":
|
||||
{
|
||||
long serverId = IdConvert.ToLong(e.Payload.Value<string>("guild_id"));
|
||||
|
||||
if (serverId == ServerId)
|
||||
{
|
||||
var client = _service.Client;
|
||||
string token = e.Payload.Value<string>("token");
|
||||
_voiceSocket.Host = "wss://" + e.Payload.Value<string>("endpoint").Split(':')[0];
|
||||
await _voiceSocket.Login(client.CurrentUser.Id, _dataSocket.SessionId, token, client.CancelToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_dataSocket.Logger.Log(LogSeverity.Error, $"Error handling {e.Type} event", ex);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public Task Disconnect()
|
||||
{
|
||||
return _voiceSocket.Disconnect();
|
||||
}
|
||||
|
||||
internal void SetServerId(long serverId)
|
||||
{
|
||||
_voiceSocket.ServerId = serverId;
|
||||
}
|
||||
public async Task Join(Channel channel)
|
||||
{
|
||||
if (channel == null) throw new ArgumentNullException(nameof(channel));
|
||||
long? serverId = channel.Server?.Id;
|
||||
if (serverId != ServerId)
|
||||
throw new InvalidOperationException("Cannot join a channel on a different server than this voice client.");
|
||||
//CheckReady(checkVoice: true);
|
||||
|
||||
await _voiceSocket.Disconnect().ConfigureAwait(false);
|
||||
_voiceSocket.ChannelId = channel.Id;
|
||||
_dataSocket.SendJoinVoice(channel.Server.Id, channel.Id);
|
||||
await _voiceSocket.WaitForConnection(_service.Config.ConnectionTimeout).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary> Sends a PCM frame to the voice server. Will block until space frees up in the outgoing buffer. </summary>
|
||||
/// <param name="data">PCM frame to send. This must be a single or collection of uncompressed 48Kz monochannel 20ms PCM frames. </param>
|
||||
/// <param name="count">Number of bytes in this frame. </param>
|
||||
public void Send(byte[] data, int count)
|
||||
{
|
||||
if (data == null) throw new ArgumentException(nameof(data));
|
||||
if (count < 0) throw new ArgumentOutOfRangeException(nameof(count));
|
||||
//CheckReady(checkVoice: true);
|
||||
|
||||
if (count != 0)
|
||||
_voiceSocket.SendPCMFrames(data, count);
|
||||
}
|
||||
|
||||
/// <summary> Clears the PCM buffer. </summary>
|
||||
public void Clear()
|
||||
{
|
||||
//CheckReady(checkVoice: true);
|
||||
|
||||
_voiceSocket.ClearPCMFrames();
|
||||
}
|
||||
|
||||
/// <summary> Returns a task that completes once the voice output buffer is empty. </summary>
|
||||
public Task Wait()
|
||||
{
|
||||
//CheckReady(checkVoice: true);
|
||||
|
||||
_voiceSocket.WaitForQueue();
|
||||
return TaskHelper.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/Discord.Net.Audio/Net/VoiceWebSocket.Events.cs
Normal file
33
src/Discord.Net.Audio/Net/VoiceWebSocket.Events.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Discord.Audio;
|
||||
using System;
|
||||
|
||||
namespace Discord.Net.WebSockets
|
||||
{
|
||||
internal sealed class IsTalkingEventArgs : EventArgs
|
||||
{
|
||||
public readonly long UserId;
|
||||
public readonly bool IsSpeaking;
|
||||
internal IsTalkingEventArgs(long userId, bool isTalking)
|
||||
{
|
||||
UserId = userId;
|
||||
IsSpeaking = isTalking;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class VoiceWebSocket
|
||||
{
|
||||
internal event EventHandler<IsTalkingEventArgs> IsSpeaking;
|
||||
private void RaiseIsSpeaking(long userId, bool isSpeaking)
|
||||
{
|
||||
if (IsSpeaking != null)
|
||||
IsSpeaking(this, new IsTalkingEventArgs(userId, isSpeaking));
|
||||
}
|
||||
|
||||
internal event EventHandler<VoicePacketEventArgs> OnPacket;
|
||||
internal void RaiseOnPacket(long userId, long channelId, byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (OnPacket != null)
|
||||
OnPacket(this, new VoicePacketEventArgs(userId, channelId, buffer, offset, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
555
src/Discord.Net.Audio/Net/VoiceWebSocket.cs
Normal file
555
src/Discord.Net.Audio/Net/VoiceWebSocket.cs
Normal file
@@ -0,0 +1,555 @@
|
||||
using Discord.API;
|
||||
using Discord.Audio;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Discord.Net.WebSockets
|
||||
{
|
||||
public partial class VoiceWebSocket : WebSocket
|
||||
{
|
||||
public enum OpCodes : byte
|
||||
{
|
||||
Identify = 0,
|
||||
SelectProtocol = 1,
|
||||
Ready = 2,
|
||||
Heartbeat = 3,
|
||||
SessionDescription = 4,
|
||||
Speaking = 5
|
||||
}
|
||||
|
||||
private const int MaxOpusSize = 4000;
|
||||
private const string EncryptedMode = "xsalsa20_poly1305";
|
||||
private const string UnencryptedMode = "plain";
|
||||
|
||||
//private readonly Random _rand;
|
||||
private readonly int _targetAudioBufferLength;
|
||||
private readonly ConcurrentDictionary<uint, OpusDecoder> _decoders;
|
||||
private readonly AudioServiceConfig _audioConfig;
|
||||
private OpusEncoder _encoder;
|
||||
private uint _ssrc;
|
||||
private ConcurrentDictionary<uint, long> _ssrcMapping;
|
||||
|
||||
private VoiceBuffer _sendBuffer;
|
||||
private UdpClient _udp;
|
||||
private IPEndPoint _endpoint;
|
||||
private bool _isEncrypted;
|
||||
private byte[] _secretKey, _encodingBuffer;
|
||||
private ushort _sequence;
|
||||
private long? _serverId, _channelId, _userId;
|
||||
private string _sessionId, _token, _encryptionMode;
|
||||
private int _ping;
|
||||
|
||||
private Thread _sendThread, _receiveThread;
|
||||
|
||||
public long? ServerId { get { return _serverId; } internal set { _serverId = value; } }
|
||||
public long? ChannelId { get { return _channelId; } internal set { _channelId = value; } }
|
||||
public int Ping => _ping;
|
||||
internal VoiceBuffer OutputBuffer => _sendBuffer;
|
||||
|
||||
public VoiceWebSocket(DiscordConfig config, AudioServiceConfig audioConfig, Logger logger)
|
||||
: base(config, logger)
|
||||
{
|
||||
_audioConfig = audioConfig;
|
||||
_decoders = new ConcurrentDictionary<uint, OpusDecoder>();
|
||||
_targetAudioBufferLength = _audioConfig.BufferLength / 20; //20 ms frames
|
||||
_encodingBuffer = new byte[MaxOpusSize];
|
||||
_ssrcMapping = new ConcurrentDictionary<uint, long>();
|
||||
_encoder = new OpusEncoder(48000, 1, 20, _audioConfig.Bitrate, Opus.Application.Audio);
|
||||
_sendBuffer = new VoiceBuffer((int)Math.Ceiling(_audioConfig.BufferLength / (double)_encoder.FrameLength), _encoder.FrameSize);
|
||||
}
|
||||
|
||||
public async Task Login(long userId, string sessionId, string token, CancellationToken cancelToken)
|
||||
{
|
||||
if ((WebSocketState)_state == WebSocketState.Connected)
|
||||
{
|
||||
//Adjust the host and tell the system to reconnect
|
||||
await DisconnectInternal(new Exception("Server transfer occurred."), isUnexpected: false).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_userId = userId;
|
||||
_sessionId = sessionId;
|
||||
_token = token;
|
||||
|
||||
await Start().ConfigureAwait(false);
|
||||
}
|
||||
public async Task Reconnect()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cancelToken = ParentCancelToken.Value;
|
||||
await Task.Delay(_config.ReconnectDelay, cancelToken).ConfigureAwait(false);
|
||||
while (!cancelToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
//This check is needed in case we start a reconnect before the initial login completes
|
||||
if (_state != (int)WebSocketState.Disconnected)
|
||||
await Start().ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Log(LogSeverity.Error, "Reconnect failed", ex);
|
||||
//Net is down? We can keep trying to reconnect until the user runs Disconnect()
|
||||
await Task.Delay(_config.FailedReconnectDelay, cancelToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
protected override IEnumerable<Task> GetTasks()
|
||||
{
|
||||
_udp = new UdpClient(new IPEndPoint(IPAddress.Any, 0));
|
||||
|
||||
VoiceLoginCommand msg = new VoiceLoginCommand();
|
||||
msg.Payload.ServerId = _serverId.Value;
|
||||
msg.Payload.SessionId = _sessionId;
|
||||
msg.Payload.Token = _token;
|
||||
msg.Payload.UserId = _userId.Value;
|
||||
QueueMessage(msg);
|
||||
|
||||
List<Task> tasks = new List<Task>();
|
||||
if ((_audioConfig.Mode & AudioMode.Outgoing) != 0)
|
||||
{
|
||||
_sendThread = new Thread(new ThreadStart(() => SendVoiceAsync(_cancelToken)));
|
||||
_sendThread.IsBackground = true;
|
||||
_sendThread.Start();
|
||||
}
|
||||
|
||||
//This thread is required to establish a connection even if we're outgoing only
|
||||
if ((_audioConfig.Mode & AudioMode.Incoming) != 0)
|
||||
{
|
||||
_receiveThread = new Thread(new ThreadStart(() => ReceiveVoiceAsync(_cancelToken)));
|
||||
_receiveThread.IsBackground = true;
|
||||
_receiveThread.Start();
|
||||
}
|
||||
else //Dont make an OS thread if we only want to capture one packet...
|
||||
tasks.Add(Task.Run(() => ReceiveVoiceAsync(_cancelToken)));
|
||||
|
||||
#if !DOTNET5_4
|
||||
tasks.Add(WatcherAsync());
|
||||
#endif
|
||||
if (tasks.Count > 0)
|
||||
{
|
||||
// We need to combine tasks into one because receiveThread is
|
||||
// supposed to exit early if it's an outgoing-only client
|
||||
// and we dont want the main thread to think we errored
|
||||
var task = Task.WhenAll(tasks);
|
||||
tasks.Clear();
|
||||
tasks.Add(task);
|
||||
}
|
||||
tasks.AddRange(base.GetTasks());
|
||||
|
||||
return new Task[] { Task.WhenAll(tasks.ToArray()) };
|
||||
}
|
||||
protected override Task Stop()
|
||||
{
|
||||
if (_sendThread != null)
|
||||
_sendThread.Join();
|
||||
if (_receiveThread != null)
|
||||
_receiveThread.Join();
|
||||
_sendThread = null;
|
||||
_receiveThread = null;
|
||||
|
||||
OpusDecoder decoder;
|
||||
foreach (var pair in _decoders)
|
||||
{
|
||||
if (_decoders.TryRemove(pair.Key, out decoder))
|
||||
decoder.Dispose();
|
||||
}
|
||||
|
||||
ClearPCMFrames();
|
||||
if (!_wasDisconnectUnexpected)
|
||||
{
|
||||
_userId = null;
|
||||
_sessionId = null;
|
||||
_token = null;
|
||||
}
|
||||
_udp = null;
|
||||
|
||||
return base.Stop();
|
||||
}
|
||||
|
||||
private void ReceiveVoiceAsync(CancellationToken cancelToken)
|
||||
{
|
||||
var closeTask = cancelToken.Wait();
|
||||
try
|
||||
{
|
||||
byte[] packet, decodingBuffer = null, nonce = null, result;
|
||||
int packetLength, resultOffset, resultLength;
|
||||
IPEndPoint endpoint = new IPEndPoint(IPAddress.Any, 0);
|
||||
|
||||
if ((_audioConfig.Mode & AudioMode.Incoming) != 0)
|
||||
{
|
||||
decodingBuffer = new byte[MaxOpusSize];
|
||||
nonce = new byte[24];
|
||||
}
|
||||
|
||||
while (!cancelToken.IsCancellationRequested)
|
||||
{
|
||||
Thread.Sleep(1);
|
||||
if (_udp.Available > 0)
|
||||
{
|
||||
#if !DOTNET5_4
|
||||
packet = _udp.Receive(ref endpoint);
|
||||
#else
|
||||
//TODO: Is this really the only way to end a Receive call in DOTNET5_4?
|
||||
var receiveTask = _udp.ReceiveAsync();
|
||||
var task = Task.WhenAny(closeTask, receiveTask).Result;
|
||||
if (task == closeTask)
|
||||
break;
|
||||
var udpPacket = receiveTask.Result;
|
||||
packet = udpPacket.Buffer;
|
||||
endpoint = udpPacket.RemoteEndPoint;
|
||||
#endif
|
||||
packetLength = packet.Length;
|
||||
|
||||
if (packetLength > 0 && endpoint.Equals(_endpoint))
|
||||
{
|
||||
if (_state != (int)WebSocketState.Connected)
|
||||
{
|
||||
if (packetLength != 70)
|
||||
return;
|
||||
|
||||
int port = packet[68] | packet[69] << 8;
|
||||
string ip = Encoding.UTF8.GetString(packet, 4, 70 - 6).TrimEnd('\0');
|
||||
|
||||
var login2 = new VoiceLogin2Command();
|
||||
login2.Payload.Protocol = "udp";
|
||||
login2.Payload.SocketData.Address = ip;
|
||||
login2.Payload.SocketData.Mode = _encryptionMode;
|
||||
login2.Payload.SocketData.Port = port;
|
||||
QueueMessage(login2);
|
||||
if ((_audioConfig.Mode & AudioMode.Incoming) == 0)
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
//Parse RTP Data
|
||||
if (packetLength < 12) return;
|
||||
if (packet[0] != 0x80) return; //Flags
|
||||
if (packet[1] != 0x78) return; //Payload Type
|
||||
|
||||
ushort sequenceNumber = (ushort)((packet[2] << 8) |
|
||||
packet[3] << 0);
|
||||
uint timestamp = (uint)((packet[4] << 24) |
|
||||
(packet[5] << 16) |
|
||||
(packet[6] << 8) |
|
||||
(packet[7] << 0));
|
||||
uint ssrc = (uint)((packet[8] << 24) |
|
||||
(packet[9] << 16) |
|
||||
(packet[10] << 8) |
|
||||
(packet[11] << 0));
|
||||
|
||||
//Decrypt
|
||||
if (_isEncrypted)
|
||||
{
|
||||
if (packetLength < 28) //12 + 16 (RTP + Poly1305 MAC)
|
||||
return;
|
||||
|
||||
Buffer.BlockCopy(packet, 0, nonce, 0, 12);
|
||||
int ret = Sodium.Decrypt(packet, 12, packetLength - 12, decodingBuffer, nonce, _secretKey);
|
||||
if (ret != 0)
|
||||
continue;
|
||||
result = decodingBuffer;
|
||||
resultOffset = 0;
|
||||
resultLength = packetLength - 28;
|
||||
}
|
||||
else //Plain
|
||||
{
|
||||
result = packet;
|
||||
resultOffset = 12;
|
||||
resultLength = packetLength - 12;
|
||||
}
|
||||
|
||||
/*if (_logLevel >= LogMessageSeverity.Debug)
|
||||
RaiseOnLog(LogMessageSeverity.Debug, $"Received {buffer.Length - 12} bytes.");*/
|
||||
|
||||
long userId;
|
||||
if (_ssrcMapping.TryGetValue(ssrc, out userId))
|
||||
RaiseOnPacket(userId, _channelId.Value, result, resultOffset, resultLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (InvalidOperationException) { } //Includes ObjectDisposedException
|
||||
}
|
||||
|
||||
private void SendVoiceAsync(CancellationToken cancelToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancelToken.IsCancellationRequested && _state != (int)WebSocketState.Connected)
|
||||
Thread.Sleep(1);
|
||||
|
||||
if (cancelToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
byte[] frame = new byte[_encoder.FrameSize];
|
||||
byte[] encodedFrame = new byte[MaxOpusSize];
|
||||
byte[] voicePacket, pingPacket, nonce = null;
|
||||
uint timestamp = 0;
|
||||
double nextTicks = 0.0, nextPingTicks = 0.0;
|
||||
long ticksPerSeconds = Stopwatch.Frequency;
|
||||
double ticksPerMillisecond = Stopwatch.Frequency / 1000.0;
|
||||
double ticksPerFrame = ticksPerMillisecond * _encoder.FrameLength;
|
||||
double spinLockThreshold = 3 * ticksPerMillisecond;
|
||||
uint samplesPerFrame = (uint)_encoder.SamplesPerFrame;
|
||||
Stopwatch sw = Stopwatch.StartNew();
|
||||
|
||||
if (_isEncrypted)
|
||||
{
|
||||
nonce = new byte[24];
|
||||
voicePacket = new byte[MaxOpusSize + 12 + 16];
|
||||
}
|
||||
else
|
||||
voicePacket = new byte[MaxOpusSize + 12];
|
||||
|
||||
pingPacket = new byte[8];
|
||||
pingPacket[0] = 0x80; //Flags;
|
||||
pingPacket[1] = 0xC9; //Payload Type
|
||||
pingPacket[2] = 0x00; //Length
|
||||
pingPacket[3] = 0x01; //Length (1*8 bytes)
|
||||
pingPacket[4] = (byte)((_ssrc >> 24) & 0xFF);
|
||||
pingPacket[5] = (byte)((_ssrc >> 16) & 0xFF);
|
||||
pingPacket[6] = (byte)((_ssrc >> 8) & 0xFF);
|
||||
pingPacket[7] = (byte)((_ssrc >> 0) & 0xFF);
|
||||
if (_isEncrypted)
|
||||
{
|
||||
Buffer.BlockCopy(pingPacket, 0, nonce, 0, 8);
|
||||
int ret = Sodium.Encrypt(pingPacket, 8, encodedFrame, 0, nonce, _secretKey);
|
||||
if (ret != 0)
|
||||
throw new InvalidOperationException("Failed to encrypt ping packet");
|
||||
pingPacket = new byte[pingPacket.Length + 16];
|
||||
Buffer.BlockCopy(encodedFrame, 0, pingPacket, 0, pingPacket.Length);
|
||||
Array.Clear(nonce, 0, nonce.Length);
|
||||
}
|
||||
|
||||
int rtpPacketLength = 0;
|
||||
voicePacket[0] = 0x80; //Flags;
|
||||
voicePacket[1] = 0x78; //Payload Type
|
||||
voicePacket[8] = (byte)((_ssrc >> 24) & 0xFF);
|
||||
voicePacket[9] = (byte)((_ssrc >> 16) & 0xFF);
|
||||
voicePacket[10] = (byte)((_ssrc >> 8) & 0xFF);
|
||||
voicePacket[11] = (byte)((_ssrc >> 0) & 0xFF);
|
||||
|
||||
if (_isEncrypted)
|
||||
Buffer.BlockCopy(voicePacket, 0, nonce, 0, 12);
|
||||
|
||||
bool hasFrame = false;
|
||||
while (!cancelToken.IsCancellationRequested)
|
||||
{
|
||||
if (!hasFrame && _sendBuffer.Pop(frame))
|
||||
{
|
||||
ushort sequence = unchecked(_sequence++);
|
||||
voicePacket[2] = (byte)((sequence >> 8) & 0xFF);
|
||||
voicePacket[3] = (byte)((sequence >> 0) & 0xFF);
|
||||
voicePacket[4] = (byte)((timestamp >> 24) & 0xFF);
|
||||
voicePacket[5] = (byte)((timestamp >> 16) & 0xFF);
|
||||
voicePacket[6] = (byte)((timestamp >> 8) & 0xFF);
|
||||
voicePacket[7] = (byte)((timestamp >> 0) & 0xFF);
|
||||
|
||||
//Encode
|
||||
int encodedLength = _encoder.EncodeFrame(frame, 0, encodedFrame);
|
||||
|
||||
//Encrypt
|
||||
if (_isEncrypted)
|
||||
{
|
||||
Buffer.BlockCopy(voicePacket, 2, nonce, 2, 6); //Update nonce
|
||||
int ret = Sodium.Encrypt(encodedFrame, encodedLength, voicePacket, 12, nonce, _secretKey);
|
||||
if (ret != 0)
|
||||
continue;
|
||||
rtpPacketLength = encodedLength + 12 + 16;
|
||||
}
|
||||
else
|
||||
{
|
||||
Buffer.BlockCopy(encodedFrame, 0, voicePacket, 12, encodedLength);
|
||||
rtpPacketLength = encodedLength + 12;
|
||||
}
|
||||
|
||||
timestamp = unchecked(timestamp + samplesPerFrame);
|
||||
hasFrame = true;
|
||||
}
|
||||
|
||||
long currentTicks = sw.ElapsedTicks;
|
||||
double ticksToNextFrame = nextTicks - currentTicks;
|
||||
if (ticksToNextFrame <= 0.0)
|
||||
{
|
||||
if (hasFrame)
|
||||
{
|
||||
try
|
||||
{
|
||||
_udp.Send(voicePacket, rtpPacketLength);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
_logger.Log(LogSeverity.Error, "Failed to send UDP packet.", ex);
|
||||
}
|
||||
hasFrame = false;
|
||||
}
|
||||
nextTicks += ticksPerFrame;
|
||||
|
||||
//Is it time to send out another ping?
|
||||
if (currentTicks > nextPingTicks)
|
||||
{
|
||||
_udp.Send(pingPacket, pingPacket.Length);
|
||||
nextPingTicks = currentTicks + 5 * ticksPerSeconds;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (hasFrame)
|
||||
{
|
||||
int time = (int)Math.Floor(ticksToNextFrame / ticksPerMillisecond);
|
||||
if (time > 0)
|
||||
Thread.Sleep(time);
|
||||
}
|
||||
else
|
||||
Thread.Sleep(1); //Give as much time to the encrypter as possible
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (InvalidOperationException) { } //Includes ObjectDisposedException
|
||||
}
|
||||
#if !DOTNET5_4
|
||||
//Closes the UDP socket when _disconnectToken is triggered, since UDPClient doesn't allow passing a canceltoken
|
||||
private Task WatcherAsync()
|
||||
{
|
||||
var cancelToken = _cancelToken;
|
||||
return cancelToken.Wait()
|
||||
.ContinueWith(_ => _udp.Close());
|
||||
}
|
||||
#endif
|
||||
|
||||
protected override async Task ProcessMessage(string json)
|
||||
{
|
||||
await base.ProcessMessage(json).ConfigureAwait(false);
|
||||
var msg = JsonConvert.DeserializeObject<WebSocketMessage>(json);
|
||||
var opCode = (OpCodes)msg.Operation;
|
||||
switch (opCode)
|
||||
{
|
||||
case OpCodes.Ready:
|
||||
{
|
||||
if (_state != (int)WebSocketState.Connected)
|
||||
{
|
||||
var payload = (msg.Payload as JToken).ToObject<VoiceReadyEvent>(_serializer);
|
||||
_heartbeatInterval = payload.HeartbeatInterval;
|
||||
_ssrc = payload.SSRC;
|
||||
_endpoint = new IPEndPoint((await Dns.GetHostAddressesAsync(Host.Replace("wss://", "")).ConfigureAwait(false)).FirstOrDefault(), payload.Port);
|
||||
|
||||
if (_audioConfig.EnableEncryption)
|
||||
{
|
||||
if (payload.Modes.Contains(EncryptedMode))
|
||||
{
|
||||
_encryptionMode = EncryptedMode;
|
||||
_isEncrypted = true;
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException("Unexpected encryption format.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_encryptionMode = UnencryptedMode;
|
||||
_isEncrypted = false;
|
||||
}
|
||||
_udp.Connect(_endpoint);
|
||||
|
||||
_sequence = 0;// (ushort)_rand.Next(0, ushort.MaxValue);
|
||||
//No thread issue here because SendAsync doesn't start until _isReady is true
|
||||
byte[] packet = new byte[70];
|
||||
packet[0] = (byte)((_ssrc >> 24) & 0xFF);
|
||||
packet[1] = (byte)((_ssrc >> 16) & 0xFF);
|
||||
packet[2] = (byte)((_ssrc >> 8) & 0xFF);
|
||||
packet[3] = (byte)((_ssrc >> 0) & 0xFF);
|
||||
await _udp.SendAsync(packet, 70).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case OpCodes.Heartbeat:
|
||||
{
|
||||
long time = EpochTime.GetMilliseconds();
|
||||
var payload = (long)msg.Payload;
|
||||
_ping = (int)(payload - time);
|
||||
//TODO: Use this to estimate latency
|
||||
}
|
||||
break;
|
||||
case OpCodes.SessionDescription:
|
||||
{
|
||||
var payload = (msg.Payload as JToken).ToObject<JoinServerEvent>(_serializer);
|
||||
_secretKey = payload.SecretKey;
|
||||
SendIsTalking(true);
|
||||
EndConnect();
|
||||
}
|
||||
break;
|
||||
case OpCodes.Speaking:
|
||||
{
|
||||
var payload = (msg.Payload as JToken).ToObject<IsTalkingEvent>(_serializer);
|
||||
RaiseIsSpeaking(payload.UserId, payload.IsSpeaking);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (_logger.Level >= LogSeverity.Warning)
|
||||
_logger.Log(LogSeverity.Warning, $"Unknown Opcode: {opCode}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void SendPCMFrames(byte[] data, int bytes)
|
||||
{
|
||||
_sendBuffer.Push(data, bytes, _cancelToken);
|
||||
}
|
||||
public void ClearPCMFrames()
|
||||
{
|
||||
_sendBuffer.Clear(_cancelToken);
|
||||
}
|
||||
|
||||
private void SendIsTalking(bool value)
|
||||
{
|
||||
var isTalking = new IsTalkingCommand();
|
||||
isTalking.Payload.IsSpeaking = value;
|
||||
isTalking.Payload.Delay = 0;
|
||||
QueueMessage(isTalking);
|
||||
}
|
||||
|
||||
protected override object GetKeepAlive()
|
||||
{
|
||||
return new VoiceKeepAliveCommand();
|
||||
}
|
||||
|
||||
public void WaitForQueue()
|
||||
{
|
||||
_sendBuffer.Wait(_cancelToken);
|
||||
}
|
||||
public Task WaitForConnection(int timeout)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_connectedEvent.Wait(timeout, _cancelToken))
|
||||
throw new TimeoutException();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ThrowError();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/Discord.Net.Audio/Opus.cs
Normal file
71
src/Discord.Net.Audio/Opus.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Discord.Audio
|
||||
{
|
||||
internal unsafe static class Opus
|
||||
{
|
||||
[DllImport("opus", EntryPoint = "opus_encoder_create", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern IntPtr CreateEncoder(int Fs, int channels, int application, out Error error);
|
||||
[DllImport("opus", EntryPoint = "opus_encoder_destroy", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void DestroyEncoder(IntPtr encoder);
|
||||
[DllImport("opus", EntryPoint = "opus_encode", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern int Encode(IntPtr st, byte* pcm, int frame_size, byte[] data, int max_data_bytes);
|
||||
|
||||
[DllImport("opus", EntryPoint = "opus_decoder_create", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern IntPtr CreateDecoder(int Fs, int channels, out Error error);
|
||||
[DllImport("opus", EntryPoint = "opus_decoder_destroy", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void DestroyDecoder(IntPtr decoder);
|
||||
[DllImport("opus", EntryPoint = "opus_decode", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern int Decode(IntPtr st, byte* data, int len, byte[] pcm, int frame_size, int decode_fec);
|
||||
|
||||
[DllImport("opus", EntryPoint = "opus_encoder_ctl", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern int EncoderCtl(IntPtr st, Ctl request, int value);
|
||||
|
||||
public enum Ctl : int
|
||||
{
|
||||
SetBitrateRequest = 4002,
|
||||
GetBitrateRequest = 4003,
|
||||
SetInbandFECRequest = 4012,
|
||||
GetInbandFECRequest = 4013
|
||||
}
|
||||
|
||||
/// <summary>Supported coding modes.</summary>
|
||||
public enum Application : int
|
||||
{
|
||||
/// <summary>
|
||||
/// Gives best quality at a given bitrate for voice signals. It enhances the input signal by high-pass filtering and emphasizing formants and harmonics.
|
||||
/// Optionally it includes in-band forward error correction to protect against packet loss. Use this mode for typical VoIP applications.
|
||||
/// Because of the enhancement, even at high bitrates the output may sound different from the input.
|
||||
/// </summary>
|
||||
Voip = 2048,
|
||||
/// <summary>
|
||||
/// Gives best quality at a given bitrate for most non-voice signals like music.
|
||||
/// Use this mode for music and mixed (music/voice) content, broadcast, and applications requiring less than 15 ms of coding delay.
|
||||
/// </summary>
|
||||
Audio = 2049,
|
||||
/// <summary> Low-delay mode that disables the speech-optimized mode in exchange for slightly reduced delay. </summary>
|
||||
Restricted_LowLatency = 2051
|
||||
}
|
||||
|
||||
public enum Error : int
|
||||
{
|
||||
/// <summary> No error. </summary>
|
||||
OK = 0,
|
||||
/// <summary> One or more invalid/out of range arguments. </summary>
|
||||
BadArg = -1,
|
||||
/// <summary> The mode struct passed is invalid. </summary>
|
||||
BufferToSmall = -2,
|
||||
/// <summary> An internal error was detected. </summary>
|
||||
InternalError = -3,
|
||||
/// <summary> The compressed data passed is corrupted. </summary>
|
||||
InvalidPacket = -4,
|
||||
/// <summary> Invalid/unsupported request number. </summary>
|
||||
Unimplemented = -5,
|
||||
/// <summary> An encoder or decoder structure is invalid or already freed. </summary>
|
||||
InvalidState = -6,
|
||||
/// <summary> Memory allocation has failed. </summary>
|
||||
AllocFail = -7
|
||||
}
|
||||
}
|
||||
}
|
||||
105
src/Discord.Net.Audio/OpusDecoder.cs
Normal file
105
src/Discord.Net.Audio/OpusDecoder.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
|
||||
namespace Discord.Audio
|
||||
{
|
||||
/// <summary> Opus codec wrapper. </summary>
|
||||
internal class OpusDecoder : IDisposable
|
||||
{
|
||||
private readonly IntPtr _ptr;
|
||||
|
||||
/// <summary> Gets the bit rate of the encoder. </summary>
|
||||
public const int BitRate = 16;
|
||||
/// <summary> Gets the input sampling rate of the encoder. </summary>
|
||||
public int InputSamplingRate { get; private set; }
|
||||
/// <summary> Gets the number of channels of the encoder. </summary>
|
||||
public int InputChannels { get; private set; }
|
||||
/// <summary> Gets the milliseconds per frame. </summary>
|
||||
public int FrameLength { get; private set; }
|
||||
/// <summary> Gets the number of samples per frame. </summary>
|
||||
public int SamplesPerFrame { get; private set; }
|
||||
/// <summary> Gets the bytes per sample. </summary>
|
||||
public int SampleSize { get; private set; }
|
||||
/// <summary> Gets the bytes per frame. </summary>
|
||||
public int FrameSize { get; private set; }
|
||||
|
||||
/// <summary> Creates a new Opus encoder. </summary>
|
||||
/// <param name="samplingRate">Sampling rate of the input signal (Hz). Supported Values: 8000, 12000, 16000, 24000, or 48000.</param>
|
||||
/// <param name="channels">Number of channels (1 or 2) in input signal.</param>
|
||||
/// <param name="frameLength">Length, in milliseconds, that each frame takes. Supported Values: 2.5, 5, 10, 20, 40, 60</param>
|
||||
/// <param name="application">Coding mode.</param>
|
||||
/// <returns>A new <c>OpusEncoder</c></returns>
|
||||
public OpusDecoder(int samplingRate, int channels, int frameLength)
|
||||
{
|
||||
if (samplingRate != 8000 && samplingRate != 12000 &&
|
||||
samplingRate != 16000 && samplingRate != 24000 &&
|
||||
samplingRate != 48000)
|
||||
throw new ArgumentOutOfRangeException(nameof(samplingRate));
|
||||
if (channels != 1 && channels != 2)
|
||||
throw new ArgumentOutOfRangeException(nameof(channels));
|
||||
|
||||
InputSamplingRate = samplingRate;
|
||||
InputChannels = channels;
|
||||
FrameLength = frameLength;
|
||||
SampleSize = (BitRate / 8) * channels;
|
||||
SamplesPerFrame = samplingRate / 1000 * FrameLength;
|
||||
FrameSize = SamplesPerFrame * SampleSize;
|
||||
|
||||
Opus.Error error;
|
||||
_ptr = Opus.CreateDecoder(samplingRate, channels, out error);
|
||||
if (error != Opus.Error.OK)
|
||||
throw new InvalidOperationException($"Error occured while creating decoder: {error}");
|
||||
|
||||
SetForwardErrorCorrection(true);
|
||||
}
|
||||
|
||||
/// <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 DecodeFrame(byte[] input, int inputOffset, byte[] output)
|
||||
{
|
||||
if (disposed)
|
||||
throw new ObjectDisposedException(nameof(OpusDecoder));
|
||||
|
||||
int result = 0;
|
||||
fixed (byte* inPtr = input)
|
||||
result = Opus.Encode(_ptr, inPtr + inputOffset, SamplesPerFrame, output, output.Length);
|
||||
|
||||
if (result < 0)
|
||||
throw new Exception("Decoding failed: " + ((Opus.Error)result).ToString());
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary> Gets or sets whether Forward Error Correction is enabled. </summary>
|
||||
public void SetForwardErrorCorrection(bool value)
|
||||
{
|
||||
if (disposed)
|
||||
throw new ObjectDisposedException(nameof(OpusDecoder));
|
||||
|
||||
var result = Opus.EncoderCtl(_ptr, Opus.Ctl.SetInbandFECRequest, value ? 1 : 0);
|
||||
if (result < 0)
|
||||
throw new Exception("Decoder error: " + ((Opus.Error)result).ToString());
|
||||
}
|
||||
|
||||
#region IDisposable
|
||||
private bool disposed;
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
return;
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
|
||||
if (_ptr != IntPtr.Zero)
|
||||
Opus.DestroyEncoder(_ptr);
|
||||
|
||||
disposed = true;
|
||||
}
|
||||
~OpusDecoder()
|
||||
{
|
||||
Dispose();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
127
src/Discord.Net.Audio/OpusEncoder.cs
Normal file
127
src/Discord.Net.Audio/OpusEncoder.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
|
||||
namespace Discord.Audio
|
||||
{
|
||||
/// <summary> Opus codec wrapper. </summary>
|
||||
internal class OpusEncoder : IDisposable
|
||||
{
|
||||
private readonly IntPtr _ptr;
|
||||
|
||||
/// <summary> Gets the bit rate of the encoder. </summary>
|
||||
public const int BitsPerSample = 16;
|
||||
/// <summary> Gets the input sampling rate of the encoder. </summary>
|
||||
public int InputSamplingRate { get; private set; }
|
||||
/// <summary> Gets the number of channels of the encoder. </summary>
|
||||
public int InputChannels { get; private set; }
|
||||
/// <summary> Gets the milliseconds per frame. </summary>
|
||||
public int FrameLength { get; private set; }
|
||||
/// <summary> Gets the number of samples per frame. </summary>
|
||||
public int SamplesPerFrame { get; private set; }
|
||||
/// <summary> Gets the bytes per sample. </summary>
|
||||
public int SampleSize { get; private set; }
|
||||
/// <summary> Gets the bytes per frame. </summary>
|
||||
public int FrameSize { get; private set; }
|
||||
/// <summary> Gets the bit rate in kbit/s. </summary>
|
||||
public int? BitRate { get; private set; }
|
||||
/// <summary> Gets the coding mode of the encoder. </summary>
|
||||
public Opus.Application Application { get; private set; }
|
||||
|
||||
/// <summary> Creates a new Opus encoder. </summary>
|
||||
/// <param name="samplingRate">Sampling rate of the input signal (Hz). Supported Values: 8000, 12000, 16000, 24000, or 48000.</param>
|
||||
/// <param name="channels">Number of channels (1 or 2) in input signal.</param>
|
||||
/// <param name="frameLength">Length, in milliseconds, that each frame takes. Supported Values: 2.5, 5, 10, 20, 40, 60</param>
|
||||
/// <param name="bitrate">Bitrate (kbit/s) used for this encoder. Supported Values: 1-512. Null will use the recommended bitrate. </param>
|
||||
/// <param name="application">Coding mode.</param>
|
||||
/// <returns>A new <c>OpusEncoder</c></returns>
|
||||
public OpusEncoder(int samplingRate, int channels, int frameLength, int? bitrate, Opus.Application application)
|
||||
{
|
||||
if (samplingRate != 8000 && samplingRate != 12000 &&
|
||||
samplingRate != 16000 && samplingRate != 24000 &&
|
||||
samplingRate != 48000)
|
||||
throw new ArgumentOutOfRangeException(nameof(samplingRate));
|
||||
if (channels != 1 && channels != 2)
|
||||
throw new ArgumentOutOfRangeException(nameof(channels));
|
||||
if (bitrate != null && (bitrate < 1 || bitrate > 512))
|
||||
throw new ArgumentOutOfRangeException(nameof(bitrate));
|
||||
|
||||
InputSamplingRate = samplingRate;
|
||||
InputChannels = channels;
|
||||
Application = application;
|
||||
FrameLength = frameLength;
|
||||
SampleSize = (BitsPerSample / 8) * channels;
|
||||
SamplesPerFrame = samplingRate / 1000 * FrameLength;
|
||||
FrameSize = SamplesPerFrame * SampleSize;
|
||||
BitRate = bitrate;
|
||||
|
||||
Opus.Error error;
|
||||
_ptr = Opus.CreateEncoder(samplingRate, channels, (int)application, out error);
|
||||
if (error != Opus.Error.OK)
|
||||
throw new InvalidOperationException($"Error occured while creating encoder: {error}");
|
||||
|
||||
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)
|
||||
{
|
||||
if (disposed)
|
||||
throw new ObjectDisposedException(nameof(OpusEncoder));
|
||||
|
||||
int result = 0;
|
||||
fixed (byte* inPtr = input)
|
||||
result = Opus.Encode(_ptr, inPtr + inputOffset, SamplesPerFrame, output, output.Length);
|
||||
|
||||
if (result < 0)
|
||||
throw new Exception("Encoding failed: " + ((Opus.Error)result).ToString());
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary> Gets or sets whether Forward Error Correction is enabled. </summary>
|
||||
public void SetForwardErrorCorrection(bool value)
|
||||
{
|
||||
if (disposed)
|
||||
throw new ObjectDisposedException(nameof(OpusEncoder));
|
||||
|
||||
var result = Opus.EncoderCtl(_ptr, Opus.Ctl.SetInbandFECRequest, value ? 1 : 0);
|
||||
if (result < 0)
|
||||
throw new Exception("Encoder error: " + ((Opus.Error)result).ToString());
|
||||
}
|
||||
|
||||
/// <summary> Gets or sets whether Forward Error Correction is enabled. </summary>
|
||||
public void SetBitrate(int value)
|
||||
{
|
||||
if (disposed)
|
||||
throw new ObjectDisposedException(nameof(OpusEncoder));
|
||||
|
||||
var result = Opus.EncoderCtl(_ptr, Opus.Ctl.SetBitrateRequest, value * 1000);
|
||||
if (result < 0)
|
||||
throw new Exception("Encoder error: " + ((Opus.Error)result).ToString());
|
||||
}
|
||||
|
||||
#region IDisposable
|
||||
private bool disposed;
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
return;
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
|
||||
if (_ptr != IntPtr.Zero)
|
||||
Opus.DestroyEncoder(_ptr);
|
||||
|
||||
disposed = true;
|
||||
}
|
||||
~OpusEncoder()
|
||||
{
|
||||
Dispose();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
26
src/Discord.Net.Audio/Sodium.cs
Normal file
26
src/Discord.Net.Audio/Sodium.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Discord.Audio
|
||||
{
|
||||
internal unsafe static class Sodium
|
||||
{
|
||||
[DllImport("libsodium", EntryPoint = "crypto_secretbox_easy", CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern int SecretBoxEasy(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);
|
||||
}
|
||||
|
||||
|
||||
[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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/Discord.Net.Audio/VoiceBuffer.cs
Normal file
138
src/Discord.Net.Audio/VoiceBuffer.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace Discord.Audio
|
||||
{
|
||||
internal class VoiceBuffer
|
||||
{
|
||||
private readonly int _frameSize, _frameCount, _bufferSize;
|
||||
private readonly byte[] _buffer;
|
||||
private readonly byte[] _blankFrame;
|
||||
private ushort _readCursor, _writeCursor;
|
||||
private ManualResetEventSlim _notOverflowEvent;
|
||||
private bool _isClearing;
|
||||
|
||||
public int FrameSize => _frameSize;
|
||||
public int FrameCount => _frameCount;
|
||||
public ushort ReadPos => _readCursor;
|
||||
public ushort WritePos => _readCursor;
|
||||
|
||||
public VoiceBuffer(int frameCount, int frameSize)
|
||||
{
|
||||
_frameSize = frameSize;
|
||||
_frameCount = frameCount;
|
||||
_bufferSize = _frameSize * _frameCount;
|
||||
_readCursor = 0;
|
||||
_writeCursor = 0;
|
||||
_buffer = new byte[_bufferSize];
|
||||
_blankFrame = new byte[_frameSize];
|
||||
_notOverflowEvent = new ManualResetEventSlim(); //Notifies when an overflow is solved
|
||||
}
|
||||
|
||||
public void Push(byte[] buffer, int bytes, CancellationToken cancelToken)
|
||||
{
|
||||
if (cancelToken.IsCancellationRequested)
|
||||
throw new OperationCanceledException("Client is disconnected.", cancelToken);
|
||||
|
||||
int wholeFrames = bytes / _frameSize;
|
||||
int expectedBytes = wholeFrames * _frameSize;
|
||||
int lastFrameSize = bytes - expectedBytes;
|
||||
|
||||
lock (this)
|
||||
{
|
||||
for (int i = 0, pos = 0; i <= wholeFrames; i++, pos += _frameSize)
|
||||
{
|
||||
//If the read cursor is in the next position, wait for it to move.
|
||||
ushort nextPosition = _writeCursor;
|
||||
AdvanceCursorPos(ref nextPosition);
|
||||
if (_readCursor == nextPosition)
|
||||
{
|
||||
_notOverflowEvent.Reset();
|
||||
try
|
||||
{
|
||||
_notOverflowEvent.Wait(cancelToken);
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
throw new OperationCanceledException("Client is disconnected.", ex, cancelToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (i == wholeFrames)
|
||||
{
|
||||
//If there are no partial frames, skip this step
|
||||
if (lastFrameSize == 0)
|
||||
break;
|
||||
|
||||
//Copy partial frame
|
||||
Buffer.BlockCopy(buffer, pos, _buffer, _writeCursor * _frameSize, lastFrameSize);
|
||||
|
||||
//Wipe the end of the buffer
|
||||
Buffer.BlockCopy(_blankFrame, 0, _buffer, _writeCursor * _frameSize + lastFrameSize, _frameSize - lastFrameSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
//Copy full frame
|
||||
Buffer.BlockCopy(buffer, pos, _buffer, _writeCursor * _frameSize, _frameSize);
|
||||
}
|
||||
|
||||
//Advance the write cursor to the next position
|
||||
AdvanceCursorPos(ref _writeCursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool Pop(byte[] buffer)
|
||||
{
|
||||
if (_writeCursor == _readCursor)
|
||||
{
|
||||
_notOverflowEvent.Set();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isClearing = _isClearing;
|
||||
if (!isClearing)
|
||||
Buffer.BlockCopy(_buffer, _readCursor * _frameSize, buffer, 0, _frameSize);
|
||||
|
||||
//Advance the read cursor to the next position
|
||||
AdvanceCursorPos(ref _readCursor);
|
||||
_notOverflowEvent.Set();
|
||||
return !isClearing;
|
||||
}
|
||||
|
||||
public void Clear(CancellationToken cancelToken)
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
_isClearing = true;
|
||||
for (int i = 0; i < _frameCount; i++)
|
||||
Buffer.BlockCopy(_blankFrame, 0, _buffer, i * _frameCount, i++);
|
||||
try
|
||||
{
|
||||
Wait(cancelToken);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
_writeCursor = 0;
|
||||
_readCursor = 0;
|
||||
_isClearing = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Wait(CancellationToken cancelToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
_notOverflowEvent.Wait(cancelToken);
|
||||
if (_writeCursor == _readCursor)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void AdvanceCursorPos(ref ushort pos)
|
||||
{
|
||||
pos++;
|
||||
if (pos == _frameCount)
|
||||
pos = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/Discord.Net.Audio/libsodium.dll
Normal file
BIN
src/Discord.Net.Audio/libsodium.dll
Normal file
Binary file not shown.
BIN
src/Discord.Net.Audio/opus.dll
Normal file
BIN
src/Discord.Net.Audio/opus.dll
Normal file
Binary file not shown.
27
src/Discord.Net.Audio/project.json
Normal file
27
src/Discord.Net.Audio/project.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"version": "0.9.0-alpha1",
|
||||
"description": "A Discord.Net extension adding voice support.",
|
||||
"authors": [ "RogueException" ],
|
||||
"tags": [ "discord", "discordapp" ],
|
||||
"projectUrl": "https://github.com/RogueException/Discord.Net",
|
||||
"licenseUrl": "http://opensource.org/licenses/MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/RogueException/Discord.Net"
|
||||
},
|
||||
"compile": [ "**/*.cs", "../Discord.Net/Helpers/Shared/*.cs" ],
|
||||
"contentFiles": [ "libsodium.dll", "opus.dll" ],
|
||||
|
||||
"compilationOptions": {
|
||||
"warningsAsErrors": true,
|
||||
"allowUnsafe": true
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"Discord.Net": "0.9.0-alpha1"
|
||||
},
|
||||
"frameworks": {
|
||||
"net45": { },
|
||||
"dotnet5.4": { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user