@@ -0,0 +1,9 @@ | |||||
namespace Discord.Audio | |||||
{ | |||||
public enum AudioApplication : int | |||||
{ | |||||
Voice, | |||||
Music, | |||||
Mixed | |||||
} | |||||
} |
@@ -1,8 +1,30 @@ | |||||
using System.IO; | |||||
using System; | |||||
using System.IO; | |||||
using System.Threading; | |||||
namespace Discord.Audio | namespace Discord.Audio | ||||
{ | { | ||||
public abstract class AudioInStream : Stream | public abstract class AudioInStream : Stream | ||||
{ | { | ||||
public override bool CanRead => true; | |||||
public override bool CanSeek => false; | |||||
public override bool CanWrite => true; | |||||
public override void Write(byte[] buffer, int offset, int count) | |||||
{ | |||||
WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); | |||||
} | |||||
public override void Flush() { throw new NotSupportedException(); } | |||||
public override long Length { get { throw new NotSupportedException(); } } | |||||
public override long Position | |||||
{ | |||||
get { throw new NotSupportedException(); } | |||||
set { throw new NotSupportedException(); } | |||||
} | |||||
public override void SetLength(long value) { throw new NotSupportedException(); } | |||||
public override long Seek(long offset, SeekOrigin origin) { throw new NotSupportedException(); } | |||||
} | } | ||||
} | } |
@@ -1,4 +1,5 @@ | |||||
using System.IO; | |||||
using System; | |||||
using System.IO; | |||||
using System.Threading; | using System.Threading; | ||||
using System.Threading.Tasks; | using System.Threading.Tasks; | ||||
@@ -10,7 +11,31 @@ namespace Discord.Audio | |||||
public override bool CanSeek => false; | public override bool CanSeek => false; | ||||
public override bool CanWrite => true; | public override bool CanWrite => true; | ||||
public virtual void Clear() { } | |||||
public virtual Task ClearAsync(CancellationToken cancelToken) { return Task.Delay(0); } | |||||
public override void Write(byte[] buffer, int offset, int count) | |||||
{ | |||||
WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); | |||||
} | |||||
public override void Flush() | |||||
{ | |||||
FlushAsync(CancellationToken.None).GetAwaiter().GetResult(); | |||||
} | |||||
public void Clear() | |||||
{ | |||||
ClearAsync(CancellationToken.None).GetAwaiter().GetResult(); | |||||
} | |||||
public virtual Task ClearAsync(CancellationToken cancellationToken) { return Task.Delay(0); } | |||||
//public virtual Task WriteSilenceAsync(CancellationToken cancellationToken) { return Task.Delay(0); } | |||||
public override long Length { get { throw new NotSupportedException(); } } | |||||
public override long Position | |||||
{ | |||||
get { throw new NotSupportedException(); } | |||||
set { throw new NotSupportedException(); } | |||||
} | |||||
public override int Read(byte[] buffer, int offset, int count) { throw new NotSupportedException(); } | |||||
public override void SetLength(long value) { throw new NotSupportedException(); } | |||||
public override long Seek(long offset, SeekOrigin origin) { throw new NotSupportedException(); } | |||||
} | } | ||||
} | } |
@@ -34,13 +34,13 @@ namespace Discord.Audio | |||||
/// <param name="samplesPerFrame">Samples per frame. Must be 120, 240, 480, 960, 1920 or 2880, representing 2.5, 5, 10, 20, 40 or 60 milliseconds respectively.</param> | /// <param name="samplesPerFrame">Samples per frame. Must be 120, 240, 480, 960, 1920 or 2880, representing 2.5, 5, 10, 20, 40 or 60 milliseconds respectively.</param> | ||||
/// <param name="bitrate"></param> | /// <param name="bitrate"></param> | ||||
/// <returns></returns> | /// <returns></returns> | ||||
AudioOutStream CreatePCMStream(int samplesPerFrame, int channels = 2, int? bitrate = null, int bufferMillis = 1000); | |||||
AudioOutStream CreatePCMStream(AudioApplication application, int samplesPerFrame, int channels = 2, int? bitrate = null, int bufferMillis = 1000); | |||||
/// <summary> | /// <summary> | ||||
/// Creates a new direct outgoing stream accepting PCM (raw) data. This is a direct stream with no internal timer. | /// Creates a new direct outgoing stream accepting PCM (raw) data. This is a direct stream with no internal timer. | ||||
/// </summary> | /// </summary> | ||||
/// <param name="samplesPerFrame">Samples per frame. Must be 120, 240, 480, 960, 1920 or 2880, representing 2.5, 5, 10, 20, 40 or 60 milliseconds respectively.</param> | /// <param name="samplesPerFrame">Samples per frame. Must be 120, 240, 480, 960, 1920 or 2880, representing 2.5, 5, 10, 20, 40 or 60 milliseconds respectively.</param> | ||||
/// <param name="bitrate"></param> | /// <param name="bitrate"></param> | ||||
/// <returns></returns> | /// <returns></returns> | ||||
AudioOutStream CreateDirectPCMStream(int samplesPerFrame, int channels = 2, int? bitrate = null); | |||||
AudioOutStream CreateDirectPCMStream(AudioApplication application, int samplesPerFrame, int channels = 2, int? bitrate = null); | |||||
} | } | ||||
} | } |
@@ -1,4 +1,5 @@ | |||||
using Discord.API.Voice; | using Discord.API.Voice; | ||||
using Discord.Audio.Streams; | |||||
using Discord.Logging; | using Discord.Logging; | ||||
using Discord.Net.Converters; | using Discord.Net.Converters; | ||||
using Discord.WebSocket; | using Discord.WebSocket; | ||||
@@ -80,7 +81,7 @@ namespace Discord.Audio | |||||
{ | { | ||||
_audioLogger.WarningAsync(e.ErrorContext.Error).GetAwaiter().GetResult(); | _audioLogger.WarningAsync(e.ErrorContext.Error).GetAwaiter().GetResult(); | ||||
e.ErrorContext.Handled = true; | e.ErrorContext.Handled = true; | ||||
}; | |||||
}; | |||||
LatencyUpdated += async (old, val) => await _audioLogger.VerboseAsync($"Latency = {val} ms").ConfigureAwait(false); | LatencyUpdated += async (old, val) => await _audioLogger.VerboseAsync($"Latency = {val} ms").ConfigureAwait(false); | ||||
} | } | ||||
@@ -129,26 +130,34 @@ namespace Discord.Audio | |||||
public AudioOutStream CreateOpusStream(int samplesPerFrame, int bufferMillis) | public AudioOutStream CreateOpusStream(int samplesPerFrame, int bufferMillis) | ||||
{ | { | ||||
CheckSamplesPerFrame(samplesPerFrame); | CheckSamplesPerFrame(samplesPerFrame); | ||||
var target = new BufferedAudioTarget(ApiClient, samplesPerFrame, bufferMillis, _connection.CancelToken); | |||||
return new RTPWriteStream(target, _secretKey, samplesPerFrame, _ssrc); | |||||
var outputStream = new OutputStream(ApiClient); | |||||
var sodiumEncrypter = new SodiumEncryptStream(outputStream, _secretKey); | |||||
var rtpWriter = new RTPWriteStream(sodiumEncrypter, samplesPerFrame, _ssrc); | |||||
return new BufferedWriteStream(rtpWriter, samplesPerFrame, bufferMillis, _connection.CancelToken, _audioLogger); | |||||
} | } | ||||
public AudioOutStream CreateDirectOpusStream(int samplesPerFrame) | public AudioOutStream CreateDirectOpusStream(int samplesPerFrame) | ||||
{ | { | ||||
CheckSamplesPerFrame(samplesPerFrame); | CheckSamplesPerFrame(samplesPerFrame); | ||||
var target = new DirectAudioTarget(ApiClient); | |||||
return new RTPWriteStream(target, _secretKey, samplesPerFrame, _ssrc); | |||||
var outputStream = new OutputStream(ApiClient); | |||||
var sodiumEncrypter = new SodiumEncryptStream(outputStream, _secretKey); | |||||
return new RTPWriteStream(sodiumEncrypter, samplesPerFrame, _ssrc); | |||||
} | } | ||||
public AudioOutStream CreatePCMStream(int samplesPerFrame, int channels, int? bitrate, int bufferMillis) | |||||
public AudioOutStream CreatePCMStream(AudioApplication application, int samplesPerFrame, int channels, int? bitrate, int bufferMillis) | |||||
{ | { | ||||
CheckSamplesPerFrame(samplesPerFrame); | CheckSamplesPerFrame(samplesPerFrame); | ||||
var target = new BufferedAudioTarget(ApiClient, samplesPerFrame, bufferMillis, _connection.CancelToken); | |||||
return new OpusEncodeStream(target, _secretKey, channels, samplesPerFrame, _ssrc, bitrate); | |||||
var outputStream = new OutputStream(ApiClient); | |||||
var sodiumEncrypter = new SodiumEncryptStream(outputStream, _secretKey); | |||||
var rtpWriter = new RTPWriteStream(sodiumEncrypter, samplesPerFrame, _ssrc); | |||||
var bufferedStream = new BufferedWriteStream(rtpWriter, samplesPerFrame, bufferMillis, _connection.CancelToken, _audioLogger); | |||||
return new OpusEncodeStream(bufferedStream, channels, samplesPerFrame, bitrate ?? (96 * 1024), application); | |||||
} | } | ||||
public AudioOutStream CreateDirectPCMStream(int samplesPerFrame, int channels, int? bitrate) | |||||
public AudioOutStream CreateDirectPCMStream(AudioApplication application, int samplesPerFrame, int channels, int? bitrate) | |||||
{ | { | ||||
CheckSamplesPerFrame(samplesPerFrame); | CheckSamplesPerFrame(samplesPerFrame); | ||||
var target = new DirectAudioTarget(ApiClient); | |||||
return new OpusEncodeStream(target, _secretKey, channels, samplesPerFrame, _ssrc, bitrate); | |||||
var outputStream = new OutputStream(ApiClient); | |||||
var sodiumEncrypter = new SodiumEncryptStream(outputStream, _secretKey); | |||||
var rtpWriter = new RTPWriteStream(sodiumEncrypter, samplesPerFrame, _ssrc); | |||||
return new OpusEncodeStream(rtpWriter, channels, samplesPerFrame, bitrate ?? (96 * 1024), application); | |||||
} | } | ||||
private void CheckSamplesPerFrame(int samplesPerFrame) | private void CheckSamplesPerFrame(int samplesPerFrame) | ||||
{ | { | ||||
@@ -1,6 +1,6 @@ | |||||
namespace Discord.Audio | namespace Discord.Audio | ||||
{ | { | ||||
public enum OpusApplication : int | |||||
internal enum OpusApplication : int | |||||
{ | { | ||||
Voice = 2048, | Voice = 2048, | ||||
MusicOrMixed = 2049, | MusicOrMixed = 2049, | ||||
@@ -1,10 +1,12 @@ | |||||
namespace Discord.Audio | namespace Discord.Audio | ||||
{ | { | ||||
//https://github.com/gcp/opus/blob/master/include/opus_defines.h | |||||
internal enum OpusCtl : int | internal enum OpusCtl : int | ||||
{ | { | ||||
SetBitrateRequest = 4002, | |||||
GetBitrateRequest = 4003, | |||||
SetInbandFECRequest = 4012, | |||||
GetInbandFECRequest = 4013 | |||||
SetBitrate = 4002, | |||||
SetBandwidth = 4008, | |||||
SetInbandFEC = 4012, | |||||
SetPacketLossPercent = 4014, | |||||
SetSignal = 4024 | |||||
} | } | ||||
} | } |
@@ -15,17 +15,62 @@ namespace Discord.Audio | |||||
private static extern int EncoderCtl(IntPtr st, OpusCtl request, int value); | private static extern int EncoderCtl(IntPtr st, OpusCtl request, int value); | ||||
/// <summary> Gets the coding mode of the encoder. </summary> | /// <summary> Gets the coding mode of the encoder. </summary> | ||||
public OpusApplication Application { get; } | |||||
public AudioApplication Application { get; } | |||||
public int BitRate { get;} | |||||
public OpusEncoder(int samplingRate, int channels, OpusApplication application = OpusApplication.MusicOrMixed) | |||||
public OpusEncoder(int samplingRate, int channels, int bitrate, AudioApplication application) | |||||
: base(samplingRate, channels) | : base(samplingRate, channels) | ||||
{ | { | ||||
if (bitrate < 1 || bitrate > DiscordVoiceAPIClient.MaxBitrate) | |||||
throw new ArgumentOutOfRangeException(nameof(bitrate)); | |||||
Application = application; | Application = application; | ||||
BitRate = bitrate; | |||||
OpusApplication opusApplication; | |||||
OpusSignal opusSignal; | |||||
switch (application) | |||||
{ | |||||
case AudioApplication.Mixed: | |||||
opusApplication = OpusApplication.MusicOrMixed; | |||||
opusSignal = OpusSignal.Auto; | |||||
break; | |||||
case AudioApplication.Music: | |||||
opusApplication = OpusApplication.MusicOrMixed; | |||||
opusSignal = OpusSignal.Music; | |||||
break; | |||||
case AudioApplication.Voice: | |||||
opusApplication = OpusApplication.Voice; | |||||
opusSignal = OpusSignal.Voice; | |||||
break; | |||||
default: | |||||
throw new ArgumentOutOfRangeException(nameof(application)); | |||||
} | |||||
OpusError error; | OpusError error; | ||||
_ptr = CreateEncoder(samplingRate, channels, (int)application, out error); | |||||
_ptr = CreateEncoder(samplingRate, channels, (int)opusApplication, out error); | |||||
if (error != OpusError.OK) | if (error != OpusError.OK) | ||||
throw new Exception($"Opus Error: {error}"); | throw new Exception($"Opus Error: {error}"); | ||||
var result = EncoderCtl(_ptr, OpusCtl.SetSignal, (int)opusSignal); | |||||
if (result < 0) | |||||
throw new Exception($"Opus Error: {(OpusError)result}"); | |||||
result = EncoderCtl(_ptr, OpusCtl.SetPacketLossPercent, 5); //%% | |||||
if (result < 0) | |||||
throw new Exception($"Opus Error: {(OpusError)result}"); | |||||
result = EncoderCtl(_ptr, OpusCtl.SetInbandFEC, 1); //True | |||||
if (result < 0) | |||||
throw new Exception($"Opus Error: {(OpusError)result}"); | |||||
result = EncoderCtl(_ptr, OpusCtl.SetBitrate, bitrate); | |||||
if (result < 0) | |||||
throw new Exception($"Opus Error: {(OpusError)result}"); | |||||
/*result = EncoderCtl(_ptr, OpusCtl.SetBandwidth, 1105); | |||||
if (result < 0) | |||||
throw new Exception($"Opus Error: {(OpusError)result}");*/ | |||||
} | } | ||||
/// <summary> Produces Opus encoded audio from PCM samples. </summary> | /// <summary> Produces Opus encoded audio from PCM samples. </summary> | ||||
@@ -44,25 +89,6 @@ namespace Discord.Audio | |||||
return result; | return result; | ||||
} | } | ||||
/// <summary> Gets or sets whether Forward Error Correction is enabled. </summary> | |||||
public void SetForwardErrorCorrection(bool value) | |||||
{ | |||||
var result = EncoderCtl(_ptr, OpusCtl.SetInbandFECRequest, value ? 1 : 0); | |||||
if (result < 0) | |||||
throw new Exception($"Opus Error: {(OpusError)result}"); | |||||
} | |||||
/// <summary> Gets or sets the encoder's bitrate. </summary> | |||||
public void SetBitrate(int value) | |||||
{ | |||||
if (value < 1 || value > DiscordVoiceAPIClient.MaxBitrate) | |||||
throw new ArgumentOutOfRangeException(nameof(value)); | |||||
var result = EncoderCtl(_ptr, OpusCtl.SetBitrateRequest, value); | |||||
if (result < 0) | |||||
throw new Exception($"Opus Error: {(OpusError)result}"); | |||||
} | |||||
protected override void Dispose(bool disposing) | protected override void Dispose(bool disposing) | ||||
{ | { | ||||
if (_ptr != IntPtr.Zero) | if (_ptr != IntPtr.Zero) | ||||
@@ -0,0 +1,9 @@ | |||||
namespace Discord.Audio | |||||
{ | |||||
internal enum OpusSignal : int | |||||
{ | |||||
Auto = -1000, | |||||
Voice = 3001, | |||||
Music = 3002, | |||||
} | |||||
} |
@@ -0,0 +1,156 @@ | |||||
using Discord.Logging; | |||||
using System; | |||||
using System.Collections.Concurrent; | |||||
using System.Threading; | |||||
using System.Threading.Tasks; | |||||
namespace Discord.Audio.Streams | |||||
{ | |||||
///<summary> Wraps another stream with a timed buffer. </summary> | |||||
public class BufferedWriteStream : AudioOutStream | |||||
{ | |||||
private struct Frame | |||||
{ | |||||
public Frame(byte[] buffer, int bytes) | |||||
{ | |||||
Buffer = buffer; | |||||
Bytes = bytes; | |||||
} | |||||
public readonly byte[] Buffer; | |||||
public readonly int Bytes; | |||||
} | |||||
private static readonly byte[] _silenceFrame = new byte[0]; | |||||
private readonly AudioOutStream _next; | |||||
private readonly CancellationTokenSource _cancelTokenSource; | |||||
private readonly CancellationToken _cancelToken; | |||||
private readonly Task _task; | |||||
private readonly ConcurrentQueue<Frame> _queuedFrames; | |||||
private readonly ConcurrentQueue<byte[]> _bufferPool; | |||||
private readonly SemaphoreSlim _queueLock; | |||||
private readonly Logger _logger; | |||||
private readonly int _ticksPerFrame, _queueLength; | |||||
private bool _isPreloaded; | |||||
internal BufferedWriteStream(AudioOutStream next, int samplesPerFrame, int bufferMillis, CancellationToken cancelToken, Logger logger, int maxFrameSize = 1500) | |||||
{ | |||||
//maxFrameSize = 1275 was too limiting at 128*1024 | |||||
_next = next; | |||||
_ticksPerFrame = samplesPerFrame / 48; | |||||
_logger = logger; | |||||
_queueLength = (bufferMillis + (_ticksPerFrame - 1)) / _ticksPerFrame; //Round up | |||||
_cancelTokenSource = new CancellationTokenSource(); | |||||
_cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_cancelTokenSource.Token, cancelToken).Token; | |||||
_queuedFrames = new ConcurrentQueue<Frame>(); | |||||
_bufferPool = new ConcurrentQueue<byte[]>(); | |||||
for (int i = 0; i < _queueLength; i++) | |||||
_bufferPool.Enqueue(new byte[maxFrameSize]); | |||||
_queueLock = new SemaphoreSlim(_queueLength, _queueLength); | |||||
_task = Run(); | |||||
} | |||||
private Task Run() | |||||
{ | |||||
uint num = 0; | |||||
return Task.Run(async () => | |||||
{ | |||||
try | |||||
{ | |||||
while (!_isPreloaded && !_cancelToken.IsCancellationRequested) | |||||
await Task.Delay(1).ConfigureAwait(false); | |||||
long nextTick = Environment.TickCount; | |||||
while (!_cancelToken.IsCancellationRequested) | |||||
{ | |||||
const int limit = 1; | |||||
long tick = Environment.TickCount; | |||||
long dist = nextTick - tick; | |||||
if (dist <= limit) | |||||
{ | |||||
Frame frame; | |||||
if (_queuedFrames.TryDequeue(out frame)) | |||||
{ | |||||
await _next.WriteAsync(frame.Buffer, 0, frame.Bytes).ConfigureAwait(false); | |||||
_bufferPool.Enqueue(frame.Buffer); | |||||
_queueLock.Release(); | |||||
nextTick += _ticksPerFrame; | |||||
#if DEBUG | |||||
var _ = _logger.DebugAsync($"{num++}: Sent {frame.Bytes} bytes ({_queuedFrames.Count} frames buffered)"); | |||||
#endif | |||||
} | |||||
else if (dist == 0) | |||||
{ | |||||
await _next.WriteAsync(_silenceFrame, 0, _silenceFrame.Length).ConfigureAwait(false); | |||||
nextTick += _ticksPerFrame; | |||||
#if DEBUG | |||||
var _ = _logger.DebugAsync($"{num++}: Buffer underrun"); | |||||
#endif | |||||
} | |||||
} | |||||
else | |||||
await Task.Delay((int)(dist - (limit - 1))).ConfigureAwait(false); | |||||
} | |||||
} | |||||
catch (OperationCanceledException) { } | |||||
}); | |||||
} | |||||
public override async Task WriteAsync(byte[] data, int offset, int count, CancellationToken cancelToken) | |||||
{ | |||||
if (cancelToken.CanBeCanceled) | |||||
cancelToken = CancellationTokenSource.CreateLinkedTokenSource(cancelToken, _cancelToken).Token; | |||||
else | |||||
cancelToken = _cancelToken; | |||||
await _queueLock.WaitAsync(-1, cancelToken).ConfigureAwait(false); | |||||
byte[] buffer; | |||||
if (!_bufferPool.TryDequeue(out buffer)) | |||||
{ | |||||
#if DEBUG | |||||
var _ = _logger.DebugAsync($"Buffer overflow"); //Should never happen because of the queueLock | |||||
#endif | |||||
return; | |||||
} | |||||
Buffer.BlockCopy(data, offset, buffer, 0, count); | |||||
_queuedFrames.Enqueue(new Frame(buffer, count)); | |||||
#if DEBUG | |||||
//var _ await _logger.DebugAsync($"Queued {count} bytes ({_queuedFrames.Count} frames buffered)"); | |||||
#endif | |||||
if (!_isPreloaded && _queuedFrames.Count == _queueLength) | |||||
{ | |||||
#if DEBUG | |||||
var _ = _logger.DebugAsync($"Preloaded"); | |||||
#endif | |||||
_isPreloaded = true; | |||||
} | |||||
} | |||||
public override async Task FlushAsync(CancellationToken cancelToken) | |||||
{ | |||||
while (true) | |||||
{ | |||||
cancelToken.ThrowIfCancellationRequested(); | |||||
if (_queuedFrames.Count == 0) | |||||
return; | |||||
await Task.Delay(250, cancelToken).ConfigureAwait(false); | |||||
} | |||||
} | |||||
public override Task ClearAsync(CancellationToken cancelToken) | |||||
{ | |||||
Frame ignored; | |||||
do | |||||
cancelToken.ThrowIfCancellationRequested(); | |||||
while (_queuedFrames.TryDequeue(out ignored)); | |||||
return Task.Delay(0); | |||||
} | |||||
protected override void Dispose(bool disposing) | |||||
{ | |||||
if (disposing) | |||||
_cancelTokenSource.Cancel(); | |||||
} | |||||
} | |||||
} |
@@ -1,22 +1,34 @@ | |||||
namespace Discord.Audio | |||||
using System; | |||||
using System.Collections.Concurrent; | |||||
namespace Discord.Audio.Streams | |||||
{ | { | ||||
internal class OpusDecodeStream : RTPReadStream | |||||
///<summary> Converts Opus to PCM </summary> | |||||
public class OpusDecodeStream : AudioInStream | |||||
{ | { | ||||
private readonly BlockingCollection<byte[]> _queuedData; //TODO: Replace with max-length ring buffer | |||||
private readonly byte[] _buffer; | private readonly byte[] _buffer; | ||||
private readonly OpusDecoder _decoder; | private readonly OpusDecoder _decoder; | ||||
internal OpusDecodeStream(AudioClient audioClient, byte[] secretKey, int samplingRate, | |||||
int channels = OpusConverter.MaxChannels, int bufferSize = 4000) | |||||
: base(audioClient, secretKey) | |||||
internal OpusDecodeStream(AudioClient audioClient, int samplingRate, int channels = OpusConverter.MaxChannels, int bufferSize = 4000) | |||||
{ | { | ||||
_buffer = new byte[bufferSize]; | _buffer = new byte[bufferSize]; | ||||
_decoder = new OpusDecoder(samplingRate, channels); | _decoder = new OpusDecoder(samplingRate, channels); | ||||
_queuedData = new BlockingCollection<byte[]>(100); | |||||
} | } | ||||
public override int Read(byte[] buffer, int offset, int count) | public override int Read(byte[] buffer, int offset, int count) | ||||
{ | |||||
var queuedData = _queuedData.Take(); | |||||
Buffer.BlockCopy(queuedData, 0, buffer, offset, Math.Min(queuedData.Length, count)); | |||||
return queuedData.Length; | |||||
} | |||||
public override void Write(byte[] buffer, int offset, int count) | |||||
{ | { | ||||
count = _decoder.DecodeFrame(buffer, offset, count, _buffer, 0); | count = _decoder.DecodeFrame(buffer, offset, count, _buffer, 0); | ||||
return base.Read(_buffer, 0, count); | |||||
var newBuffer = new byte[count]; | |||||
Buffer.BlockCopy(_buffer, 0, newBuffer, 0, count); | |||||
_queuedData.Add(newBuffer); | |||||
} | } | ||||
protected override void Dispose(bool disposing) | protected override void Dispose(bool disposing) | ||||
@@ -2,27 +2,28 @@ | |||||
using System.Threading; | using System.Threading; | ||||
using System.Threading.Tasks; | using System.Threading.Tasks; | ||||
namespace Discord.Audio | |||||
namespace Discord.Audio.Streams | |||||
{ | { | ||||
internal class OpusEncodeStream : RTPWriteStream | |||||
///<summary> Converts PCM to Opus </summary> | |||||
public class OpusEncodeStream : AudioOutStream | |||||
{ | { | ||||
public const int SampleRate = 48000; | public const int SampleRate = 48000; | ||||
private readonly AudioOutStream _next; | |||||
private readonly OpusEncoder _encoder; | |||||
private readonly byte[] _buffer; | |||||
private int _frameSize; | private int _frameSize; | ||||
private byte[] _partialFrameBuffer; | private byte[] _partialFrameBuffer; | ||||
private int _partialFramePos; | private int _partialFramePos; | ||||
private readonly OpusEncoder _encoder; | |||||
internal OpusEncodeStream(IAudioTarget target, byte[] secretKey, int channels, int samplesPerFrame, uint ssrc, int? bitrate = null) | |||||
: base(target, secretKey, samplesPerFrame, ssrc) | |||||
internal OpusEncodeStream(AudioOutStream next, int channels, int samplesPerFrame, int bitrate, AudioApplication application, int bufferSize = 4000) | |||||
{ | { | ||||
_encoder = new OpusEncoder(SampleRate, channels); | |||||
_next = next; | |||||
_encoder = new OpusEncoder(SampleRate, channels, bitrate, application); | |||||
_frameSize = samplesPerFrame * channels * 2; | _frameSize = samplesPerFrame * channels * 2; | ||||
_buffer = new byte[bufferSize]; | |||||
_partialFrameBuffer = new byte[_frameSize]; | _partialFrameBuffer = new byte[_frameSize]; | ||||
_encoder.SetForwardErrorCorrection(true); | |||||
if (bitrate != null) | |||||
_encoder.SetBitrate(bitrate.Value); | |||||
} | } | ||||
public override void Write(byte[] buffer, int offset, int count) | public override void Write(byte[] buffer, int offset, int count) | ||||
@@ -43,7 +44,7 @@ namespace Discord.Audio | |||||
_partialFramePos = 0; | _partialFramePos = 0; | ||||
int encFrameSize = _encoder.EncodeFrame(_partialFrameBuffer, 0, _frameSize, _buffer, 0); | int encFrameSize = _encoder.EncodeFrame(_partialFrameBuffer, 0, _frameSize, _buffer, 0); | ||||
await base.WriteAsync(_buffer, 0, encFrameSize, cancellationToken).ConfigureAwait(false); | |||||
await _next.WriteAsync(_buffer, 0, encFrameSize, cancellationToken).ConfigureAwait(false); | |||||
} | } | ||||
else | else | ||||
{ | { | ||||
@@ -54,10 +55,7 @@ namespace Discord.Audio | |||||
} | } | ||||
} | } | ||||
/*public override void Flush() | |||||
{ | |||||
FlushAsync(CancellationToken.None).GetAwaiter().GetResult(); | |||||
} | |||||
/* | |||||
public override async Task FlushAsync(CancellationToken cancellationToken) | public override async Task FlushAsync(CancellationToken cancellationToken) | ||||
{ | { | ||||
try | try | ||||
@@ -70,6 +68,15 @@ namespace Discord.Audio | |||||
await base.FlushAsync(cancellationToken).ConfigureAwait(false); | await base.FlushAsync(cancellationToken).ConfigureAwait(false); | ||||
}*/ | }*/ | ||||
public override async Task FlushAsync(CancellationToken cancelToken) | |||||
{ | |||||
await _next.FlushAsync(cancelToken).ConfigureAwait(false); | |||||
} | |||||
public override async Task ClearAsync(CancellationToken cancelToken) | |||||
{ | |||||
await _next.ClearAsync(cancelToken).ConfigureAwait(false); | |||||
} | |||||
protected override void Dispose(bool disposing) | protected override void Dispose(bool disposing) | ||||
{ | { | ||||
base.Dispose(disposing); | base.Dispose(disposing); | ||||
@@ -0,0 +1,23 @@ | |||||
using System.Threading; | |||||
using System.Threading.Tasks; | |||||
namespace Discord.Audio.Streams | |||||
{ | |||||
///<summary> Wraps an IAudioClient, sending voice data on write. </summary> | |||||
public class OutputStream : AudioOutStream | |||||
{ | |||||
private readonly DiscordVoiceAPIClient _client; | |||||
public OutputStream(IAudioClient client) | |||||
: this((client as AudioClient).ApiClient) { } | |||||
internal OutputStream(DiscordVoiceAPIClient client) | |||||
{ | |||||
_client = client; | |||||
} | |||||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) | |||||
{ | |||||
cancelToken.ThrowIfCancellationRequested(); | |||||
await _client.SendAsync(buffer, offset, count).ConfigureAwait(false); | |||||
} | |||||
} | |||||
} |
@@ -2,11 +2,13 @@ | |||||
using System.Collections.Concurrent; | using System.Collections.Concurrent; | ||||
using System.IO; | using System.IO; | ||||
namespace Discord.Audio | |||||
namespace Discord.Audio.Streams | |||||
{ | { | ||||
internal class RTPReadStream : Stream | |||||
///<summary> Reads the payload from an RTP frame </summary> | |||||
public class RTPReadStream : AudioInStream | |||||
{ | { | ||||
private readonly BlockingCollection<byte[]> _queuedData; //TODO: Replace with max-length ring buffer | private readonly BlockingCollection<byte[]> _queuedData; //TODO: Replace with max-length ring buffer | ||||
//private readonly BlockingCollection<RTPFrame> _queuedData; //TODO: Replace with max-length ring buffer | |||||
private readonly AudioClient _audioClient; | private readonly AudioClient _audioClient; | ||||
private readonly byte[] _buffer, _nonce, _secretKey; | private readonly byte[] _buffer, _nonce, _secretKey; | ||||
@@ -23,6 +25,12 @@ namespace Discord.Audio | |||||
_nonce = new byte[24]; | _nonce = new byte[24]; | ||||
} | } | ||||
/*public RTPFrame ReadFrame() | |||||
{ | |||||
var queuedData = _queuedData.Take(); | |||||
Buffer.BlockCopy(queuedData, 0, buffer, offset, Math.Min(queuedData.Length, count)); | |||||
return queuedData.Length; | |||||
}*/ | |||||
public override int Read(byte[] buffer, int offset, int count) | public override int Read(byte[] buffer, int offset, int count) | ||||
{ | { | ||||
var queuedData = _queuedData.Take(); | var queuedData = _queuedData.Take(); | ||||
@@ -31,10 +39,8 @@ namespace Discord.Audio | |||||
} | } | ||||
public override void Write(byte[] buffer, int offset, int count) | public override void Write(byte[] buffer, int offset, int count) | ||||
{ | { | ||||
Buffer.BlockCopy(buffer, 0, _nonce, 0, 12); | |||||
count = SecretBox.Decrypt(buffer, offset, count, _buffer, 0, _nonce, _secretKey); | |||||
var newBuffer = new byte[count]; | var newBuffer = new byte[count]; | ||||
Buffer.BlockCopy(_buffer, 0, newBuffer, 0, count); | |||||
Buffer.BlockCopy(buffer, 0, newBuffer, 0, count); | |||||
_queuedData.Add(newBuffer); | _queuedData.Add(newBuffer); | ||||
} | } | ||||
@@ -1,33 +1,32 @@ | |||||
using System; | using System; | ||||
using System.IO; | |||||
using System.Threading; | using System.Threading; | ||||
using System.Threading.Tasks; | using System.Threading.Tasks; | ||||
namespace Discord.Audio | |||||
namespace Discord.Audio.Streams | |||||
{ | { | ||||
internal class RTPWriteStream : AudioOutStream | |||||
///<summary> Wraps data in an RTP frame </summary> | |||||
public class RTPWriteStream : AudioOutStream | |||||
{ | { | ||||
private readonly IAudioTarget _target; | |||||
private readonly byte[] _nonce, _secretKey; | |||||
private readonly AudioOutStream _next; | |||||
private readonly byte[] _header; | |||||
private int _samplesPerFrame; | private int _samplesPerFrame; | ||||
private uint _ssrc, _timestamp = 0; | private uint _ssrc, _timestamp = 0; | ||||
protected readonly byte[] _buffer; | protected readonly byte[] _buffer; | ||||
internal RTPWriteStream(IAudioTarget target, byte[] secretKey, int samplesPerFrame, uint ssrc) | |||||
internal RTPWriteStream(AudioOutStream next, int samplesPerFrame, uint ssrc, int bufferSize = 4000) | |||||
{ | { | ||||
_target = target; | |||||
_secretKey = secretKey; | |||||
_next = next; | |||||
_samplesPerFrame = samplesPerFrame; | _samplesPerFrame = samplesPerFrame; | ||||
_ssrc = ssrc; | _ssrc = ssrc; | ||||
_buffer = new byte[4000]; | |||||
_nonce = new byte[24]; | |||||
_nonce[0] = 0x80; | |||||
_nonce[1] = 0x78; | |||||
_nonce[8] = (byte)(_ssrc >> 24); | |||||
_nonce[9] = (byte)(_ssrc >> 16); | |||||
_nonce[10] = (byte)(_ssrc >> 8); | |||||
_nonce[11] = (byte)(_ssrc >> 0); | |||||
_buffer = new byte[bufferSize]; | |||||
_header = new byte[24]; | |||||
_header[0] = 0x80; | |||||
_header[1] = 0x78; | |||||
_header[8] = (byte)(_ssrc >> 24); | |||||
_header[9] = (byte)(_ssrc >> 16); | |||||
_header[10] = (byte)(_ssrc >> 8); | |||||
_header[11] = (byte)(_ssrc >> 0); | |||||
} | } | ||||
public override void Write(byte[] buffer, int offset, int count) | public override void Write(byte[] buffer, int offset, int count) | ||||
@@ -39,48 +38,28 @@ namespace Discord.Audio | |||||
cancellationToken.ThrowIfCancellationRequested(); | cancellationToken.ThrowIfCancellationRequested(); | ||||
unchecked | unchecked | ||||
{ | { | ||||
if (_nonce[3]++ == byte.MaxValue) | |||||
_nonce[2]++; | |||||
if (_header[3]++ == byte.MaxValue) | |||||
_header[2]++; | |||||
_timestamp += (uint)_samplesPerFrame; | _timestamp += (uint)_samplesPerFrame; | ||||
_nonce[4] = (byte)(_timestamp >> 24); | |||||
_nonce[5] = (byte)(_timestamp >> 16); | |||||
_nonce[6] = (byte)(_timestamp >> 8); | |||||
_nonce[7] = (byte)(_timestamp >> 0); | |||||
_header[4] = (byte)(_timestamp >> 24); | |||||
_header[5] = (byte)(_timestamp >> 16); | |||||
_header[6] = (byte)(_timestamp >> 8); | |||||
_header[7] = (byte)(_timestamp >> 0); | |||||
} | } | ||||
Buffer.BlockCopy(_header, 0, _buffer, 0, 12); //Copy RTP header from to the buffer | |||||
Buffer.BlockCopy(buffer, offset, _buffer, 12, count); | |||||
count = SecretBox.Encrypt(buffer, offset, count, _buffer, 12, _nonce, _secretKey); | |||||
Buffer.BlockCopy(_nonce, 0, _buffer, 0, 12); //Copy the RTP header from nonce to buffer | |||||
await _target.SendAsync(_buffer, count + 12).ConfigureAwait(false); | |||||
await _next.WriteAsync(_buffer, 0, count + 12).ConfigureAwait(false); | |||||
} | } | ||||
public override void Flush() | |||||
public override async Task FlushAsync(CancellationToken cancelToken) | |||||
{ | { | ||||
FlushAsync(CancellationToken.None).GetAwaiter().GetResult(); | |||||
} | |||||
public override async Task FlushAsync(CancellationToken cancellationToken) | |||||
{ | |||||
await _target.FlushAsync(cancellationToken).ConfigureAwait(false); | |||||
} | |||||
public override void Clear() | |||||
{ | |||||
ClearAsync(CancellationToken.None).GetAwaiter().GetResult(); | |||||
await _next.FlushAsync(cancelToken).ConfigureAwait(false); | |||||
} | } | ||||
public override async Task ClearAsync(CancellationToken cancelToken) | public override async Task ClearAsync(CancellationToken cancelToken) | ||||
{ | { | ||||
await _target.ClearAsync(cancelToken).ConfigureAwait(false); | |||||
await _next.ClearAsync(cancelToken).ConfigureAwait(false); | |||||
} | } | ||||
public override long Length { get { throw new NotSupportedException(); } } | |||||
public override long Position | |||||
{ | |||||
get { throw new NotSupportedException(); } | |||||
set { throw new NotSupportedException(); } | |||||
} | |||||
public override int Read(byte[] buffer, int offset, int count) { throw new NotSupportedException(); } | |||||
public override void SetLength(long value) { throw new NotSupportedException(); } | |||||
public override long Seek(long offset, SeekOrigin origin) { throw new NotSupportedException(); } | |||||
} | } | ||||
} | } |
@@ -0,0 +1,42 @@ | |||||
using System; | |||||
using System.Collections.Concurrent; | |||||
namespace Discord.Audio.Streams | |||||
{ | |||||
///<summary> Decrypts an RTP frame using libsodium </summary> | |||||
public class SodiumDecryptStream : AudioInStream | |||||
{ | |||||
private readonly BlockingCollection<byte[]> _queuedData; //TODO: Replace with max-length ring buffer | |||||
private readonly AudioClient _audioClient; | |||||
private readonly byte[] _buffer, _nonce, _secretKey; | |||||
public override bool CanRead => true; | |||||
public override bool CanSeek => false; | |||||
public override bool CanWrite => true; | |||||
internal SodiumDecryptStream(AudioClient audioClient, byte[] secretKey, int bufferSize = 4000) | |||||
{ | |||||
_audioClient = audioClient; | |||||
_secretKey = secretKey; | |||||
_buffer = new byte[bufferSize]; | |||||
_queuedData = new BlockingCollection<byte[]>(100); | |||||
_nonce = new byte[24]; | |||||
} | |||||
public override int Read(byte[] buffer, int offset, int count) | |||||
{ | |||||
var queuedData = _queuedData.Take(); | |||||
Buffer.BlockCopy(queuedData, 0, buffer, offset, Math.Min(queuedData.Length, count)); | |||||
return queuedData.Length; | |||||
} | |||||
public override void Write(byte[] buffer, int offset, int count) | |||||
{ | |||||
Buffer.BlockCopy(buffer, 0, _nonce, 0, 12); //Copy RTP header to nonce | |||||
count = SecretBox.Decrypt(buffer, offset, count, _buffer, 0, _nonce, _secretKey); | |||||
var newBuffer = new byte[count]; | |||||
Buffer.BlockCopy(_buffer, 0, newBuffer, 0, count); | |||||
_queuedData.Add(newBuffer); | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,45 @@ | |||||
using System; | |||||
using System.Threading; | |||||
using System.Threading.Tasks; | |||||
namespace Discord.Audio.Streams | |||||
{ | |||||
///<summary> Encrypts an RTP frame using libsodium </summary> | |||||
public class SodiumEncryptStream : AudioOutStream | |||||
{ | |||||
private readonly AudioOutStream _next; | |||||
private readonly byte[] _nonce, _secretKey; | |||||
//protected readonly byte[] _buffer; | |||||
internal SodiumEncryptStream(AudioOutStream next, byte[] secretKey/*, int bufferSize = 4000*/) | |||||
{ | |||||
_next = next; | |||||
_secretKey = secretKey; | |||||
//_buffer = new byte[bufferSize]; //TODO: Can Sodium do an in-place encrypt? | |||||
_nonce = new byte[24]; | |||||
} | |||||
public override void Write(byte[] buffer, int offset, int count) | |||||
{ | |||||
WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); | |||||
} | |||||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) | |||||
{ | |||||
cancellationToken.ThrowIfCancellationRequested(); | |||||
Buffer.BlockCopy(buffer, offset, _nonce, 0, 12); //Copy nonce from RTP header | |||||
count = SecretBox.Encrypt(buffer, offset + 12, count - 12, buffer, 12, _nonce, _secretKey); | |||||
await _next.WriteAsync(buffer, 0, count + 12, cancellationToken).ConfigureAwait(false); | |||||
} | |||||
public override async Task FlushAsync(CancellationToken cancelToken) | |||||
{ | |||||
await _next.FlushAsync(cancelToken).ConfigureAwait(false); | |||||
} | |||||
public override async Task ClearAsync(CancellationToken cancelToken) | |||||
{ | |||||
await _next.ClearAsync(cancelToken).ConfigureAwait(false); | |||||
} | |||||
} | |||||
} |
@@ -1,119 +0,0 @@ | |||||
using System; | |||||
using System.Collections.Concurrent; | |||||
using System.Threading; | |||||
using System.Threading.Tasks; | |||||
namespace Discord.Audio | |||||
{ | |||||
internal class BufferedAudioTarget : IAudioTarget, IDisposable | |||||
{ | |||||
private struct Frame | |||||
{ | |||||
public Frame(byte[] buffer, int bytes) | |||||
{ | |||||
Buffer = buffer; | |||||
Bytes = bytes; | |||||
} | |||||
public readonly byte[] Buffer; | |||||
public readonly int Bytes; | |||||
} | |||||
private static readonly byte[] _silenceFrame = new byte[] { 0xF8, 0xFF, 0xFE }; | |||||
private Task _task; | |||||
private DiscordVoiceAPIClient _client; | |||||
private CancellationTokenSource _cancelTokenSource; | |||||
private CancellationToken _cancelToken; | |||||
private ConcurrentQueue<Frame> _queuedFrames; | |||||
private ConcurrentQueue<byte[]> _bufferPool; | |||||
private SemaphoreSlim _queueLock; | |||||
private int _ticksPerFrame; | |||||
internal BufferedAudioTarget(DiscordVoiceAPIClient client, int samplesPerFrame, int bufferMillis, CancellationToken cancelToken) | |||||
{ | |||||
_client = client; | |||||
_ticksPerFrame = samplesPerFrame / 48; | |||||
int queueLength = (bufferMillis + (_ticksPerFrame - 1)) / _ticksPerFrame; //Round up | |||||
_cancelTokenSource = new CancellationTokenSource(); | |||||
_cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_cancelTokenSource.Token, cancelToken).Token; | |||||
_queuedFrames = new ConcurrentQueue<Frame>(); | |||||
_bufferPool = new ConcurrentQueue<byte[]>(); | |||||
for (int i = 0; i < queueLength; i++) | |||||
_bufferPool.Enqueue(new byte[1275]); | |||||
_queueLock = new SemaphoreSlim(queueLength, queueLength); | |||||
_task = Run(); | |||||
} | |||||
private Task Run() | |||||
{ | |||||
return Task.Run(async () => | |||||
{ | |||||
try | |||||
{ | |||||
long nextTick = Environment.TickCount; | |||||
while (!_cancelToken.IsCancellationRequested) | |||||
{ | |||||
long tick = Environment.TickCount; | |||||
long dist = nextTick - tick; | |||||
if (dist <= 0) | |||||
{ | |||||
Frame frame; | |||||
if (_queuedFrames.TryDequeue(out frame)) | |||||
{ | |||||
await _client.SendAsync(frame.Buffer, frame.Bytes).ConfigureAwait(false); | |||||
_bufferPool.Enqueue(frame.Buffer); | |||||
_queueLock.Release(); | |||||
} | |||||
else | |||||
await _client.SendAsync(_silenceFrame, _silenceFrame.Length).ConfigureAwait(false); | |||||
nextTick += _ticksPerFrame; | |||||
} | |||||
else if (dist > 1) | |||||
await Task.Delay((int)dist).ConfigureAwait(false); | |||||
} | |||||
} | |||||
catch (OperationCanceledException) { } | |||||
}); | |||||
} | |||||
public async Task SendAsync(byte[] data, int count) | |||||
{ | |||||
await _queueLock.WaitAsync(-1, _cancelToken).ConfigureAwait(false); | |||||
byte[] buffer; | |||||
_bufferPool.TryDequeue(out buffer); | |||||
Buffer.BlockCopy(data, 0, buffer, 0, count); | |||||
_queuedFrames.Enqueue(new Frame(buffer, count)); | |||||
} | |||||
public async Task FlushAsync(CancellationToken cancelToken) | |||||
{ | |||||
while (true) | |||||
{ | |||||
cancelToken.ThrowIfCancellationRequested(); | |||||
if (_queuedFrames.Count == 0) | |||||
return; | |||||
await Task.Delay(250, cancelToken).ConfigureAwait(false); | |||||
} | |||||
} | |||||
public Task ClearAsync(CancellationToken cancelToken) | |||||
{ | |||||
Frame ignored; | |||||
do | |||||
cancelToken.ThrowIfCancellationRequested(); | |||||
while (_queuedFrames.TryDequeue(out ignored)); | |||||
return Task.Delay(0); | |||||
} | |||||
protected void Dispose(bool disposing) | |||||
{ | |||||
if (disposing) | |||||
_cancelTokenSource.Cancel(); | |||||
} | |||||
public void Dispose() | |||||
{ | |||||
Dispose(true); | |||||
} | |||||
} | |||||
} |
@@ -1,22 +0,0 @@ | |||||
using System.Threading; | |||||
using System.Threading.Tasks; | |||||
namespace Discord.Audio | |||||
{ | |||||
internal class DirectAudioTarget : IAudioTarget | |||||
{ | |||||
private readonly DiscordVoiceAPIClient _client; | |||||
public DirectAudioTarget(DiscordVoiceAPIClient client) | |||||
{ | |||||
_client = client; | |||||
} | |||||
public Task SendAsync(byte[] buffer, int count) | |||||
=> _client.SendAsync(buffer, count); | |||||
public Task FlushAsync(CancellationToken cancelToken) | |||||
=> Task.Delay(0); | |||||
public Task ClearAsync(CancellationToken cancelToken) | |||||
=> Task.Delay(0); | |||||
} | |||||
} |
@@ -1,12 +0,0 @@ | |||||
using System.Threading; | |||||
using System.Threading.Tasks; | |||||
namespace Discord.Audio | |||||
{ | |||||
internal interface IAudioTarget | |||||
{ | |||||
Task SendAsync(byte[] buffer, int count); | |||||
Task FlushAsync(CancellationToken cancelToken); | |||||
Task ClearAsync(CancellationToken cancelToken); | |||||
} | |||||
} |
@@ -64,7 +64,7 @@ namespace Discord.Audio | |||||
}; | }; | ||||
WebSocketClient = webSocketProvider(); | WebSocketClient = webSocketProvider(); | ||||
//_gatewayClient.SetHeader("user-agent", DiscordConfig.UserAgent); (Causes issues in .Net 4.6+) | |||||
//_gatewayClient.SetHeader("user-agent", DiscordConfig.UserAgent); //(Causes issues in .Net 4.6+) | |||||
WebSocketClient.BinaryMessage += async (data, index, count) => | WebSocketClient.BinaryMessage += async (data, index, count) => | ||||
{ | { | ||||
using (var compressed = new MemoryStream(data, index + 2, count - 2)) | using (var compressed = new MemoryStream(data, index + 2, count - 2)) | ||||
@@ -117,9 +117,9 @@ namespace Discord.Audio | |||||
await WebSocketClient.SendAsync(bytes, 0, bytes.Length, true).ConfigureAwait(false); | await WebSocketClient.SendAsync(bytes, 0, bytes.Length, true).ConfigureAwait(false); | ||||
await _sentGatewayMessageEvent.InvokeAsync(opCode).ConfigureAwait(false); | await _sentGatewayMessageEvent.InvokeAsync(opCode).ConfigureAwait(false); | ||||
} | } | ||||
public async Task SendAsync(byte[] data, int bytes) | |||||
public async Task SendAsync(byte[] data, int offset, int bytes) | |||||
{ | { | ||||
await _udp.SendAsync(data, 0, bytes).ConfigureAwait(false); | |||||
await _udp.SendAsync(data, offset, bytes).ConfigureAwait(false); | |||||
await _sentDataEvent.InvokeAsync(bytes).ConfigureAwait(false); | await _sentDataEvent.InvokeAsync(bytes).ConfigureAwait(false); | ||||
} | } | ||||
@@ -224,7 +224,7 @@ namespace Discord.Audio | |||||
packet[1] = (byte)(ssrc >> 16); | packet[1] = (byte)(ssrc >> 16); | ||||
packet[2] = (byte)(ssrc >> 8); | packet[2] = (byte)(ssrc >> 8); | ||||
packet[3] = (byte)(ssrc >> 0); | packet[3] = (byte)(ssrc >> 0); | ||||
await SendAsync(packet, 70).ConfigureAwait(false); | |||||
await SendAsync(packet, 0, 70).ConfigureAwait(false); | |||||
await _sentDiscoveryEvent.InvokeAsync().ConfigureAwait(false); | await _sentDiscoveryEvent.InvokeAsync().ConfigureAwait(false); | ||||
} | } | ||||