From 2310d1e8ba742d73f3e716dbc3cd6e456b2e5c02 Mon Sep 17 00:00:00 2001 From: Quahu Date: Sat, 7 Mar 2026 23:46:18 +0100 Subject: [PATCH] Implement voice receive --- Disqord.sln | 15 + .../BasicVoice/Audio/AudioMetadataKeys.cs | 12 - .../BasicVoice/Audio/BasicAudioPlayer.cs | 208 +++----- .../BasicVoice/Audio/FFmpegAudioSource.cs | 176 ++---- examples/Voice/BasicVoice/Audio/Song.cs | 10 + examples/Voice/BasicVoice/AudioModule.cs | 60 +-- .../Voice/BasicVoice/AudioPlayerService.cs | 51 +- examples/Voice/BasicVoice/Program.cs | 81 ++- .../BasicVoiceReceive.csproj | 27 + examples/Voice/BasicVoiceReceive/Program.cs | 47 ++ examples/Voice/BasicVoiceReceive/README.md | 23 + .../BasicVoiceReceive/RecordingModule.cs | 74 +++ .../BasicVoiceReceive/RecordingService.cs | 288 ++++++++++ .../Audio/Default/AudioPlayer.cs | 102 ++-- .../Default/AudioReceiveBufferFullMode.cs | 22 + .../Default/AudioReceiveEndBehaviorType.cs | 22 + .../AudioReceiveSubscriptionOptions.cs | 31 ++ .../Audio/Default/AudioReceiver.cs | 325 +++++++++++ .../Default/AudioReceiverSubscription.cs | 208 ++++++++ .../Audio/Default/AudioSource.cs | 21 - .../Audio/Default/OggOpusWriter.cs | 266 +++++++++ .../Audio/Default/OggReader.cs | 4 +- .../Audio/Default/OggStreamAudioSource.cs | 4 +- .../Audio/IAudioSource.cs | 34 ++ .../VoiceConnectOptions.cs | 18 + .../VoiceExtension.cs | 35 +- .../Default/DefaultVoiceGateway.cs | 2 +- .../Default/DefaultVoiceGatewayClient.cs | 162 +++++- .../Default/DefaultVoiceGatewayHeartbeater.cs | 2 + src/Disqord.Voice.Api/IVoiceGatewayClient.cs | 19 +- .../Models/Payloads/ClientConnectJsonModel.cs | 12 +- .../Default/DaveProtocolHandler.cs | 396 ++++++++++++-- .../Default/DefaultVoiceConnection.cs | 505 ++++++++++++++---- .../Default/DefaultVoiceUdpClient.cs | 314 ++++++++++- src/Disqord.Voice/Encryption/Dave/Dave.cs | 68 ++- .../Encryption/Dave/DaveDecryptor.cs | 8 +- .../Encryption/Dave/DaveEncryptor.cs | 8 +- .../Encryption/Dave/DaveSession.cs | 8 +- .../Modes/AEADAes256GcmRtpSizeEncryption.cs | 31 ++ .../AEADXChaCha20Poly1305RtpSizeEncryption.cs | 31 ++ .../Modes/XSalsa20Poly1305Encryption.cs | 15 + .../Modes/XSalsa20Poly1305LiteEncryption.cs | 15 + .../Modes/XSalsa20Poly1305SuffixEncryption.cs | 12 + .../Encryption/IVoiceEncryption.cs | 18 + .../Default/DefaultVoiceConnectionFactory.cs | 4 +- .../Factory/IVoiceConnectionFactory.cs | 2 +- src/Disqord.Voice/IVoiceConnection.cs | 120 ++++- src/Disqord.Voice/IVoiceUdpClient.cs | 2 + src/Disqord.Voice/VoiceConstants.cs | 4 +- src/Disqord.Voice/VoiceReceivePacket.cs | 192 +++++++ 50 files changed, 3393 insertions(+), 721 deletions(-) delete mode 100644 examples/Voice/BasicVoice/Audio/AudioMetadataKeys.cs create mode 100644 examples/Voice/BasicVoice/Audio/Song.cs create mode 100644 examples/Voice/BasicVoiceReceive/BasicVoiceReceive.csproj create mode 100644 examples/Voice/BasicVoiceReceive/Program.cs create mode 100644 examples/Voice/BasicVoiceReceive/README.md create mode 100644 examples/Voice/BasicVoiceReceive/RecordingModule.cs create mode 100644 examples/Voice/BasicVoiceReceive/RecordingService.cs create mode 100644 src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveBufferFullMode.cs create mode 100644 src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveEndBehaviorType.cs create mode 100644 src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveSubscriptionOptions.cs create mode 100644 src/Disqord.Extensions.Voice/Audio/Default/AudioReceiver.cs create mode 100644 src/Disqord.Extensions.Voice/Audio/Default/AudioReceiverSubscription.cs delete mode 100644 src/Disqord.Extensions.Voice/Audio/Default/AudioSource.cs create mode 100644 src/Disqord.Extensions.Voice/Audio/Default/OggOpusWriter.cs create mode 100644 src/Disqord.Extensions.Voice/Audio/IAudioSource.cs create mode 100644 src/Disqord.Extensions.Voice/VoiceConnectOptions.cs create mode 100644 src/Disqord.Voice/VoiceReceivePacket.cs diff --git a/Disqord.sln b/Disqord.sln index e6d0d292e..ffd46ccb1 100644 --- a/Disqord.sln +++ b/Disqord.sln @@ -65,6 +65,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{5D89D2F8 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{F3EE9740-ECB6-4224-807F-521AC1672A04}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicVoiceReceive", "examples\Voice\BasicVoiceReceive\BasicVoiceReceive.csproj", "{A402D4B7-22FD-4AAC-8175-0D378501600B}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Disqord.Tests", "Disqord.Tests\Disqord.Tests.csproj", "{13E985F4-4DC2-4B7B-BB8E-49A95E720C71}" EndProject Global @@ -281,6 +283,18 @@ Global {13E985F4-4DC2-4B7B-BB8E-49A95E720C71}.Release|x64.Build.0 = Release|Any CPU {13E985F4-4DC2-4B7B-BB8E-49A95E720C71}.Release|x86.ActiveCfg = Release|Any CPU {13E985F4-4DC2-4B7B-BB8E-49A95E720C71}.Release|x86.Build.0 = Release|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Debug|x64.ActiveCfg = Debug|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Debug|x64.Build.0 = Debug|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Debug|x86.ActiveCfg = Debug|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Debug|x86.Build.0 = Debug|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Release|Any CPU.Build.0 = Release|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Release|x64.ActiveCfg = Release|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Release|x64.Build.0 = Release|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Release|x86.ActiveCfg = Release|Any CPU + {A402D4B7-22FD-4AAC-8175-0D378501600B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -315,6 +329,7 @@ Global {4063DDC5-4595-4209-8AC8-B27F00B0C4B2} = {90A6850B-FB75-4ABA-9233-9E59AE6D4C98} {EE5D060B-0555-4712-89EE-F1B1295D76F7} = {4063DDC5-4595-4209-8AC8-B27F00B0C4B2} {13E985F4-4DC2-4B7B-BB8E-49A95E720C71} = {5D89D2F8-EC16-46AD-8D9C-362EDABC4383} + {A402D4B7-22FD-4AAC-8175-0D378501600B} = {4063DDC5-4595-4209-8AC8-B27F00B0C4B2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EFD09C91-3B86-4BB5-ABCD-4E8DD454F224} diff --git a/examples/Voice/BasicVoice/Audio/AudioMetadataKeys.cs b/examples/Voice/BasicVoice/Audio/AudioMetadataKeys.cs deleted file mode 100644 index 7d69ab874..000000000 --- a/examples/Voice/BasicVoice/Audio/AudioMetadataKeys.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace BasicVoice; - -/// -/// Represents audio source metadata keys. -/// -public static class AudioMetadataKeys -{ - /// - /// The title metadata key. - /// - public const string Title = nameof(Title); -} diff --git a/examples/Voice/BasicVoice/Audio/BasicAudioPlayer.cs b/examples/Voice/BasicVoice/Audio/BasicAudioPlayer.cs index 675a71f6f..a4eb1d08a 100644 --- a/examples/Voice/BasicVoice/Audio/BasicAudioPlayer.cs +++ b/examples/Voice/BasicVoice/Audio/BasicAudioPlayer.cs @@ -8,191 +8,117 @@ using Disqord.Voice; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Qommon.Metadata; namespace BasicVoice; -/// -/// Represents a basic implementation -/// that supports queueing audio sources and sends notifications -/// to a text channel. -/// -public class BasicAudioPlayer : AudioPlayer +// Basic player implementation that supports queueing and sends notifications to a text channel. +public class BasicAudioPlayer( + DiscordBotBase bot, + Snowflake notificationsChannelId, + IVoiceConnection connection) : AudioPlayer(connection) { - /// - /// Gets the bot of this audio player. - /// - public DiscordBotBase Bot { get; } - - /// - /// Gets the ID of the channel this audio player will send notifications to. - /// - public Snowflake NotificationsChannelId { get; } - - // The lock is necessary to prevent the race condition - // between Enqueue() and OnSourceFinished(). - private readonly object _queueLock = new(); - private readonly Queue _queue; + public DiscordBotBase Bot { get; } = bot; - public BasicAudioPlayer( - DiscordBotBase bot, - Snowflake notificationsChannelId, - IVoiceConnection connection) - : base(connection) - { - Bot = bot; - NotificationsChannelId = notificationsChannelId; + public Snowflake NotificationsChannelId { get; } = notificationsChannelId; - _queue = new(); - } + public Song? CurrentSong { get; private set; } - /// - /// Invoked when this audio player is stopped. - /// - /// The exception that caused the stop or if no exception occurred. - /// - /// A representing the work. - /// - protected override async ValueTask OnStopped(Exception? exception) + private readonly object _queueLock = new(); + private readonly Queue _queue = new(); + + public bool Enqueue(Song track) { - if (exception == null) + lock (_queueLock) { - // If an exception occurred, we'll log it. - Bot.Logger.LogError(exception, "An exception occurred in the audio player for guild ID {GuildId}.", GuildId); - - // Here you can add different handling for different exceptions that might occur - // but for VoiceConnectionException the logic should always be basically the same. - // VoiceConnectionException indicates that the connection object was rendered unusable - // because, for example, the bot was disconnected from the voice channel. - // We dispose of this audio player allowing for a new one to be created. - if (exception is VoiceConnectionException) + if (!TrySetSource(track.Source)) { - var playerService = Bot.Services.GetRequiredService(); - await playerService.DisposePlayerAsync(GuildId); + _queue.Enqueue(track); + return true; } + + CurrentSong = track; } + + return false; } - /// - /// Invoked when an audio source has finished playing. - /// Starts playing the next audio source if one is queued - /// and sends notifications to the channel with ID . - /// - /// The audio source that finished playing. - /// if the previous audio source was replaced with a new one. - /// - /// A representing the work. - /// - protected override ValueTask OnSourceFinished(AudioSource source, bool wasReplaced) + private async Task SendNotificationAsync(string content) { - var nextSource = PlayNextSource(); + // Yield to not delay playing the next audio track. + await Task.Yield(); - // Not awaited so that we don't block the audio playback. - _ = SendNotificationAsync(source, wasReplaced, exception: null, nextSource); - return default; + try + { + await Bot.SendMessageAsync(NotificationsChannelId, + new LocalMessage().WithContent(content)); + } + catch (Exception ex) + { + Bot.Logger.LogWarning(ex, "Failed to send notification for guild ID {GuildId}.", GuildId); + } } - /// - /// Invoked when an has errored, - /// i.e. thrown an exception. - /// - /// The that has errored. - /// The exception that occurred. - /// - /// A representing the work. - /// - protected override ValueTask OnSourceErrored(AudioSource source, Exception exception) + protected override ValueTask OnSourceStarted(IAudioSource source) { - // An error occurred in the audio source. - // We'll simply log it. - var title = source.GetMetadataOrDefault(AudioMetadataKeys.Title, null); - Bot.Logger.LogError(exception, "An exception occurred in the audio source '{Title}' ({AudioSourceType}) " - + "in the audio player for guild ID {GuildId}.", title ?? "unknown", source.GetType().Name, GuildId); - - var nextSource = PlayNextSource(); + var song = CurrentSong; + if (song != null) + { + _ = SendNotificationAsync($"Now playing {Markdown.Bold(song.Title)}."); + } - // Not awaited so that we don't block the audio playback. - _ = SendNotificationAsync(source, wasReplaced: false, exception, nextSource); return default; } - private AudioSource? PlayNextSource() + protected override ValueTask OnSourceFinished(IAudioSource source, bool wasReplaced) { - lock (_queueLock) + var song = CurrentSong; + if (song != null && !wasReplaced) { - if (_queue.TryDequeue(out var queuedSource)) - { - // If there's a queued source we start playing it. - if (TrySetSource(queuedSource)) - { - return queuedSource; - } - } + _ = SendNotificationAsync($"Finished playing {Markdown.Bold(song.Title)}."); } - return null; + PlayNextTrack(); + return default; } - private async Task SendNotificationAsync(AudioSource finishedSource, bool wasReplaced, Exception? exception, AudioSource? nextSource) + protected override ValueTask OnSourceErrored(IAudioSource source, Exception exception) { - // Yield, so the code below runs in background. - await Task.Yield(); + var displayName = CurrentSong?.Title ?? source.GetType().Name; + Bot.Logger.LogError(exception, "An exception occurred in audio source '{Title}' for guild ID {GuildId}.", displayName, GuildId); - // Below we access metadata on the audio sources. - // This metadata is set in AudioModule. - string? notification = null; - if (finishedSource.TryGetMetadata(AudioMetadataKeys.Title, out var title)) - { - if (exception != null) - { - notification += $"An error occurred while playing {Markdown.Code(title)}.\n"; - } - else - { - notification += $"{(wasReplaced ? "Skipped" : "Finished")} playing {Markdown.Code(title)}.\n"; - } - } + _ = SendNotificationAsync($"An error occurred while playing {Markdown.Bold(displayName)}."); - if (nextSource != null && nextSource.TryGetMetadata(AudioMetadataKeys.Title, out title)) - { - notification += $"Now playing {Markdown.Code(title)}."; - } + PlayNextTrack(); + return default; + } - if (notification != null) + protected override async ValueTask OnStopped(Exception? exception) + { + if (exception != null) { - try - { - var message = new LocalMessage().WithContent(notification.TrimStart()); - await Bot.SendMessageAsync(NotificationsChannelId, message); - } - catch (Exception ex) + Bot.Logger.LogError(exception, "An exception occurred in the audio player for guild ID {GuildId}.", GuildId); + + if (exception is VoiceConnectionException) { - // If an exception occurred, we'll log it. - Bot.Logger.LogError(ex, "An exception occurred while sending the notification in the audio player for guild ID {GuildId}.", GuildId); + var playerService = Bot.Services.GetRequiredService(); + await playerService.DisposePlayerAsync(GuildId); } } } - /// - /// Enqueues the specified audio source. - /// - /// The audio source to enqueue. - /// - /// if the source was enqueued; - /// if the source is being played immediately. - /// - public bool Enqueue(AudioSource source) + private void PlayNextTrack() { lock (_queueLock) { - if (!TrySetSource(source)) + if (_queue.TryDequeue(out var next)) { - // If a source is already playing we enqueue the source. - _queue.Enqueue(source); - return true; + CurrentSong = next; + TrySetSource(next.Source); + } + else + { + CurrentSong = null; } } - - return false; } } diff --git a/examples/Voice/BasicVoice/Audio/FFmpegAudioSource.cs b/examples/Voice/BasicVoice/Audio/FFmpegAudioSource.cs index 43ae48873..6d8e3eb2a 100644 --- a/examples/Voice/BasicVoice/Audio/FFmpegAudioSource.cs +++ b/examples/Voice/BasicVoice/Audio/FFmpegAudioSource.cs @@ -1,144 +1,93 @@ using System; -using System.Buffers; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; -using System.Text; using System.Threading; using System.Threading.Tasks; using Disqord.Extensions.Voice; namespace BasicVoice; -// Represents an audio source implementation that uses FFmpeg to produce -// the Opus audio packets. The input stream can be any format -// that FFmpeg is able to convert (virtually anything). -public class FFmpegAudioSource : AudioSource +public class FFmpegAudioSource(Stream stream) : IAudioSource { - private readonly Stream _stream; - - // The path to the FFmpeg executable. - // For this default value to work - // put FFmpeg in the bot's working directory or add it to PATH. private const string FFmpegPath = "ffmpeg"; - public FFmpegAudioSource(Stream stream) - { - _stream = stream; - } - private static void PopulateFFmpegArguments(Collection arguments) { - // You can add your own arguments here. - // For example, you could add support for changing the volume using: - // arguments.Add("-filter:a"); - // arguments.Add("\"volume=0.5\""); - // - // You could also set the volume float via the constructor. - // You'd then make PopulateFFmpegArguments non-static and set it dynamically. - // arguments.Add("-filter:a"); - // arguments.Add($"\"volume={Volume}\""); - // - // Keep in mind that the position of some FFmpeg arguments matters! - // - // Depending on their position in the argument string, - // they can behave differently. - // For example, `-ss` placed before `-i` is input seeking - // and placed after `-i` is output seeking. - - // [Recommended]: Sets the log level to error. arguments.Add("-loglevel"); arguments.Add("error"); - // [Required]: Sets the input to stdin. + // stdin as input arguments.Add("-i"); arguments.Add("pipe:0"); - // [Required]: Sets the audio codec to libopus. arguments.Add("-c:a"); arguments.Add("libopus"); - // [Required]: Sets the Opus application type. arguments.Add("-application"); arguments.Add("audio"); - // [Required]: Sets the Opus frame duration to 20ms. arguments.Add("-frame_duration"); arguments.Add("20"); - // [Required]: Sets the format to Ogg audio. arguments.Add("-f"); arguments.Add("oga"); - // [Required]: Sets the output to stdout. - // Must be the last argument. + // stdout as output arguments.Add("pipe:1"); } - public override async IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken) + public async IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken) { - // If the stream is seekable, rewind it to the beginning. - if (_stream.CanSeek) + await using (stream) { - // This project doesn't reuse audio sources, but if it did, - // this code would make this audio source reusable as long as - // the stream passed in allows for rewinding. - _stream.Seek(0, SeekOrigin.Begin); - } - - var ffmpegStartInfo = new ProcessStartInfo - { - FileName = FFmpegPath, - RedirectStandardOutput = true, - RedirectStandardInput = true, - RedirectStandardError = true, - UseShellExecute = false - }; + var ffmpegStartInfo = new ProcessStartInfo + { + FileName = FFmpegPath, + RedirectStandardOutput = true, + RedirectStandardInput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; - PopulateFFmpegArguments(ffmpegStartInfo.ArgumentList); + PopulateFFmpegArguments(ffmpegStartInfo.ArgumentList); - using (var ffmpeg = Process.Start(ffmpegStartInfo)!) - { - try + using (var ffmpeg = Process.Start(ffmpegStartInfo)!) { - // Start the error reading and stream copying tasks. - var readErrorTask = ReadFFmpegStderrAsync(ffmpeg.StandardError.BaseStream, ffmpeg.StandardError.CurrentEncoding, cancellationToken); - var copyStreamTask = CopyStreamToFFmpegStdinAsync(_stream, ffmpeg.StandardInput.BaseStream, cancellationToken); + try + { + var readErrorTask = ffmpeg.StandardError.ReadToEndAsync(cancellationToken); + var copyStreamTask = CopyStreamToFFmpegStdinAsync(stream, ffmpeg.StandardInput.BaseStream, cancellationToken); - // Create an Ogg source for stdout. - var ogg = new OggStreamAudioSource(ffmpeg.StandardOutput.BaseStream); + var ogg = new OggStreamAudioSource(ffmpeg.StandardOutput.BaseStream); + await foreach (var packet in ogg.WithCancellation(cancellationToken)) + { + yield return packet; + } - // Enumerate the Ogg source to yield Ogg packets. - await foreach (var packet in ogg.WithCancellation(cancellationToken)) - { - yield return packet; - } + if (ffmpeg.ExitCode != 0) + { + var error = await readErrorTask; + error = !string.IsNullOrWhiteSpace(error) + ? error.ReplaceLineEndings(";") + : "unknown error"; - // Check if FFmpeg exited with an error. - if (ffmpeg.ExitCode != 0) - { - var error = await readErrorTask; - error = !string.IsNullOrWhiteSpace(error) - ? error.ReplaceLineEndings(";") - : "unknown error"; + throw new Exception($"FFmpeg exited with code {ffmpeg.ExitCode} ({error})."); + } - throw new Exception($"FFmpeg exited with code {ffmpeg.ExitCode} ({error})."); + await copyStreamTask; + } + finally + { + await CleanUpFFmpegAsync(ffmpeg); } - - // Propagate the error from stream copying, if any. - await copyStreamTask; - } - finally - { - await CleanUpFFmpegAsync(ffmpeg); } } } private static async Task CleanUpFFmpegAsync(Process ffmpeg) { - // Start the exit waiting task. var exitTask = ffmpeg.WaitForExitAsync(default); // Close the data streams; order matters here. @@ -148,14 +97,13 @@ private static async Task CleanUpFFmpegAsync(Process ffmpeg) try { - // Give FFmpeg some time to close gracefully. + // Give FFmpeg some time to exit gracefully. await exitTask.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None); } catch (TimeoutException) { try { - // Kill FFmpeg if it took too long to close. ffmpeg.Kill(); } catch { } @@ -166,56 +114,10 @@ private static async Task CleanUpFFmpegAsync(Process ffmpeg) private static async Task CopyStreamToFFmpegStdinAsync(Stream stream, Stream stdin, CancellationToken cancellationToken) { - // Yield, so the code below runs in background. await Task.Yield(); - - // Copy the stream to stdin. await stream.CopyToAsync(stdin, cancellationToken); - // Close stdin after copying is complete. + // Close stdin to indicate we've fed FFmpeg all the data await stdin.DisposeAsync(); } - - // This method may seem complex, but it's actually - // just a bunch of boilerplate code that's necessary - // to efficiently, correctly, and asynchronously - // capture stderr of any size and convert it into a string. - private static async Task ReadFFmpegStderrAsync(Stream stderr, Encoding encoding, CancellationToken cancellationToken) - { - // Yield, so the code below runs in background. - await Task.Yield(); - - StringBuilder? sb = null; - var buffer = new byte[256]; - try - { - int bytesRead; - while ((bytesRead = await stderr.ReadAsync(buffer, cancellationToken)) != 0) - { - static void AppendChars(StringBuilder sb, Encoding errorEncoding, ReadOnlySpan bufferSpan) - { - var charCount = errorEncoding.GetCharCount(bufferSpan); - var chars = ArrayPool.Shared.Rent(charCount); - var charSpan = chars.AsSpan(0, charCount); - try - { - errorEncoding.GetChars(bufferSpan, charSpan); - sb.Append(charSpan); - } - finally - { - ArrayPool.Shared.Return(chars); - } - } - - AppendChars(sb ??= new(), encoding, buffer.AsSpan(0, bytesRead)); - } - } - catch - { - // Ignored, so that we can return any errors read thus far. - } - - return sb?.ToString(); - } } diff --git a/examples/Voice/BasicVoice/Audio/Song.cs b/examples/Voice/BasicVoice/Audio/Song.cs new file mode 100644 index 000000000..93fd448f0 --- /dev/null +++ b/examples/Voice/BasicVoice/Audio/Song.cs @@ -0,0 +1,10 @@ +using Disqord.Extensions.Voice; + +namespace BasicVoice; + +/// +/// Represents a song. +/// +/// The display title of the track. +/// The audio source to play. +public record Song(string Title, IAudioSource Source); diff --git a/examples/Voice/BasicVoice/AudioModule.cs b/examples/Voice/BasicVoice/AudioModule.cs index 041af45db..23a11e2cd 100644 --- a/examples/Voice/BasicVoice/AudioModule.cs +++ b/examples/Voice/BasicVoice/AudioModule.cs @@ -6,69 +6,54 @@ using Disqord.Bot.Commands.Application; using Disqord.Gateway; using Qmmands; -using Qommon.Metadata; namespace BasicVoice; /// /// Contains the audio slash commands. /// -public class AudioModule : DiscordApplicationGuildModuleBase +public class AudioModule(AudioPlayerService playerService) : DiscordApplicationGuildModuleBase { - private readonly AudioPlayerService _playerService; - - public AudioModule(AudioPlayerService playerService) - { - _playerService = playerService; - } - [SlashCommand("play")] [Description("Plays the audio track matching the specified query.")] public async Task Play( [Description("The audio track query.")] string query, [Description("The channel to initially connect to."), ChannelTypes(ChannelType.Voice)] IInteractionChannel? voiceChannel = null) { - var player = await _playerService.GetPlayerAsync(Context.GuildId); + await Deferral(); + + var player = await playerService.GetPlayerAsync(Context.GuildId); if (player == null) { - // Defer the response as we need time to connect. - await Deferral(); - var voiceChannelId = voiceChannel?.Id ?? Context.Author.GetVoiceState()?.ChannelId; if (voiceChannelId == null) + { return Response("Please provide a voice channel to connect to."); + } - player = await _playerService.ConnectPlayerAsync(Context.GuildId, voiceChannelId.Value, Context.ChannelId); + player = await playerService.ConnectPlayerAsync(Context.GuildId, voiceChannelId.Value, Context.ChannelId); } - // Do your audio querying here. - // For example purposes, we'll only look for files - // in the 'music' folder in the bot's working directory - // using the most basic string contains check. - var files = Directory.GetFiles("./music/"); + // Look for files matching the query in the Music library folder. + var files = Directory.EnumerateFiles(Environment.GetFolderPath(Environment.SpecialFolder.MyMusic), "*", SearchOption.AllDirectories); var file = files.FirstOrDefault(file => Path.GetFileNameWithoutExtension(file).Contains(query, StringComparison.OrdinalIgnoreCase)); if (file == null) + { return Response("No file found."); + } var source = new FFmpegAudioSource(File.OpenRead(file)); var title = Path.GetFileNameWithoutExtension(file); - // This sets a metadata value with the key 'Title'. - // It's a quick and easy way to attach metadata to AudioSource instances. - // A performant alternative would be to create AudioSource implementations - // that store data such as the title in actual fields. - // The audio player would then cast to those types and access the fields efficiently. - source.SetMetadata(AudioMetadataKeys.Title, title); - - var queued = player.Enqueue(source); - return Response($"{(queued ? "Queued" : "Playing")} {Markdown.Code(title)}."); + var queued = player.Enqueue(new Song(title, source)); + return Response($"{(queued ? "Queued" : "Playing")} {Markdown.Bold(title)}."); } [SlashCommand("pause")] [Description("Pauses the audio playback.")] public async Task Pause() { - var player = await _playerService.GetPlayerAsync(Context.GuildId); + var player = await playerService.GetPlayerAsync(Context.GuildId); if (player == null) { return Response("Not playing."); @@ -86,7 +71,7 @@ public async Task Pause() [Description("Resumes the audio playback if it's been paused.")] public async Task Resume() { - var player = await _playerService.GetPlayerAsync(Context.GuildId); + var player = await playerService.GetPlayerAsync(Context.GuildId); if (player == null) { return Response("Not playing."); @@ -94,7 +79,7 @@ public async Task Resume() if (!player.Resume()) { - return Response("Already resumed."); + return Response("Already playing."); } return Response("Resumed."); @@ -104,30 +89,29 @@ public async Task Resume() [Description("Skips the currently playing audio track.")] public async Task Skip() { - var player = await _playerService.GetPlayerAsync(Context.GuildId); - if (player == null || player.Source == null) + var player = await playerService.GetPlayerAsync(Context.GuildId); + if (player?.Source == null) { return Response("Not playing."); } - // Setting the source to null stops the currently playing source - // and our audio player will start playing the next source in queue. + // Setting the source to null stops it and starts the next queued source. player.Source = null; - return Response(new LocalInteractionMessageResponse().WithContent("Skipped.").WithIsEphemeral()); + return Response("Skipped."); } [SlashCommand("stop")] [Description("Stops the playback and disconnects the bot from the voice channel.")] public async Task Stop() { - var player = await _playerService.GetPlayerAsync(Context.GuildId); + var player = await playerService.GetPlayerAsync(Context.GuildId); if (player == null) { return Response("Not playing."); } - await _playerService.DisposePlayerAsync(Context.GuildId); + await playerService.DisposePlayerAsync(Context.GuildId); return Response("Disconnected."); } } diff --git a/examples/Voice/BasicVoice/AudioPlayerService.cs b/examples/Voice/BasicVoice/AudioPlayerService.cs index f9e0cce83..12424ab09 100644 --- a/examples/Voice/BasicVoice/AudioPlayerService.cs +++ b/examples/Voice/BasicVoice/AudioPlayerService.cs @@ -8,22 +8,11 @@ namespace BasicVoice; -/// -/// Represents a type responsible for maintaining active -/// audio players for guilds. -/// public class AudioPlayerService : DiscordBotService { private readonly Dictionary _players = new(); private readonly SemaphoreSlim _semaphore = new(1, 1); - public AudioPlayerService() - { } - - /// - /// Disposes of the audio players when the service is stopped, - /// i.e. on bot shutdown. - /// public override async Task StopAsync(CancellationToken cancellationToken) { await base.StopAsync(cancellationToken); @@ -33,12 +22,12 @@ public override async Task StopAsync(CancellationToken cancellationToken) await _semaphore.WaitAsync(cancellationToken); try { - foreach (var (guildID, player) in _players) + foreach (var (guildId, player) in _players) { player.Stop(); await player.DisposeAsync(); - await voiceExtension.DisconnectAsync(guildID); + await voiceExtension.DisconnectAsync(guildId); } _players.Clear(); @@ -49,15 +38,6 @@ public override async Task StopAsync(CancellationToken cancellationToken) } } - /// - /// Gets the audio player for the guild with the specified ID. - /// - /// The ID of the guild. - /// The cancellation token to observe. - /// - /// A with the result being the audio player - /// or if no player exists. - /// public async Task GetPlayerAsync(Snowflake guildId, CancellationToken cancellationToken = default) { await _semaphore.WaitAsync(cancellationToken); @@ -71,16 +51,6 @@ public override async Task StopAsync(CancellationToken cancellationToken) } } - /// - /// Connects the audio player for the guild with the specified ID. - /// - /// The ID of the guild. - /// The ID of the voice channel to connect to. - /// The ID of the text channel to send notifications to. - /// The cancellation token to observe. - /// - /// A with the result being the audio player. - /// public async Task ConnectPlayerAsync(Snowflake guildId, Snowflake voiceChannelId, Snowflake notificationsChannelId, CancellationToken cancellationToken = default) { await _semaphore.WaitAsync(cancellationToken); @@ -102,10 +72,6 @@ public async Task ConnectPlayerAsync(Snowflake guildId, Snowfl } } - /// - /// Disposes of the audio player for the guild with the specified ID. - /// - /// The ID of the guild. public async Task DisposePlayerAsync(Snowflake guildId) { await _semaphore.WaitAsync(); @@ -126,18 +92,11 @@ public async Task DisposePlayerAsync(Snowflake guildId) } } - /// - /// Disposes of audio players when the bot is disconnected from voice channels. - /// - /// - /// Note that our 's - /// already handles disposing of the player, but this allows for a graceful stop of the player in the case - /// where the voice state update arrives before the websocket connection is killed (it's a race condition). - /// - /// The event data. + // Disposes of the audio player when the bot is disconnected from a voice channel. + // BasicAudioPlayer.OnStopped already handles disposing, + // but this allows for a graceful stop when the voice state update arrives before the connection is killed. protected override async ValueTask OnVoiceStateUpdated(VoiceStateUpdatedEventArgs e) { - // Checks if the bot was disconnected from a voice channel. if (e.MemberId == Bot.CurrentUser.Id && e.NewVoiceState.ChannelId == null) { await DisposePlayerAsync(e.GuildId); diff --git a/examples/Voice/BasicVoice/Program.cs b/examples/Voice/BasicVoice/Program.cs index 72471c05b..9ae24e6e2 100644 --- a/examples/Voice/BasicVoice/Program.cs +++ b/examples/Voice/BasicVoice/Program.cs @@ -7,51 +7,42 @@ using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; -namespace BasicVoice +try { - internal sealed class Program + var host = Host.CreateApplicationBuilder(args); + + host.Services.AddSerilog(CreateSerilogLogger(), dispose: true); + + host.Services.AddVoiceExtension(); + + host.ConfigureDiscordBot(new DiscordBotHostingContext { - private static void Main(string[] args) - { - try - { - var host = Host.CreateApplicationBuilder(args); - - host.Services.AddSerilog(CreateSerilogLogger(), dispose: true); - - host.Services.AddVoiceExtension(); - - host.ConfigureDiscordBot(new DiscordBotHostingContext - { - // The token is set using the DISQORD_TOKEN environment variable. - Token = host.Configuration["DISQORD_TOKEN"], - - // We use slash commands; we don't need any privileged intents. - Intents = GatewayIntents.LibraryRecommended & GatewayIntents.Unprivileged, - - // We don't use text commands, so we disable the default mention prefix. - UseMentionPrefix = false - }); - - host.Build().Run(); - } - catch (Exception ex) - { - Console.WriteLine(ex); - Console.ReadLine(); - } - } - - private static ILogger CreateSerilogLogger() - { - return new LoggerConfiguration() - .MinimumLevel.Verbose() - .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) - .WriteTo.Async(sink => - sink.Console( - outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}", - theme: AnsiConsoleTheme.Code)) - .CreateLogger(); - } - } + // The token is set using the DISQORD_TOKEN environment variable. + Token = host.Configuration["DISQORD_TOKEN"], + + // We use slash commands; we don't need any privileged intents. + Intents = GatewayIntents.LibraryRecommended & GatewayIntents.Unprivileged, + + // We don't use text commands, so we disable the default mention prefix. + UseMentionPrefix = false + }); + + host.Build().Run(); +} +catch (Exception ex) +{ + Console.WriteLine(ex); + Console.ReadLine(); +} + +static ILogger CreateSerilogLogger() +{ + return new LoggerConfiguration() + .MinimumLevel.Verbose() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .WriteTo.Async(static sink => + sink.Console( + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}", + theme: AnsiConsoleTheme.Code)) + .CreateLogger(); } diff --git a/examples/Voice/BasicVoiceReceive/BasicVoiceReceive.csproj b/examples/Voice/BasicVoiceReceive/BasicVoiceReceive.csproj new file mode 100644 index 000000000..e21b48a28 --- /dev/null +++ b/examples/Voice/BasicVoiceReceive/BasicVoiceReceive.csproj @@ -0,0 +1,27 @@ + + + + Exe + false + net8.0 + + + + enable + Nullable + + + + + + + + + + + + + + + + diff --git a/examples/Voice/BasicVoiceReceive/Program.cs b/examples/Voice/BasicVoiceReceive/Program.cs new file mode 100644 index 000000000..289737f0f --- /dev/null +++ b/examples/Voice/BasicVoiceReceive/Program.cs @@ -0,0 +1,47 @@ +using System; +using Disqord.Bot.Hosting; +using Disqord.Extensions.Voice; +using Disqord.Gateway; +using Microsoft.Extensions.Hosting; +using Serilog; +using Serilog.Events; +using Serilog.Sinks.SystemConsole.Themes; + +try +{ + var host = Host.CreateApplicationBuilder(args); + + host.Services.AddSerilog(CreateSerilogLogger(), dispose: true); + host.Services.AddVoiceExtension(); + + host.ConfigureDiscordBot(new DiscordBotHostingContext + { + // The token is set using the DISQORD_TOKEN environment variable. + Token = host.Configuration["DISQORD_TOKEN"], + + // We use slash commands; we don't need any privileged intents. + Intents = GatewayIntents.LibraryRecommended & GatewayIntents.Unprivileged, + + // We don't use text commands, so we disable the default mention prefix. + UseMentionPrefix = false + }); + + host.Build().Run(); +} +catch (Exception ex) +{ + Console.WriteLine(ex); + Console.ReadLine(); +} + +static ILogger CreateSerilogLogger() +{ + return new LoggerConfiguration() + .MinimumLevel.Verbose() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .WriteTo.Async(static sink => + sink.Console( + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}", + theme: AnsiConsoleTheme.Code)) + .CreateLogger(); +} diff --git a/examples/Voice/BasicVoiceReceive/README.md b/examples/Voice/BasicVoiceReceive/README.md new file mode 100644 index 000000000..c1aebde4f --- /dev/null +++ b/examples/Voice/BasicVoiceReceive/README.md @@ -0,0 +1,23 @@ +# BasicVoiceReceive +This example project records voice from users in a voice channel and uploads per-user OGG Opus files when recording stops. + +It showcases the audio receive APIs and one way to subscribe to either a single user or an entire channel. + +## Requirements +- Sodium + Disqord requires the Sodium library for voice packet encryption. You can download pre-built Sodium binaries [here](https://doc.libsodium.org/installation#pre-built-libraries). On Windows, you can ensure the library gets put into the bot's working directory correctly by using + ```xml + + + PreserveNewest + + + ``` + +## Commands +- `/record [user] [channel]` + Joins the selected voice channel, or your current one if `channel` is omitted, and starts recording either: + - a specific chosen user, or + - the entire channel (default, including users who join later). +- `/stop` + Stops recording, disconnects, and uploads up to 10 per-user OGG Opus files in the command response. diff --git a/examples/Voice/BasicVoiceReceive/RecordingModule.cs b/examples/Voice/BasicVoiceReceive/RecordingModule.cs new file mode 100644 index 000000000..6dfac2845 --- /dev/null +++ b/examples/Voice/BasicVoiceReceive/RecordingModule.cs @@ -0,0 +1,74 @@ +using System.Linq; +using System.Threading.Tasks; +using Disqord; +using Disqord.Bot.Commands.Application; +using Disqord.Gateway; +using Qmmands; + +namespace BasicVoiceReceive; + +public class RecordingModule(RecordingService recordingService) : DiscordApplicationGuildModuleBase +{ + [SlashCommand("record")] + [Description("Starts recording voice in a channel.")] + public async Task Record( + [Description("A specific user to record. Leave empty for the entire channel.")] IMember? user = null, + [Description("The channel to record in."), ChannelTypes(ChannelType.Voice)] IInteractionChannel? channel = null) + { + await Deferral(); + + var channelId = channel?.Id ?? Context.Author.GetVoiceState()?.ChannelId; + if (channelId == null) + { + return Response("Please provide a voice channel to record in."); + } + + var started = await recordingService.StartRecordingAsync(Context.GuildId, channelId.Value, user?.Id); + if (!started) + { + return Response("Already recording in this guild."); + } + + var responseText = user != null + ? $"Recording {Markdown.Bold(user.Nick ?? user.Name)}." + : "Recording the channel."; + + return Response($"{responseText} Use `/stop` to finish."); + } + + [SlashCommand("stop")] + [Description("Stops the active recording and uploads the result.")] + public async Task Stop() + { + await Deferral(); + + var streams = await recordingService.StopRecordingAsync(Context.GuildId); + if (streams == null) + { + return Response("Not recording."); + } + + if (streams.Count == 0) + { + return Response("Stopped recording, but no audio was captured."); + } + + var streamsToUpload = streams.Take(10).ToArray(); + var omittedCount = streams.Count - streamsToUpload.Length; + var content = $"Recording complete for {streamsToUpload.Length} user(s)."; + if (omittedCount > 0) + { + content += $" Omitted {omittedCount} recording(s) due to attachment limits."; + } + + var response = new LocalInteractionMessageResponse() + .WithContent(content); + + foreach (var (userId, stream) in streamsToUpload) + { + response.AddAttachment(new LocalAttachment(stream, $"recording-{userId}.ogg")); + } + + return Response(response); + } +} diff --git a/examples/Voice/BasicVoiceReceive/RecordingService.cs b/examples/Voice/BasicVoiceReceive/RecordingService.cs new file mode 100644 index 000000000..82ad68117 --- /dev/null +++ b/examples/Voice/BasicVoiceReceive/RecordingService.cs @@ -0,0 +1,288 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Disqord; +using Disqord.Bot.Hosting; +using Disqord.Extensions.Voice; +using Disqord.Voice; +using Microsoft.Extensions.Logging; + +namespace BasicVoiceReceive; + +public class RecordingService : DiscordBotService +{ + private readonly Dictionary _sessions = []; + private readonly SemaphoreSlim _semaphore = new(1, 1); + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await base.StopAsync(cancellationToken); + + await _semaphore.WaitAsync(cancellationToken); + RecordingSession[] sessions; + try + { + sessions = _sessions.Values.ToArray(); + _sessions.Clear(); + } + finally + { + _semaphore.Release(); + } + + var voiceExtension = Bot.GetRequiredExtension(); + foreach (var session in sessions) + { + try + { + await session.DisposeAsync(); + await voiceExtension.DisconnectAsync(session.GuildId); + } + catch (Exception ex) + { + Bot.Logger.LogError(ex, "An exception occurred while stopping an active recording session."); + } + } + } + + public async Task StartRecordingAsync(Snowflake guildId, Snowflake voiceChannelId, Snowflake? targetUserId) + { + await _semaphore.WaitAsync(); + try + { + if (_sessions.ContainsKey(guildId)) + { + return false; + } + + var voiceExtension = Bot.GetRequiredExtension(); + RecordingSession? session = null; + try + { + var connection = await voiceExtension.ConnectAsync(guildId, voiceChannelId, new VoiceConnectOptions + { + SelfDeafen = false + }); + + session = new RecordingSession(guildId, connection, targetUserId); + _sessions[guildId] = session; + session.Start(); + + return true; + } + catch + { + _sessions.Remove(guildId); + + if (session != null) + { + await session.DisposeAsync(); + } + + await voiceExtension.DisconnectAsync(guildId); + throw; + } + } + finally + { + _semaphore.Release(); + } + } + + public async Task?> StopRecordingAsync(Snowflake guildId) + { + RecordingSession? session; + await _semaphore.WaitAsync(); + try + { + if (!_sessions.Remove(guildId, out session)) + { + return null; + } + } + finally + { + _semaphore.Release(); + } + + try + { + return await session.StopAndCollectAsync(); + } + finally + { + await session.DisposeAsync(); + + var voiceExtension = Bot.GetRequiredExtension(); + await voiceExtension.DisconnectAsync(guildId); + } + } + + private sealed class RecordingSession(Snowflake guildId, IVoiceConnection connection, Snowflake? targetUserId) : IAsyncDisposable + { + public Snowflake GuildId { get; } = guildId; + + private static readonly AudioReceiveSubscriptionOptions RecordingOptions = new() + { + EndBehaviorType = AudioReceiveEndBehaviorType.Manual, + MaxBufferedDuration = TimeSpan.Zero + }; + + private readonly AudioReceiver _receiver = new(connection); + private readonly CancellationTokenSource _cts = new(); + private readonly Dictionary _recordings = []; + private readonly Dictionary _activeTasks = []; + private Task? _listenTask; + + public void Start() + { + _listenTask = ListenLoopAsync(_cts.Token); + } + + private async Task ListenLoopAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var subscription in _receiver.ListenAsync( + userId => targetUserId == null || userId == targetUserId ? RecordingOptions : null, + _cts.Token)) + { + var userId = subscription.UserId; + + // Await any previous write task for this user (handles voice reconnect). + if (_activeTasks.TryGetValue(userId, out var previousTask)) + { + await previousTask; + } + + // Reuse existing recording for this user so reconnects produce a single file. + if (!_recordings.TryGetValue(userId, out var recording)) + { + recording = new UserRecording(userId); + _recordings[userId] = recording; + } + + _activeTasks[userId] = Task.Run(() => recording.RunAsync(subscription), cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { } + } + + public async Task> StopAndCollectAsync() + { + _cts.Cancel(); + + if (_listenTask != null) + { + await _listenTask; + } + + await _receiver.DisposeAsync(); + await Task.WhenAll(_activeTasks.Values); + + var results = new List<(Snowflake UserId, Stream Stream)>(); + foreach (var recording in _recordings.Values) + { + var stream = recording.Collect(); + if (stream != null) + { + results.Add((recording.UserId, stream)); + } + } + + return results; + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + + if (_listenTask != null) + { + try + { + await _listenTask; + } + catch (OperationCanceledException) + { } + } + + await _receiver.DisposeAsync(); + await Task.WhenAll(_activeTasks.Values); + + foreach (var recording in _recordings.Values) + { + recording.Dispose(); + } + + _recordings.Clear(); + _activeTasks.Clear(); + _cts.Dispose(); + } + } + + private sealed class UserRecording : IDisposable + { + public Snowflake UserId { get; } + + private readonly MemoryStream _stream = new(); + private readonly OggOpusWriter _writer; + private long _disconnectedAt; + private bool _hasDisconnectedAt; + + public UserRecording(Snowflake userId) + { + UserId = userId; + _writer = new OggOpusWriter(_stream); + } + + public async Task RunAsync(AudioReceiverSubscription subscription) + { + if (_hasDisconnectedAt) + { + var reconnectGap = Stopwatch.GetElapsedTime(_disconnectedAt, Stopwatch.GetTimestamp()); + _hasDisconnectedAt = false; + _writer.WriteSilence(reconnectGap); + } + + _writer.ResetRtpTimestampBase(); + + try + { + await foreach (var packet in subscription) + { + try + { + _writer.WritePacket(packet.Opus.Span, packet.Timestamp); + } + finally + { + packet.Dispose(); + } + } + } + finally + { + _disconnectedAt = Stopwatch.GetTimestamp(); + _hasDisconnectedAt = true; + } + } + + public Stream? Collect() + { + _writer.Complete(); + _stream.Position = 0; + return _writer.PacketCount > 0 ? _stream : null; + } + + public void Dispose() + { + _writer.Dispose(); + } + } +} diff --git a/src/Disqord.Extensions.Voice/Audio/Default/AudioPlayer.cs b/src/Disqord.Extensions.Voice/Audio/Default/AudioPlayer.cs index ded3ac958..df2cd400c 100644 --- a/src/Disqord.Extensions.Voice/Audio/Default/AudioPlayer.cs +++ b/src/Disqord.Extensions.Voice/Audio/Default/AudioPlayer.cs @@ -31,7 +31,7 @@ public class AudioPlayer : IAsyncDisposable /// /// Gets or sets the audio source currently being played by this audio player. /// - public AudioSource? Source + public IAudioSource? Source { get { @@ -112,7 +112,7 @@ protected virtual SpeakingFlags SpeakingFlags private readonly object _sourceLock = new(); private Cts _sourceCts; - private Tcs _source; + private Tcs _source; private readonly AsyncManualResetEvent _pauseAmre; private readonly object _stopLock = new(); @@ -134,40 +134,54 @@ protected void ThrowIfDisposed() } /// - /// Invoked when an has started playing. + /// Invoked when an audio source has started playing. /// - /// The that has started playing. + /// The audio source that has started playing. /// /// A representing the work. /// - protected virtual ValueTask OnSourceStarted(AudioSource source) + protected virtual ValueTask OnSourceStarted(IAudioSource source) { return default; } /// - /// Invoked when an has finished playing. + /// Invoked when an audio source has finished playing. /// - /// The that has finished playing. + /// The audio source that has finished playing. /// if the previous audio source was replaced with a new one. /// /// A representing the work. /// - protected virtual ValueTask OnSourceFinished(AudioSource source, bool wasReplaced) + protected virtual ValueTask OnSourceFinished(IAudioSource source, bool wasReplaced) { return default; } /// - /// Invoked when an has errored, + /// Invoked when an audio source has errored, /// i.e. thrown an exception. /// - /// The that has errored. + /// The audio source that has errored. /// The exception that occurred. /// /// A representing the work. /// - protected virtual ValueTask OnSourceErrored(AudioSource source, Exception exception) + protected virtual ValueTask OnSourceErrored(IAudioSource source, Exception exception) + { + return default; + } + + /// + /// Invoked when an audio packet has been sent to the voice connection. + /// This is called on the player loop for every 20ms packet, so implementations + /// should avoid heavy or async work to prevent playback delays. + /// + /// The audio source the packet was read from. + /// + /// A representing the work. + /// + protected virtual ValueTask OnSourcePacketSent(IAudioSource source) { return default; } @@ -225,7 +239,7 @@ protected virtual ValueTask OnStopped(Exception? exception) /// /// if the source was set. /// - public bool TrySetSource(AudioSource source) + public bool TrySetSource(IAudioSource source) { ThrowIfDisposed(); @@ -312,21 +326,6 @@ public bool Stop() } } - /// - /// Moves this audio player to a different channel within the guild. - /// - /// The ID of the channel to move to. - /// The cancellation token to observe. - /// - /// A representing the work. - /// - public ValueTask SetChannelIdAsync(Snowflake channelId, CancellationToken cancellationToken) - { - ThrowIfDisposed(); - - return Connection.SetChannelIdAsync(channelId, cancellationToken); - } - private static async Task SendSilenceAsync(IVoiceConnection connection, CancellationToken cancellationToken) { for (var i = 0; i < 5; i++) @@ -350,7 +349,8 @@ private async Task WaitIfPausedAsync(CancellationToken cancellationToken) } catch (Exception ex) when (ex is not OperationCanceledException) { - // TODO: handle exception + // Silence is cosmetic; failure doesn't affect pause correctness. + // On resume, speaking flags are re-sent. } await pauseTask.ConfigureAwait(false); @@ -360,7 +360,7 @@ private async Task WaitIfPausedAsync(CancellationToken cancellationToken) } } - private void ResetSource(Tcs source) + private void ResetSource(Tcs source) { lock (_sourceLock) { @@ -371,7 +371,7 @@ private void ResetSource(Tcs source) } } - private async Task HandleSourceErroredAsync(Tcs source, Exception exception) + private async Task HandleSourceErroredAsync(Tcs source, Exception exception) { ResetSource(source); @@ -388,12 +388,11 @@ private async Task ExecuteAsync(CancellationToken cancellationToken) { await OnStarted().ConfigureAwait(false); - await connection.WaitUntilReadyAsync(cancellationToken).ConfigureAwait(false); while (!cancellationToken.IsCancellationRequested) { await SendSilenceAsync(connection, cancellationToken).ConfigureAwait(false); - Tcs sourceTask; + Tcs sourceTask; lock (_sourceLock) { sourceTask = _source; @@ -410,11 +409,18 @@ private async Task ExecuteAsync(CancellationToken cancellationToken) using (var linkedCts = Cts.Linked(cancellationToken, sourceCancellationToken)) { var linkedCancellationToken = linkedCts.Token; - await connection.SetSpeakingFlagsAsync(SpeakingFlags, linkedCancellationToken).ConfigureAwait(false); + try + { + await connection.SetSpeakingFlagsAsync(SpeakingFlags, linkedCancellationToken).ConfigureAwait(false); - await OnSourceStarted(source).ConfigureAwait(false); + await OnSourceStarted(source).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + continue; + } - IAsyncEnumerator>? enumerator = null; + IAsyncEnumerator>? enumerator = null; try { try @@ -444,7 +450,7 @@ private async Task ExecuteAsync(CancellationToken cancellationToken) await WaitIfPausedAsync(linkedCancellationToken).ConfigureAwait(false); - Memory packet; + ReadOnlyMemory packet; try { packet = enumerator.Current; @@ -459,10 +465,13 @@ private async Task ExecuteAsync(CancellationToken cancellationToken) try { await connection.SendPacketAsync(packet, linkedCancellationToken).ConfigureAwait(false); + await OnSourcePacketSent(source).ConfigureAwait(false); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is not OperationCanceledException and not VoiceConnectionException) { - // TODO: handle exception + // Transient send failures (e.g. DAVE key rotation) self-recover on the + // next packet. The synchronizer tick naturally throttles the loop. + // VoiceConnectionException is not caught here - it stops the player. } } @@ -500,7 +509,7 @@ private async Task ExecuteAsync(CancellationToken cancellationToken) { if (exception is not VoiceConnectionException) { - await connection.SetSpeakingFlagsAsync(SpeakingFlags, cancellationToken).ConfigureAwait(false); + await connection.SetSpeakingFlagsAsync(SpeakingFlags.None, default).ConfigureAwait(false); } } finally @@ -512,13 +521,24 @@ private async Task ExecuteAsync(CancellationToken cancellationToken) } /// - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { if (_isDisposed) - return default; + return; _isDisposed = true; + await DisposeAsyncCore().ConfigureAwait(false); + + GC.SuppressFinalize(this); + } + + /// + /// Performs the async cleanup. Override this in derived classes to dispose additional resources. + /// Always call base.DisposeAsyncCore() to ensure the player is stopped. + /// + protected virtual ValueTask DisposeAsyncCore() + { lock (_stopLock) { if (_stopCts != null) diff --git a/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveBufferFullMode.cs b/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveBufferFullMode.cs new file mode 100644 index 000000000..d73784d05 --- /dev/null +++ b/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveBufferFullMode.cs @@ -0,0 +1,22 @@ +namespace Disqord.Extensions.Voice; + +/// +/// Specifies how the subscription buffer behaves when full. +/// +public enum AudioReceiveBufferFullMode +{ + /// + /// Drop the oldest buffered packet to make room for the new one. + /// + DropOldest, + + /// + /// Backpressure the producer until space is available in the buffer. + /// + Wait, + + /// + /// Drop the newest incoming packet when the buffer is full. + /// + DropNewest, +} diff --git a/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveEndBehaviorType.cs b/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveEndBehaviorType.cs new file mode 100644 index 000000000..dd51964f9 --- /dev/null +++ b/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveEndBehaviorType.cs @@ -0,0 +1,22 @@ +namespace Disqord.Extensions.Voice; + +/// +/// Specifies how a receive subscription determines when to end. +/// +public enum AudioReceiveEndBehaviorType +{ + /// + /// The subscription remains open until explicitly disposed. + /// + Manual, + + /// + /// The subscription ends after a period of no packets (including silence). + /// + AfterInactivity, + + /// + /// The subscription ends after a period of only silence packets. + /// + AfterSilence, +} diff --git a/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveSubscriptionOptions.cs b/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveSubscriptionOptions.cs new file mode 100644 index 000000000..e78de1540 --- /dev/null +++ b/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiveSubscriptionOptions.cs @@ -0,0 +1,31 @@ +using System; + +namespace Disqord.Extensions.Voice; + +/// +/// Specifies options for an . +/// +public class AudioReceiveSubscriptionOptions +{ + /// + /// Gets or sets the end behavior type for this subscription. Defaults to . + /// + public AudioReceiveEndBehaviorType EndBehaviorType { get; set; } = AudioReceiveEndBehaviorType.Manual; + + /// + /// Gets or sets the duration used by + /// and end behaviors. + /// + public TimeSpan EndBehaviorDuration { get; set; } = TimeSpan.FromMilliseconds(750); + + /// + /// Gets or sets the maximum amount of packet time buffered in memory. + /// Set to a value less than or equal to zero to disable buffering limits (unbounded). + /// + public TimeSpan MaxBufferedDuration { get; set; } = TimeSpan.FromSeconds(5); + + /// + /// Gets or sets how writes behave when the subscription buffer is full. + /// + public AudioReceiveBufferFullMode BufferFullMode { get; set; } = AudioReceiveBufferFullMode.DropOldest; +} diff --git a/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiver.cs b/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiver.cs new file mode 100644 index 000000000..a12395059 --- /dev/null +++ b/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiver.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Disqord.Voice; + +namespace Disqord.Extensions.Voice; + +/// +/// Manages per-user audio receive subscriptions for a voice connection. +/// Handles packet routing, silence gap synthesis, and subscription lifecycle. +/// +public class AudioReceiver : IAsyncDisposable +{ + /// + /// Gets the underlying voice connection. + /// + public IVoiceConnection Connection { get; } + + private readonly object _subscriptionLock = new(); + private readonly Dictionary _subscriptions = []; + private bool _isListening; + private bool _isDisposed; + + private sealed class SubscriptionState(AudioReceiverSubscription subscription) + { + public AudioReceiverSubscription Subscription { get; } = subscription; + + public uint LastSsrc { get; set; } + + public ushort LastSequence { get; set; } + + public bool HasLastSequence { get; set; } + } + + public AudioReceiver(IVoiceConnection connection) + { + Connection = connection; + connection.UserDisconnected += OnUserDisconnected; + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + } + + private void OnUserDisconnected(Snowflake userId) + { + lock (_subscriptionLock) + { + if (_subscriptions.TryGetValue(userId, out var state)) + { + state.Subscription.Complete(); + } + } + } + + /// + /// Listens for users connecting to the voice session and yields subscriptions based on the provided predicate. + /// When a user connects, the predicate is called with their ID. If it returns options, a subscription is created and yielded. + /// Subscriptions are automatically completed when the user disconnects. + /// + /// A predicate that returns subscription options for users to listen to, or to skip. + /// The cancellation token to observe. Cancelling stops listening for new subscriptions. + /// + /// An async enumerable of subscriptions for connecting users. + /// + public async IAsyncEnumerable ListenAsync( + Func userPredicate, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + lock (_subscriptionLock) + { + if (_isListening) + { + throw new InvalidOperationException("ListenAsync is already active."); + } + + _isListening = true; + } + + var newUserChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + Connection.UserConnected += OnUserConnected; + await Connection.SetPacketSinkAsync(OnPacketReceivedAsync, cancellationToken).ConfigureAwait(false); + + try + { + // Snapshot currently connected users. Any user that connected between + // hooking the event and this read is either in the snapshot or in the channel (or both). + foreach (var userId in Connection.ConnectedUserIds) + { + var subscription = TryCreateListenSubscription(userId, userPredicate); + if (subscription != null) + { + yield return subscription; + } + } + + await foreach (var userId in newUserChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + var subscription = TryCreateListenSubscription(userId, userPredicate); + if (subscription != null) + { + yield return subscription; + } + } + } + finally + { + Connection.UserConnected -= OnUserConnected; + + lock (_subscriptionLock) + { + _isListening = false; + } + } + + yield break; + + void OnUserConnected(Snowflake userId) + { + newUserChannel.Writer.TryWrite(userId); + } + } + + private AudioReceiverSubscription? TryCreateListenSubscription(Snowflake userId, Func userPredicate) + { + lock (_subscriptionLock) + { + if (_subscriptions.ContainsKey(userId)) + { + return null; + } + } + + var options = userPredicate(userId); + if (options == null) + { + return null; + } + + lock (_subscriptionLock) + { + if (_subscriptions.ContainsKey(userId)) + { + return null; + } + + var subscription = new AudioReceiverSubscription(userId, options); + subscription.Closed += OnSubscriptionClosed; + _subscriptions[userId] = new SubscriptionState(subscription); + return subscription; + } + } + + /// + /// Subscribes to audio packets from the specified user. + /// If a subscription already exists for this user, the existing subscription is returned. + /// Automatically enables receiving on the voice connection when the first subscription is created. + /// + /// The ID of the user to subscribe to. + /// The subscription options, or to use defaults. + /// The cancellation token to observe. + /// The audio receive subscription for the user. + public async ValueTask SubscribeAsync(Snowflake userId, AudioReceiveSubscriptionOptions? options = null, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + bool shouldEnable; + AudioReceiverSubscription subscription; + lock (_subscriptionLock) + { + if (_subscriptions.TryGetValue(userId, out var existingState)) + { + return existingState.Subscription; + } + + subscription = new AudioReceiverSubscription(userId, options ?? new AudioReceiveSubscriptionOptions()); + subscription.Closed += OnSubscriptionClosed; + _subscriptions[userId] = new SubscriptionState(subscription); + shouldEnable = _subscriptions.Count == 1; + } + + if (shouldEnable) + { + await Connection.SetPacketSinkAsync(OnPacketReceivedAsync, cancellationToken).ConfigureAwait(false); + } + + return subscription; + } + + /// + /// Unsubscribes from audio packets for the specified user and disposes the subscription. + /// + /// The ID of the user to unsubscribe from. + /// if a subscription was found and removed; otherwise, . + public async ValueTask UnsubscribeAsync(Snowflake userId) + { + AudioReceiverSubscription? subscription; + lock (_subscriptionLock) + { + if (!_subscriptions.Remove(userId, out var state)) + { + return false; + } + + subscription = state.Subscription; + subscription.Closed -= OnSubscriptionClosed; + } + + await subscription.DisposeAsync().ConfigureAwait(false); + return true; + } + + /// + /// Gets a snapshot of all active subscriptions keyed by user ID. + /// + public IReadOnlyDictionary GetSubscriptions() + { + lock (_subscriptionLock) + { + return _subscriptions.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value.Subscription); + } + } + + private void OnSubscriptionClosed(AudioReceiverSubscription subscription) + { + lock (_subscriptionLock) + { + if (_subscriptions.TryGetValue(subscription.UserId, out var state) + && state.Subscription == subscription) + { + _subscriptions.Remove(subscription.UserId); + } + } + } + + private async ValueTask OnPacketReceivedAsync(VoiceReceivePacket packet) + { + if (packet.UserId == null) + { + packet.Dispose(); + return; + } + + SubscriptionState? state; + lock (_subscriptionLock) + { + _subscriptions.TryGetValue(packet.UserId.Value, out state); + } + + if (state == null) + { + packet.Dispose(); + return; + } + + // When a user reconnects, they get a new SSRC with fresh sequence numbers. + // Reset tracking so we don't misinterpret the new stream as out-of-order or a huge gap. + if (state.HasLastSequence && packet.Ssrc != state.LastSsrc) + { + state.HasLastSequence = false; + } + + if (state.HasLastSequence) + { + var sequenceDelta = (ushort) (packet.Sequence - state.LastSequence); + + // Duplicate packet. + if (sequenceDelta == 0) + { + packet.Dispose(); + return; + } + + // Out-of-order / late packet. + if (sequenceDelta >= 0x8000) + { + packet.Dispose(); + return; + } + } + + state.LastSsrc = packet.Ssrc; + state.LastSequence = packet.Sequence; + state.HasLastSequence = true; + + if (!await state.Subscription.WriteAsync(packet).ConfigureAwait(false)) + { + packet.Dispose(); + } + } + + /// + public async ValueTask DisposeAsync() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + Connection.UserDisconnected -= OnUserDisconnected; + await Connection.SetPacketSinkAsync(null).ConfigureAwait(false); + + AudioReceiverSubscription[] subscriptions; + lock (_subscriptionLock) + { + subscriptions = _subscriptions.Values.Select(static x => x.Subscription).ToArray(); + _subscriptions.Clear(); + } + + foreach (var subscription in subscriptions) + { + subscription.Closed -= OnSubscriptionClosed; + await subscription.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiverSubscription.cs b/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiverSubscription.cs new file mode 100644 index 000000000..4a22560fb --- /dev/null +++ b/src/Disqord.Extensions.Voice/Audio/Default/AudioReceiverSubscription.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Disqord.Voice; + +namespace Disqord.Extensions.Voice; + +/// +/// Represents a subscription to a single user's audio receive stream. +/// Packets are delivered as an async enumerable of received voice packets. +/// The consumer owns each yielded packet and must dispose it after use. +/// +public class AudioReceiverSubscription : IAsyncEnumerable, IAsyncDisposable +{ + /// + /// Gets the ID of the user this subscription is receiving audio from. + /// + public Snowflake UserId { get; } + + /// + /// Gets the options used to configure this subscription. + /// + public AudioReceiveSubscriptionOptions Options { get; } + + private readonly Channel _channel; + private readonly object _timerLock = new(); + private Timer? _endTimer; + private int _isCompleted; + + internal event Action? Closed; + + internal AudioReceiverSubscription(Snowflake userId, AudioReceiveSubscriptionOptions options) + { + UserId = userId; + Options = options; + var maxBufferedDuration = options.MaxBufferedDuration; + if (maxBufferedDuration <= TimeSpan.Zero) + { + _channel = Channel.CreateUnbounded(); + } + else + { + var capacity = Math.Max(1, (int) Math.Ceiling(maxBufferedDuration.TotalMilliseconds / VoiceConstants.DurationMilliseconds)); + var fullMode = options.BufferFullMode switch + { + AudioReceiveBufferFullMode.Wait => BoundedChannelFullMode.Wait, + AudioReceiveBufferFullMode.DropNewest => BoundedChannelFullMode.DropWrite, + _ => BoundedChannelFullMode.DropOldest + }; + + var channelOptions = new BoundedChannelOptions(capacity) + { + FullMode = fullMode, + SingleWriter = true, + }; + + _channel = fullMode == BoundedChannelFullMode.DropOldest + ? Channel.CreateBounded(channelOptions, static (packet) => packet.Dispose()) + : Channel.CreateBounded(channelOptions); + } + } + + internal ValueTask WriteAsync(VoiceReceivePacket packet) + { + if (Volatile.Read(ref _isCompleted) != 0) + return new ValueTask(false); + + var opus = packet.Opus; + if (Options.EndBehaviorType == AudioReceiveEndBehaviorType.AfterInactivity + || (Options.EndBehaviorType == AudioReceiveEndBehaviorType.AfterSilence + && (!opus.Span.SequenceEqual(VoiceConstants.SilencePacket.Span) || _endTimer == null))) + { + RenewEndTimer(); + } + + if (_channel.Writer.TryWrite(packet)) + return new ValueTask(true); + + if (Options.BufferFullMode != AudioReceiveBufferFullMode.Wait) + return new ValueTask(false); + + return WriteSlowAsync(packet); + } + + private async ValueTask WriteSlowAsync(VoiceReceivePacket packet) + { + while (await _channel.Writer.WaitToWriteAsync().ConfigureAwait(false)) + { + if (Volatile.Read(ref _isCompleted) != 0) + return false; + + if (_channel.Writer.TryWrite(packet)) + return true; + } + + return false; + } + + private void RenewEndTimer() + { + if (Options.EndBehaviorType == AudioReceiveEndBehaviorType.Manual) + return; + + if (Options.EndBehaviorDuration <= TimeSpan.Zero) + { + Complete(); + return; + } + + lock (_timerLock) + { + if (_endTimer != null) + { + _endTimer.Change(Options.EndBehaviorDuration, Timeout.InfiniteTimeSpan); + } + else + { + _endTimer = new Timer(static state => ((AudioReceiverSubscription) state!).Complete(), + this, Options.EndBehaviorDuration, Timeout.InfiniteTimeSpan); + } + } + } + + internal void Complete() + { + if (Interlocked.Exchange(ref _isCompleted, 1) != 0) + return; + + lock (_timerLock) + { + _endTimer?.Dispose(); + _endTimer = null; + } + + _channel.Writer.TryComplete(); + Closed?.Invoke(this); + } + + /// + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return new PacketEnumerator(_channel.Reader, cancellationToken); + } + + /// + public ValueTask DisposeAsync() + { + Complete(); + + // Drain and dispose any remaining pooled packets in the channel. + while (_channel.Reader.TryRead(out var packet)) + { + packet.Dispose(); + } + + return default; + } + + private sealed class PacketEnumerator : IAsyncEnumerator + { + private readonly ChannelReader _reader; + private readonly CancellationToken _cancellationToken; + private VoiceReceivePacket _current; + private bool _hasCurrent; + + public PacketEnumerator(ChannelReader reader, CancellationToken cancellationToken) + { + _reader = reader; + _cancellationToken = cancellationToken; + } + + public VoiceReceivePacket Current => _current; + + public async ValueTask MoveNextAsync() + { + if (_hasCurrent) + { + _current = default; + _hasCurrent = false; + } + + while (await _reader.WaitToReadAsync(_cancellationToken).ConfigureAwait(false)) + { + if (_reader.TryRead(out _current)) + { + _hasCurrent = true; + return true; + } + } + + return false; + } + + public ValueTask DisposeAsync() + { + if (_hasCurrent) + { + _current.Dispose(); + } + + _current = default; + _hasCurrent = false; + return default; + } + } +} diff --git a/src/Disqord.Extensions.Voice/Audio/Default/AudioSource.cs b/src/Disqord.Extensions.Voice/Audio/Default/AudioSource.cs deleted file mode 100644 index 61120afa8..000000000 --- a/src/Disqord.Extensions.Voice/Audio/Default/AudioSource.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using Qommon.Metadata; - -namespace Disqord.Extensions.Voice; - -/// -/// Represents a type responsible for yielding Opus audio. -/// -public abstract class AudioSource : IAsyncEnumerable>, IThreadSafeMetadata -{ - /// - /// Gets an enumerator which should yield 20 milliseconds worth of Opus audio packets. - /// - /// The cancellation token to observe. - /// - /// The enumerator. - /// - public abstract IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken); -} diff --git a/src/Disqord.Extensions.Voice/Audio/Default/OggOpusWriter.cs b/src/Disqord.Extensions.Voice/Audio/Default/OggOpusWriter.cs new file mode 100644 index 000000000..f3df0ae50 --- /dev/null +++ b/src/Disqord.Extensions.Voice/Audio/Default/OggOpusWriter.cs @@ -0,0 +1,266 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.IO; +using System.Text; +using Disqord.Voice; + +namespace Disqord.Extensions.Voice; + +/// +/// Writes Opus audio packets into an Ogg container (RFC 7845). +/// +public sealed class OggOpusWriter : IDisposable +{ + private static readonly uint[] CrcLookupTable = CreateCrcLookupTable(); + + private readonly Stream _stream; + private readonly uint _serialNumber; + private uint _pageSequence; + private long _granulePosition; + private uint _firstRtpTimestamp; + private bool _hasFirstTimestamp; + + private bool _isCompleted; + private bool _isDisposed; + + /// + /// Gets the number of Opus packets written. + /// + public int PacketCount { get; private set; } + + /// + /// Initializes a new that writes to the specified stream. + /// + /// The stream to write the Ogg Opus data to. + /// The number of audio channels (1 for mono, 2 for stereo). + /// The vendor string to write in the Ogg Opus tags. + public OggOpusWriter(Stream stream, int channels = 2, string vendor = "Disqord") + { + _stream = stream; + _serialNumber = CreateSerialNumber(); + + WriteIdentificationPage(channels); + WriteTagsPage(vendor); + } + + /// + /// Resets RTP timestamp tracking used by . + /// + public void ResetRtpTimestampBase() + { + _firstRtpTimestamp = 0; + _hasFirstTimestamp = false; + } + + /// + /// Writes Opus DTX silence packets for the specified duration. + /// + /// The duration of silence to write. + public void WriteSilence(TimeSpan duration) + { + if (_isCompleted) + throw new InvalidOperationException("Cannot write packets after completion."); + + var silenceFrameCount = duration.Ticks / (TimeSpan.TicksPerMillisecond * VoiceConstants.DurationMilliseconds); + if (silenceFrameCount <= 0) + return; + + WriteSilenceFrames(silenceFrameCount); + } + + /// + /// Writes an Opus audio packet to the Ogg stream using RTP timestamp for timing. + /// Silence frames are automatically inserted to fill gaps between non-contiguous timestamps. + /// + /// The Opus packet data. + /// The RTP timestamp of the packet. + public void WritePacket(ReadOnlySpan packet, uint rtpTimestamp) + { + if (_isCompleted) + throw new InvalidOperationException("Cannot write packets after completion."); + + if (_hasFirstTimestamp) + { + var delta = (long) (rtpTimestamp - _firstRtpTimestamp) - _granulePosition; + if (delta > VoiceConstants.AudioSize) + { + WriteSilenceFrames(delta / VoiceConstants.AudioSize); + } + } + else + { + _firstRtpTimestamp = rtpTimestamp; + _hasFirstTimestamp = true; + } + + _granulePosition += VoiceConstants.AudioSize; + PacketCount++; + WritePage(packet, headerType: 0, granulePosition: _granulePosition); + } + + /// + /// Writes an Opus audio packet to the Ogg stream. + /// Each packet advances the granule position by one frame (960 samples / 20ms). + /// + /// The Opus packet data. + public void WritePacket(ReadOnlySpan packet) + { + if (_isCompleted) + throw new InvalidOperationException("Cannot write packets after completion."); + + _granulePosition += VoiceConstants.AudioSize; + PacketCount++; + WritePage(packet, headerType: 0, granulePosition: _granulePosition); + } + + /// + /// Writes the end-of-stream page, completing the Ogg stream. + /// This is called automatically by . + /// + public void Complete() + { + if (_isCompleted) + { + return; + } + + _isCompleted = true; + WritePage(ReadOnlySpan.Empty, headerType: 0x04, granulePosition: _granulePosition); + _stream.Flush(); + } + + private void WriteSilenceFrames(long silenceFrameCount) + { + for (var i = 0L; i < silenceFrameCount; i++) + { + _granulePosition += VoiceConstants.AudioSize; + WritePage(VoiceConstants.SilencePacket.Span, headerType: 0, granulePosition: _granulePosition); + } + } + + private void WriteIdentificationPage(int channels) + { + Span packet = stackalloc byte[19]; + "OpusHead"u8.CopyTo(packet); + packet[8] = 1; // Version + packet[9] = (byte) channels; // Channels + BinaryPrimitives.WriteUInt16LittleEndian(packet[10..], 0); // Pre-skip + BinaryPrimitives.WriteUInt32LittleEndian(packet[12..], (uint) VoiceConstants.SamplingRate); + BinaryPrimitives.WriteUInt16LittleEndian(packet[16..], 0); // Output gain + packet[18] = 0; // Channel mapping family + + WritePage(packet, headerType: 0x02, granulePosition: 0); + } + + private void WriteTagsPage(string vendor) + { + var vendorBytes = Encoding.UTF8.GetBytes(vendor); + var packet = new byte[8 + 4 + vendorBytes.Length + 4]; + + "OpusTags"u8.CopyTo(packet); + BinaryPrimitives.WriteUInt32LittleEndian(packet.AsSpan(8), (uint) vendorBytes.Length); + vendorBytes.CopyTo(packet.AsSpan(12)); + BinaryPrimitives.WriteUInt32LittleEndian(packet.AsSpan(12 + vendorBytes.Length), 0); + + WritePage(packet, headerType: 0, granulePosition: 0); + } + + private void WritePage(ReadOnlySpan packet, byte headerType, long granulePosition) + { + // OGG lacing: segments of up to 255 bytes. A segment of exactly 255 signals + // "continues on the next segment." Packets whose length is a multiple of 255 need + // a terminating zero-length segment to indicate completion. + var segmentCount = (packet.Length / 255) + 1; + var headerSize = 27 + segmentCount; + var pageSize = headerSize + packet.Length; + + byte[]? rentedPage = null; + try + { + var page = pageSize <= 8192 + ? stackalloc byte[pageSize] + : rentedPage = ArrayPool.Shared.Rent(pageSize); + + page[..pageSize].Clear(); + "OggS"u8.CopyTo(page); + page[4] = 0; + page[5] = headerType; + BinaryPrimitives.WriteInt64LittleEndian(page[6..], granulePosition); + BinaryPrimitives.WriteUInt32LittleEndian(page[14..], _serialNumber); + BinaryPrimitives.WriteUInt32LittleEndian(page[18..], _pageSequence++); + page[26] = (byte) segmentCount; + + var remaining = packet.Length; + for (var i = 0; i < segmentCount; i++) + { + var segmentLength = Math.Min(255, remaining); + page[27 + i] = (byte) segmentLength; + remaining -= segmentLength; + } + + packet.CopyTo(page[headerSize..]); + + var checksum = ComputeCrc(page[..pageSize]); + BinaryPrimitives.WriteUInt32LittleEndian(page[22..], checksum); + + _stream.Write(page[..pageSize]); + } + finally + { + if (rentedPage != null) + { + ArrayPool.Shared.Return(rentedPage); + } + } + } + + private static uint CreateSerialNumber() + { + Span buffer = stackalloc byte[4]; + Random.Shared.NextBytes(buffer); + return BinaryPrimitives.ReadUInt32LittleEndian(buffer); + } + + private static uint ComputeCrc(ReadOnlySpan data) + { + var crc = 0u; + foreach (var b in data) + { + crc = (crc << 8) ^ CrcLookupTable[((crc >> 24) & 0xFF) ^ b]; + } + + return crc; + } + + private static uint[] CreateCrcLookupTable() + { + var table = new uint[256]; + for (var i = 0; i < table.Length; i++) + { + var remainder = (uint) i << 24; + for (var j = 0; j < 8; j++) + { + remainder = (remainder & 0x80000000) != 0 + ? (remainder << 1) ^ 0x04C11DB7 + : remainder << 1; + } + + table[i] = remainder; + } + + return table; + } + + /// + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + Complete(); + } +} diff --git a/src/Disqord.Extensions.Voice/Audio/Default/OggReader.cs b/src/Disqord.Extensions.Voice/Audio/Default/OggReader.cs index ca328c9b0..5b81a64b7 100644 --- a/src/Disqord.Extensions.Voice/Audio/Default/OggReader.cs +++ b/src/Disqord.Extensions.Voice/Audio/Default/OggReader.cs @@ -7,7 +7,7 @@ namespace Disqord.Extensions.Voice; -internal class OggReader : IAsyncEnumerable> +internal class OggReader : IAsyncEnumerable> { // 4 magic // 1 stream structure version @@ -185,7 +185,7 @@ private async Task ReadPacketAsync(RentedArray packet, CancellationT return false; } - public async IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken = default) + public async IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken = default) { if (_returnedEnumerator) yield break; diff --git a/src/Disqord.Extensions.Voice/Audio/Default/OggStreamAudioSource.cs b/src/Disqord.Extensions.Voice/Audio/Default/OggStreamAudioSource.cs index d63ad09e5..792f436c6 100644 --- a/src/Disqord.Extensions.Voice/Audio/Default/OggStreamAudioSource.cs +++ b/src/Disqord.Extensions.Voice/Audio/Default/OggStreamAudioSource.cs @@ -8,7 +8,7 @@ namespace Disqord.Extensions.Voice; /// /// Represents an audio source that can read the Ogg format. /// -public class OggStreamAudioSource : AudioSource +public class OggStreamAudioSource : IAudioSource { private readonly Stream _stream; @@ -22,7 +22,7 @@ public OggStreamAudioSource(Stream stream) } /// - public override IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken) + public IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken) { return new OggReader(_stream).GetAsyncEnumerator(cancellationToken); } diff --git a/src/Disqord.Extensions.Voice/Audio/IAudioSource.cs b/src/Disqord.Extensions.Voice/Audio/IAudioSource.cs new file mode 100644 index 000000000..7f5b8b305 --- /dev/null +++ b/src/Disqord.Extensions.Voice/Audio/IAudioSource.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace Disqord.Extensions.Voice; + +/// +/// Represents a source of Opus-encoded audio packets. +/// Each element is a single 20ms Opus frame as a of bytes. +/// +public interface IAudioSource : IAsyncEnumerable> +{ + /// + /// Wraps an existing of Opus packets + /// as an . + /// + /// The async enumerable yielding 20ms Opus-encoded audio packets. + /// An wrapping the provided enumerable. + static IAudioSource Create(IAsyncEnumerable> source) + { + if (source is IAudioSource audioSource) + return audioSource; + + return new WrappedAudioSource(source); + } + + private sealed class WrappedAudioSource(IAsyncEnumerable> source) : IAudioSource + { + public IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return source.GetAsyncEnumerator(cancellationToken); + } + } +} diff --git a/src/Disqord.Extensions.Voice/VoiceConnectOptions.cs b/src/Disqord.Extensions.Voice/VoiceConnectOptions.cs new file mode 100644 index 000000000..950417e78 --- /dev/null +++ b/src/Disqord.Extensions.Voice/VoiceConnectOptions.cs @@ -0,0 +1,18 @@ +namespace Disqord.Extensions.Voice; + +/// +/// Represents voice-state behavior options used when connecting a voice session. +/// +public class VoiceConnectOptions +{ + /// + /// Gets or sets whether the current user should be self-muted while connected. + /// + public bool SelfMute { get; set; } + + /// + /// Gets or sets whether the current user should be self-deafened while connected. + /// This is visual-only for bots; bots can receive audio regardless of this setting. + /// + public bool SelfDeafen { get; set; } = true; +} diff --git a/src/Disqord.Extensions.Voice/VoiceExtension.cs b/src/Disqord.Extensions.Voice/VoiceExtension.cs index ac8c73ebb..a2075b1f7 100644 --- a/src/Disqord.Extensions.Voice/VoiceExtension.cs +++ b/src/Disqord.Extensions.Voice/VoiceExtension.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,7 +17,7 @@ public class VoiceExtension : DiscordClientExtension { private readonly IVoiceConnectionFactory _connectionFactory; - private readonly IThreadSafeDictionary _pendingConnections; + private readonly IThreadSafeDictionary _pendingConnections; private readonly IThreadSafeDictionary _connections; public VoiceExtension( @@ -26,7 +27,7 @@ public VoiceExtension( { _connectionFactory = connectionFactory; - _pendingConnections = ThreadSafeDictionary.Monitor.Create(); + _pendingConnections = ThreadSafeDictionary.Monitor.Create(); _connections = ThreadSafeDictionary.Monitor.Create(); } @@ -79,7 +80,7 @@ private Task VoiceStateUpdatedAsync(object? sender, VoiceStateUpdatedEventArgs e /// public IReadOnlyDictionary GetConnections() { - return _connections.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value.Connection); + return _connections.ToDictionary(static kvp => kvp.Key, static kvp => (IVoiceConnection) kvp.Value.Connection); } /// @@ -96,6 +97,26 @@ public IReadOnlyDictionary GetConnections() /// public async ValueTask ConnectAsync(Snowflake guildId, Snowflake channelId, CancellationToken cancellationToken = default) { + return await ConnectAsync(guildId, channelId, options: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Connects to the channel with the given ID. + /// + /// + /// To disconnect the voice connection, use . + /// + /// The ID of the guild the channel is in. + /// The ID of the channel. + /// The voice-state behavior options for the connection. + /// The cancellation token to observe. This is only used for the initial connection. + /// + /// A with the result being the created voice connection. + /// + public async ValueTask ConnectAsync(Snowflake guildId, Snowflake channelId, VoiceConnectOptions? options, CancellationToken cancellationToken = default) + { + var connectOptions = options ?? new VoiceConnectOptions(); + var connection = _connectionFactory.Create(guildId, channelId, Client.CurrentUser.Id, (guildId, channelId, cancellationToken) => { @@ -104,7 +125,7 @@ public async ValueTask ConnectAsync(Snowflake guildId, Snowfla Throw.InvalidOperationException("The guild ID is not handled by any of the shards of the client"); Logger.LogDebug("Setting voice state for guild ID: {GuildId} to channel ID: {ChannelId}", guildId, channelId); - return new(shard.SetVoiceStateAsync(guildId, channelId, false, true, cancellationToken)); + return new(shard.SetVoiceStateAsync(guildId, channelId, connectOptions.SelfMute, connectOptions.SelfDeafen, cancellationToken)); }); _pendingConnections[guildId] = connection; @@ -154,13 +175,13 @@ public async ValueTask DisconnectAsync(Snowflake guildId) private readonly struct VoiceConnectionInfo { - public IVoiceConnection Connection { get; } + public IVoiceConnectionHost Connection { get; } public Task RunTask { get; } public Cts Cts { get; } - public VoiceConnectionInfo(IVoiceConnection connection, Task runTask, Cts cts) + public VoiceConnectionInfo(IVoiceConnectionHost connection, Task runTask, Cts cts) { Connection = connection; RunTask = runTask; diff --git a/src/Disqord.Voice.Api/Default/DefaultVoiceGateway.cs b/src/Disqord.Voice.Api/Default/DefaultVoiceGateway.cs index 0199cac5f..f543fdaa9 100644 --- a/src/Disqord.Voice.Api/Default/DefaultVoiceGateway.cs +++ b/src/Disqord.Voice.Api/Default/DefaultVoiceGateway.cs @@ -107,7 +107,7 @@ public async ValueTask ReceiveAsync(CancellationToken cance private static VoiceGatewayMessage ParseBinaryMessage(MemoryStream stream) { - // Binary format (server → client): [2-byte big-endian seq] [1-byte opcode] [payload] + // Binary format (server -> client): [2-byte big-endian seq] [1-byte opcode] [payload] stream.TryGetBuffer(out var buffer); var span = buffer.AsSpan(); diff --git a/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayClient.cs b/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayClient.cs index 08e0ae86e..49595a252 100644 --- a/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayClient.cs +++ b/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayClient.cs @@ -13,6 +13,8 @@ namespace Disqord.Voice.Api.Default; public class DefaultVoiceGatewayClient : IVoiceGatewayClient { + private static readonly IReadOnlySet EmptyUserIds = new HashSet(); + public Snowflake GuildId { get; } public Snowflake CurrentMemberId { get; } @@ -34,7 +36,24 @@ public class DefaultVoiceGatewayClient : IVoiceGatewayClient public CancellationToken StoppingToken { get; private set; } /// - public IReadOnlySet ConnectedUserIds => _connectedUserIds; + public IReadOnlySet ConnectedUserIds + { + get + { + lock (_stateLock) + { + return _connectedUserIds.Count == 0 + ? EmptyUserIds + : new HashSet(_connectedUserIds); + } + } + } + + /// + public Action? UserConnected { get; set; } + + /// + public Action? UserDisconnected { get; set; } /// public int LastSequenceNumber { get; private set; } @@ -50,6 +69,7 @@ public class DefaultVoiceGatewayClient : IVoiceGatewayClient private Tcs? _postSessionDescriptionTcs; private readonly HashSet _connectedUserIds = []; + private readonly Dictionary _ssrcUserIds = []; public DefaultVoiceGatewayClient( Snowflake guildId, @@ -105,31 +125,34 @@ public Task WaitForSessionDescriptionAsync(Cancella return sessionDescriptionTask.WaitAsync(cancellationToken); } + /// + public bool TryGetUserId(uint ssrc, out Snowflake userId) + { + lock (_stateLock) + { + return _ssrcUserIds.TryGetValue(ssrc, out userId); + } + } + /// public async Task SendAsync(VoiceGatewayPayloadJsonModel payload, CancellationToken cancellationToken = default) { Guard.IsNotNull(payload); - bool sent; - do + try { - try + Logger.LogTrace("Sending voice payload: {0}.", payload.Op); + await Gateway.SendAsync(payload, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (ex is not OperationCanceledException and not ObjectDisposedException) { - Logger.LogTrace("Sending voice payload: {0}.", payload.Op); - await Gateway.SendAsync(payload, cancellationToken).ConfigureAwait(false); - sent = true; + Logger.LogError(ex, "An exception occurred while sending voice payload: {0}.", payload.Op); } - catch (Exception ex) - { - if (ex is not OperationCanceledException) - { - Logger.LogError(ex, "An exception occurred while sending voice payload: {0}.", payload.Op); - } - throw; - } + throw; } - while (!sent); } /// @@ -144,7 +167,7 @@ public async Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken c } catch (Exception ex) { - if (ex is not OperationCanceledException) + if (ex is not OperationCanceledException and not ObjectDisposedException) Logger.LogError(ex, "An exception occurred while sending binary voice payload."); throw; @@ -239,6 +262,8 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) lock (_stateLock) { + _connectedUserIds.Clear(); + _ssrcUserIds.Clear(); _readyTcs.Complete(model); } @@ -266,7 +291,18 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) } case VoiceGatewayPayloadOperation.Speaking: { - // TODO: speaking, but it's bugged, after initial connect it fires once per user, after a reconnect it fires constantly + var model = message.JsonPayload!.D!.ToType()!; + if (model.UserId.TryGetValue(out var userId)) + { + if (TryAddConnectedUser(userId)) + UserConnected?.Invoke(userId); + + lock (_stateLock) + { + MapSsrcToUser(model.Ssrc, userId); + } + } + break; } case VoiceGatewayPayloadOperation.HeartbeatAcknowledged: @@ -318,12 +354,30 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) case VoiceGatewayPayloadOperation.ClientConnect: { var model = message.JsonPayload!.D!.ToType()!; - foreach (var userId in model.UserIds) + if (model.UserIds != null) + { + foreach (var userId in model.UserIds) + { + if (TryAddConnectedUser(userId)) + UserConnected?.Invoke(userId); + } + } + + if (model.UserId.TryGetValue(out var singleUserId)) { - _connectedUserIds.Add(userId); + if (TryAddConnectedUser(singleUserId)) + UserConnected?.Invoke(singleUserId); + + if (model.AudioSsrc.TryGetValue(out var audioSsrc)) + { + lock (_stateLock) + { + MapSsrcToUser(audioSsrc, singleUserId); + } + } } - Logger.LogTrace("Client connect: {0} user(s).", model.UserIds.Length); + Logger.LogTrace("Client connect: {0} user(s).", model.UserIds?.Length ?? 0); if (_daveMessageHandler != null) { @@ -335,7 +389,28 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) case VoiceGatewayPayloadOperation.ClientDisconnect: { var model = message.JsonPayload!.D!.ToType()!; - _connectedUserIds.Remove(model.UserId); + var wasRemoved = TryRemoveConnectedUser(model.UserId); + lock (_stateLock) + { + var disconnectUserId = model.UserId; + var ssrcs = new List(); + foreach (var pair in _ssrcUserIds) + { + if (pair.Value == disconnectUserId) + { + ssrcs.Add(pair.Key); + } + } + + foreach (var ssrc in ssrcs) + { + _ssrcUserIds.Remove(ssrc); + } + } + + if (wasRemoved) + UserDisconnected?.Invoke(model.UserId); + Logger.LogTrace("Client disconnect: {0}.", model.UserId); if (_daveMessageHandler != null) @@ -347,7 +422,6 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) case VoiceGatewayPayloadOperation.DaveProtocolExecuteTransition: case VoiceGatewayPayloadOperation.DaveProtocolPrepareEpoch: { - Logger.LogTrace("Received DAVE opcode {0}.", message.Op); if (_daveMessageHandler != null) { await _daveMessageHandler(message, stoppingToken).ConfigureAwait(false); @@ -360,7 +434,6 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) case VoiceGatewayPayloadOperation.DaveMlsAnnounceCommitTransition: case VoiceGatewayPayloadOperation.DaveMlsWelcome: { - Logger.LogTrace("Received DAVE binary opcode {0} ({1} bytes).", message.Op, message.BinaryPayload.Length); if (_daveMessageHandler != null) { await _daveMessageHandler(message, stoppingToken).ConfigureAwait(false); @@ -397,7 +470,7 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) if (stoppingToken.IsCancellationRequested) { // The connection is already being stopped (e.g. due to a VOICE_SERVER_UPDATE). - // The non-recoverable close code is expected — treat it as a cancellation. + // The non-recoverable close code is expected - treat it as a cancellation. Logger.LogDebug("The voice gateway was closed with code {0} while already stopping.", closeCode); throw new OperationCanceledException(stoppingToken); } @@ -427,7 +500,7 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) } catch (ObjectDisposedException) { - // Expected during shutdown — the WebSocket may already be disposed. + // Expected during shutdown - the WebSocket may already be disposed. } catch (Exception exc) { @@ -447,7 +520,7 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) } catch (ObjectDisposedException) { - // Expected during shutdown — the WebSocket may already be disposed. + // Expected during shutdown - the WebSocket may already be disposed. } catch (Exception exc) { @@ -507,4 +580,39 @@ public ValueTask DisposeAsync() { return Gateway.DisposeAsync(); } + + private void MapSsrcToUser(uint ssrc, Snowflake userId) + { + var staleSsrcs = new List(); + foreach (var pair in _ssrcUserIds) + { + if (pair.Value == userId && pair.Key != ssrc) + { + staleSsrcs.Add(pair.Key); + } + } + + foreach (var staleSsrc in staleSsrcs) + { + _ssrcUserIds.Remove(staleSsrc); + } + + _ssrcUserIds[ssrc] = userId; + } + + private bool TryAddConnectedUser(Snowflake userId) + { + lock (_stateLock) + { + return _connectedUserIds.Add(userId); + } + } + + private bool TryRemoveConnectedUser(Snowflake userId) + { + lock (_stateLock) + { + return _connectedUserIds.Remove(userId); + } + } } diff --git a/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayHeartbeater.cs b/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayHeartbeater.cs index ced48bec0..055cbdff0 100644 --- a/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayHeartbeater.cs +++ b/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayHeartbeater.cs @@ -60,6 +60,8 @@ private async Task InternalRunAsync() } catch (OperationCanceledException) { } + catch (ObjectDisposedException) + { } catch (Exception ex) { Logger.LogError(ex, "An exception occurred while voice heartbeating."); diff --git a/src/Disqord.Voice.Api/IVoiceGatewayClient.cs b/src/Disqord.Voice.Api/IVoiceGatewayClient.cs index 252c977f1..6a727e161 100644 --- a/src/Disqord.Voice.Api/IVoiceGatewayClient.cs +++ b/src/Disqord.Voice.Api/IVoiceGatewayClient.cs @@ -27,10 +27,27 @@ public interface IVoiceGatewayClient : ILogging, IAsyncDisposable IJsonSerializer Serializer { get; } /// - /// Gets the set of currently connected user IDs in the voice session. + /// Gets a snapshot of the currently connected user IDs in the voice session. /// IReadOnlySet ConnectedUserIds { get; } + /// + /// Gets or sets the callback invoked when a user connects to the voice session. + /// Invoked on the gateway dispatch thread, after has been updated. + /// + Action? UserConnected { get; set; } + + /// + /// Gets or sets the callback invoked when a user disconnects from the voice session. + /// Invoked on the gateway dispatch thread, after has been updated. + /// + Action? UserDisconnected { get; set; } + + /// + /// Tries to resolve a user ID by SSRC. + /// + bool TryGetUserId(uint ssrc, out Snowflake userId); + /// /// Gets the sequence number of the last numbered message received from the gateway. /// Used for seq_ack in v8 heartbeats. diff --git a/src/Disqord.Voice.Api/Models/Payloads/ClientConnectJsonModel.cs b/src/Disqord.Voice.Api/Models/Payloads/ClientConnectJsonModel.cs index 067b050ab..137b7864f 100644 --- a/src/Disqord.Voice.Api/Models/Payloads/ClientConnectJsonModel.cs +++ b/src/Disqord.Voice.Api/Models/Payloads/ClientConnectJsonModel.cs @@ -1,9 +1,19 @@ using Disqord.Serialization.Json; +using Qommon; namespace Disqord.Voice.Api.Models; public class ClientConnectJsonModel : JsonModel { + [JsonProperty("user_id")] + public Optional UserId; + + [JsonProperty("audio_ssrc")] + public Optional AudioSsrc; + + [JsonProperty("video_ssrc")] + public Optional VideoSsrc; + [JsonProperty("user_ids")] - public Snowflake[] UserIds = null!; + public Snowflake[]? UserIds; } diff --git a/src/Disqord.Voice/Default/DaveProtocolHandler.cs b/src/Disqord.Voice/Default/DaveProtocolHandler.cs index 3e61e75ba..de654e5b7 100644 --- a/src/Disqord.Voice/Default/DaveProtocolHandler.cs +++ b/src/Disqord.Voice/Default/DaveProtocolHandler.cs @@ -7,6 +7,7 @@ using Disqord.Voice.Api; using Disqord.Voice.Api.Models; using Microsoft.Extensions.Logging; +using Qommon; using Qommon.Pooling; namespace Disqord.Voice.Default; @@ -15,13 +16,24 @@ namespace Disqord.Voice.Default; /// Handles the DAVE (Discord Audio/Video E2EE) protocol state machine. /// Based on Discord's official DaveSessionManager reference implementation. /// -internal sealed class DaveProtocolHandler : IDisposable +public sealed class DaveProtocolHandler : IDisposable { private const int InitTransitionId = 0; + /// + /// The number of consecutive DAVE decrypt failures to tolerate before triggering recovery. + /// Matches discord.js's DEFAULT_DECRYPTION_FAILURE_TOLERANCE. + /// At 50 packets/sec, 36 failures ≈ 0.72 seconds of tolerance. + /// + private const int DecryptionFailureTolerance = 36; + public DaveEncryptor Encryptor => _encryptor; - public DaveDecryptor Decryptor => _decryptor; + /// + /// Gets whether a DAVE transition is currently pending or reinitializing. + /// When , decrypt failures are expected and should not be logged. + /// + public bool IsTransitioning => _pendingTransitionCount > 0 || _reinitializing; private readonly ILogger _logger; private readonly IVoiceGatewayClient _gateway; @@ -31,8 +43,28 @@ internal sealed class DaveProtocolHandler : IDisposable private DaveSession? _session; private readonly Dave.MlsFailureCallback _mlsFailureCallback; private readonly DaveEncryptor _encryptor; - private readonly DaveDecryptor _decryptor; + private readonly Dictionary _decryptors = new(); + private readonly ReaderWriterLockSlim _decryptorsLock = new(); + private int _disposed; + private volatile bool _mlsFailureOccurred; + private volatile bool _reinitializing; private ushort _protocolVersion; + private readonly ushort _targetProtocolVersion; + + // Consecutive DAVE decrypt failure tracking (read from the UDP thread, written from both threads). + private int _consecutiveDecryptFailures; + private volatile int _lastExecutedTransitionId; + private volatile int _pendingTransitionCount; + private int _decryptRecoveryRequested; + + /// + /// The server's actual MLS group ID, extracted from incoming commit messages. + /// Libdave's CanProcessCommit checks commit.group_id() != groupId_, + /// but the server may assign a different group ID than BigEndianBytesFrom(guildId). + /// Caching the server's group ID and using it for subsequent Init calls + /// avoids repeated CanProcessCommit failures and unnecessary recovery cycles. + /// + private Snowflake? _serverGroupId; /// /// Whether the bot has joined the MLS group (via Welcome or Commit). @@ -41,7 +73,7 @@ internal sealed class DaveProtocolHandler : IDisposable private bool _hasJoinedGroup; /// - /// Maps transition ID → protocol version for pending transitions. + /// Maps transition ID -> protocol version for pending transitions. /// Multiple transitions can be in flight simultaneously. /// private readonly Dictionary _pendingTransitions = new(); @@ -60,7 +92,13 @@ public unsafe DaveProtocolHandler( ILoggerFactory loggerFactory) { _gateway = gateway; - _protocolVersion = protocolVersion; + + // Start at version 0 (unencrypted) - the handler is created at session description time, + // before we've joined the MLS group. The first transition (Welcome) will move to the target version. + // This ensures the v0->v1 check in ExecuteTransitionCore triggers correctly, + // enabling the passthrough grace window for frames that arrive before DAVE is fully established. + _protocolVersion = 0; + _targetProtocolVersion = protocolVersion; _guildId = guildId; _selfUserId = selfUserId; _logger = loggerFactory.CreateLogger($"Voice {guildId} DAVE"); @@ -69,25 +107,23 @@ public unsafe DaveProtocolHandler( _mlsFailureCallback = (source, reason, _) => { + _mlsFailureOccurred = true; var sourceStr = Marshal.PtrToStringUTF8((nint) source); var reasonStr = Marshal.PtrToStringUTF8((nint) reason); - _logger.LogError("MLS failure: {Source} {Reason}", sourceStr, reasonStr); + _logger.LogDebug("MLS failure: {Source} {Reason}", sourceStr, reasonStr); }; _encryptor = new DaveEncryptor(); _encryptor.SetPassthroughMode(true); - - _decryptor = new DaveDecryptor(); - _decryptor.TransitionToPassthroughMode(true); } public Task InitializeAsync(CancellationToken cancellationToken) { - _logger.LogDebug("DAVE protocol handler initializing (version {0}).", _protocolVersion); + _logger.LogDebug("DAVE protocol handler initializing (target version {0}).", _targetProtocolVersion); - if (_protocolVersion > 0) + if (_targetProtocolVersion > 0) { - HandlePrepareEpochCore(_protocolVersion, epoch: 1); + HandlePrepareEpochCore(_targetProtocolVersion, epoch: 1); return SendKeyPackageAsync(cancellationToken); } @@ -102,6 +138,15 @@ public Task InitializeAsync(CancellationToken cancellationToken) /// public async Task HandleMessageAsync(VoiceGatewayMessage message, CancellationToken cancellationToken) { + // Check for pending recovery triggered by excessive consecutive decrypt failures. + if (Interlocked.Exchange(ref _decryptRecoveryRequested, 0) == 1) + { + var failures = Volatile.Read(ref _consecutiveDecryptFailures); + var transitionId = _lastExecutedTransitionId; + _logger.LogDebug("Triggering DAVE recovery due to {0} consecutive decrypt failures (transition {1}).", failures, transitionId); + await RecoverFromInvalidTransitionAsync(transitionId, cancellationToken).ConfigureAwait(false); + } + switch (message.Op) { case VoiceGatewayPayloadOperation.ClientConnect: @@ -166,17 +211,103 @@ public async Task HandleMessageAsync(VoiceGatewayMessage message, CancellationTo } } + /// + /// Acquires a read-locked lease on the decryptor for the specified user. + /// The caller must dispose the returned lease to release the read lock. + /// + public DecryptorLease GetDecryptor(Snowflake userId) + { + _decryptorsLock.EnterReadLock(); + _decryptors.TryGetValue(userId, out var decryptor); + return new DecryptorLease(decryptor, _decryptorsLock); + } + + /// + /// A read-lock lease over a . + /// Disposing this lease releases the read lock on the decryptors collection. + /// + public readonly struct DecryptorLease : IDisposable + { + public readonly DaveDecryptor? Decryptor; + private readonly ReaderWriterLockSlim _lock; + + internal DecryptorLease(DaveDecryptor? decryptor, ReaderWriterLockSlim rwLock) + { + Decryptor = decryptor; + _lock = rwLock; + } + + public void Dispose() => _lock?.ExitReadLock(); + } + + /// + /// Reports a DAVE decrypt result from the UDP receive path. + /// Tracks consecutive failures and requests recovery when the threshold is exceeded. + /// + /// + /// This is called from the UDP receive thread. Recovery is deferred to the gateway thread. + /// + public void OnDecryptResult(bool success) + { + if (success) + { + Interlocked.Exchange(ref _consecutiveDecryptFailures, 0); + return; + } + + // Don't count failures when reinitializing or when transitions are pending. + // Matches discord.js behavior. + if (_reinitializing || _pendingTransitionCount > 0) + { + return; + } + + var failures = Interlocked.Increment(ref _consecutiveDecryptFailures); + if (failures > DecryptionFailureTolerance && _lastExecutedTransitionId > 0) + { + // Request recovery - will be processed on the gateway thread in HandleMessageAsync + Volatile.Write(ref _decryptRecoveryRequested, 1); + } + } + private void HandleClientConnect(VoiceGatewayMessage message) { var model = message.JsonPayload!.D!.ToType()!; - foreach (var userId in model.UserIds) + if (model.UserIds != null) { - SetupKeyRatchetForUser(userId, _latestPreparedTransitionVersion); + foreach (var userId in model.UserIds) + { + SetupKeyRatchetForUser(userId, _latestPreparedTransitionVersion); + } + } + + if (model.UserId.TryGetValue(out var singleUserId)) + { + SetupKeyRatchetForUser(singleUserId, _latestPreparedTransitionVersion); } } private void HandleClientDisconnect(VoiceGatewayMessage message) - { } + { + var model = message.JsonPayload!.D!.ToType()!; + if (model.UserId == _selfUserId) + { + return; + } + + _decryptorsLock.EnterWriteLock(); + try + { + if (_decryptors.Remove(model.UserId, out var decryptor)) + { + decryptor.Dispose(); + } + } + finally + { + _decryptorsLock.ExitWriteLock(); + } + } private void HandleExternalSenderPackage(ReadOnlySpan payload) { @@ -191,8 +322,13 @@ private async Task HandlePrepareEpochAsync(VoiceGatewayMessage message, Cancella if (model.Epoch == 1) { - _protocolVersion = (ushort) model.ProtocolVersion; - HandlePrepareEpochCore(_protocolVersion, model.Epoch); + // Reset to version 0 - the MLS session is being reset, so DAVE is not yet + // established for this epoch. The next transition will be v0->v1, which + // correctly triggers the passthrough grace window for receiving. + var targetVersion = (ushort) model.ProtocolVersion; + _protocolVersion = 0; + ClearDecryptors(); + HandlePrepareEpochCore(targetVersion, model.Epoch); await SendKeyPackageAsync(cancellationToken).ConfigureAwait(false); } } @@ -202,6 +338,8 @@ private void HandlePrepareEpochCore(ushort protocolVersion, int epoch) if (epoch == 1) { _hasJoinedGroup = false; + _pendingTransitions.Clear(); + _pendingTransitionCount = 0; if (_session != null) { @@ -213,7 +351,7 @@ private void HandlePrepareEpochCore(ushort protocolVersion, int epoch) _session = DaveSession.Create(_mlsFailureCallback); } - _session.Init(protocolVersion, _guildId, _selfUserId); + _session.Init(protocolVersion, _serverGroupId ?? _guildId, _selfUserId); } } @@ -227,12 +365,14 @@ private async Task HandleProposalsAsync(ReadOnlyMemory payload, Cancellati _logger.LogDebug("Processing DAVE MLS proposals ({0} bytes).", payload.Length); + _mlsFailureOccurred = false; + using var recognizedUserIds = GetRecognizedUserIds(); var sendBuffer = default(RentedArray); using (var commitWelcome = _session!.ProcessProposals(payload.Span, recognizedUserIds.AsSpan())) { - if (commitWelcome.Length > 0) + if (!_mlsFailureOccurred && commitWelcome.Length > 0) { sendBuffer = RentedArray.Rent(1 + commitWelcome.Length); sendBuffer[0] = (byte) VoiceGatewayPayloadOperation.DaveMlsCommitWelcome; @@ -240,6 +380,14 @@ private async Task HandleProposalsAsync(ReadOnlyMemory payload, Cancellati } } + if (_mlsFailureOccurred) + { + sendBuffer.Dispose(); + _logger.LogWarning("MLS failure detected during ProcessProposals, recovering session."); + await RecoverSessionAsync(cancellationToken).ConfigureAwait(false); + return; + } + if (sendBuffer.Length > 0) { try @@ -268,24 +416,42 @@ private async Task HandleAnnounceCommitTransitionAsync(ReadOnlyMemory payl _logger.LogDebug("Received DAVE commit for transition {0} ({1} bytes).", transitionId, commitData.Length); + // Extract the server's MLS group ID from the commit for use in future Init calls. + // The server may assign a group ID that differs from BigEndianBytesFrom(guildId), + // causing CanProcessCommit to always fail. Learning the real ID lets subsequent + // Init calls set groupId_ correctly, eliminating recovery cascades. + if (TryExtractMlsGroupId(commitData, out var extractedGroupId) && _serverGroupId != extractedGroupId) + { + _logger.LogDebug("Learned server MLS group ID: 0x{0:X16}.", (ulong) extractedGroupId); + _serverGroupId = extractedGroupId; + } + var result = default(DaveCommitResult); try { result = _session!.ProcessCommit(commitData); - if (result.IsFailed) + if (result.IsFailed || result.IsIgnored) { - _logger.LogWarning("DAVE commit processing failed for transition {0}.", transitionId); - await RecoverFromInvalidTransitionAsync(transitionId, cancellationToken).ConfigureAwait(false); - return; - } + // IsFailed: the commit data is corrupt or invalid. + // IsIgnored: the commit is for an epoch the session has already moved past, + // typically our own echoed commit after ProcessProposals advanced the MLS state. + // In both cases, recovery is needed because GetKeyRatchet uses currentState_ + // which only ProcessCommit or ProcessWelcome can advance via ReplaceState. + if (result.IsFailed) + { + _logger.LogWarning("DAVE commit processing failed for transition {0}.", transitionId); + } + else + { + _logger.LogDebug("DAVE commit ignored for transition {0} (own echoed commit); recovering.", transitionId); + } - if (result.IsIgnored) - { - _logger.LogDebug("DAVE commit ignored (we were the committer) for transition {0}.", transitionId); + await RecoverFromInvalidTransitionAsync(transitionId, cancellationToken).ConfigureAwait(false); return; } + _reinitializing = false; _hasJoinedGroup = true; PrepareDaveProtocolRatchets(transitionId, _session!.GetProtocolVersion()); @@ -338,6 +504,7 @@ private async Task HandleWelcomeAsync(ReadOnlyMemory payload, Cancellation return; } + _reinitializing = false; _hasJoinedGroup = true; PrepareDaveProtocolRatchets(transitionId, _session!.GetProtocolVersion()); @@ -390,7 +557,9 @@ private void PrepareDaveProtocolRatchets(int transitionId, ushort protocolVersio foreach (var userId in _gateway.ConnectedUserIds) { if (userId == _selfUserId) + { continue; + } SetupKeyRatchetForUser(userId, protocolVersion); } @@ -403,6 +572,7 @@ private void PrepareDaveProtocolRatchets(int transitionId, ushort protocolVersio else { _pendingTransitions[transitionId] = protocolVersion; + _pendingTransitionCount = _pendingTransitions.Count; } _latestPreparedTransitionVersion = protocolVersion; @@ -415,73 +585,215 @@ private void ExecuteTransitionCore(int transitionId) { if (transitionId != InitTransitionId) { - _logger.LogWarning("No pending transition found for transition {0}.", transitionId); + _logger.LogDebug("No pending transition found for transition {0}.", transitionId); return; } protocolVersion = _latestPreparedTransitionVersion; } + _pendingTransitionCount = _pendingTransitions.Count; + var oldVersion = _protocolVersion; _protocolVersion = protocolVersion; if (protocolVersion == 0) { + // Downgrade to unencrypted. _session?.Reset(); - _encryptor?.SetPassthroughMode(true); + _encryptor.SetPassthroughMode(true); + SetDecryptorsPassthroughMode(true); + } + else if (oldVersion == 0) + { + // Upgrading from downgraded state - enable passthrough grace window then disable. + _encryptor.SetPassthroughMode(false); + SetDecryptorsPassthroughMode(true); + SetDecryptorsPassthroughMode(false); } else { - _encryptor?.SetPassthroughMode(false); - _decryptor?.TransitionToPassthroughMode(false); + // v1->v1 transition: don't touch passthrough mode. + // Key ratchets already handle the transition. Matches discord.js behavior. + _encryptor.SetPassthroughMode(false); } SetupKeyRatchetForUser(_selfUserId, protocolVersion); + Interlocked.Exchange(ref _consecutiveDecryptFailures, 0); + _reinitializing = false; + _lastExecutedTransitionId = transitionId; _logger.LogInformation("DAVE transition {0} executed, version {1} -> {2}.", transitionId, oldVersion, _protocolVersion); } private void SetupKeyRatchetForUser(Snowflake userId, ushort protocolVersion) { if (protocolVersion == 0 || _session == null) + { return; + } using var ratchet = _session.GetKeyRatchet(userId); + if (ratchet.Handle == 0) + { + return; + } + if (userId == _selfUserId) { - _encryptor?.SetKeyRatchet(ratchet); + _encryptor.SetKeyRatchet(ratchet); } else { - _decryptor?.TransitionToKeyRatchet(ratchet); + _decryptorsLock.EnterWriteLock(); + try + { + if (!_decryptors.TryGetValue(userId, out var decryptor)) + { + // Do NOT enable passthrough on per-user decryptors. + // The native library's passthrough detection can misclassify encrypted + // frames as "not encrypted", leaking encrypted garbage as Opus data. + // Unencrypted frames during DAVE transitions will be dropped and the + // resulting gap is filled with silence by AudioReceiver. + decryptor = new DaveDecryptor(); + _decryptors[userId] = decryptor; + } + + decryptor.TransitionToKeyRatchet(ratchet); + } + finally + { + _decryptorsLock.ExitWriteLock(); + } + + // Reset the consecutive failure counter so expected failures during + // key transitions don't trigger unnecessary MLS session recovery. + Volatile.Write(ref _consecutiveDecryptFailures, 0); + } + } + + private void SetDecryptorsPassthroughMode(bool passthroughMode) + { + _decryptorsLock.EnterWriteLock(); + try + { + foreach (var decryptor in _decryptors.Values) + { + decryptor.TransitionToPassthroughMode(passthroughMode); + } + } + finally + { + _decryptorsLock.ExitWriteLock(); + } + } + + private void ClearDecryptors() + { + _decryptorsLock.EnterWriteLock(); + try + { + foreach (var decryptor in _decryptors.Values) + { + decryptor.Dispose(); + } + + _decryptors.Clear(); + } + finally + { + _decryptorsLock.ExitWriteLock(); } } + private async Task RecoverSessionAsync(CancellationToken cancellationToken) + { + if (_reinitializing) + { + _logger.LogDebug("Skipping session recovery - already reinitializing."); + return; + } + + _logger.LogDebug("Recovering DAVE session - resetting MLS state and re-sending key package."); + + _reinitializing = true; + _hasJoinedGroup = false; + _pendingTransitions.Clear(); + _pendingTransitionCount = 0; + Interlocked.Exchange(ref _consecutiveDecryptFailures, 0); + ClearDecryptors(); + _session?.Reset(); + _session?.Init(_targetProtocolVersion, _serverGroupId ?? _guildId, _selfUserId); + + await SendKeyPackageAsync(cancellationToken).ConfigureAwait(false); + } + private async Task RecoverFromInvalidTransitionAsync(int transitionId, CancellationToken cancellationToken) { - _logger.LogWarning("Recovering from invalid transition {0}.", transitionId); + if (_reinitializing) + { + _logger.LogDebug("Skipping recovery for transition {0} - already reinitializing.", transitionId); + return; + } + _logger.LogDebug("Recovering from invalid transition {0}.", transitionId); + + _reinitializing = true; _hasJoinedGroup = false; + _pendingTransitions.Clear(); + _pendingTransitionCount = 0; + Interlocked.Exchange(ref _consecutiveDecryptFailures, 0); await SendInvalidCommitWelcomeAsync(transitionId, cancellationToken).ConfigureAwait(false); + ClearDecryptors(); _session?.Reset(); - _session?.Init(_protocolVersion, _guildId, _selfUserId); + _session?.Init(_targetProtocolVersion, _serverGroupId ?? _guildId, _selfUserId); await SendKeyPackageAsync(cancellationToken).ConfigureAwait(false); } + /// + /// Extracts the MLS group ID from a serialized MLS commit message. + /// Format: version(2) + wire_format(2) + group_id_length(1) + group_id(8). + /// + private static bool TryExtractMlsGroupId(ReadOnlySpan commitData, out Snowflake groupId) + { + groupId = default; + + // Need: version(2) + wire_format(2) + length(1) + group_id(8) = 13 bytes minimum. + if (commitData.Length < 13) + { + return false; + } + + var groupIdLength = commitData[4]; + if (groupIdLength != sizeof(ulong)) + { + return false; + } + + groupId = BinaryPrimitives.ReadUInt64BigEndian(commitData.Slice(5, sizeof(ulong))); + return true; + } + private RentedArray GetRecognizedUserIds() { var connectedUsers = _gateway.ConnectedUserIds; - var result = RentedArray.Rent(connectedUsers.Count + 1); + var includeSelf = !connectedUsers.Contains(_selfUserId); + var count = connectedUsers.Count + (includeSelf ? 1 : 0); + var result = RentedArray.Rent(count); var i = 0; foreach (var userId in connectedUsers) { result[i++] = userId; } - result[i] = _selfUserId; + if (includeSelf) + { + result[i] = _selfUserId; + } + return result; } @@ -523,7 +835,7 @@ private Task SendTransitionReadyAsync(int transitionId, CancellationToken cancel private Task SendInvalidCommitWelcomeAsync(int transitionId, CancellationToken cancellationToken) { - _logger.LogWarning("Sending DAVE invalid commit/welcome for transition {0}.", transitionId); + _logger.LogDebug("Sending DAVE invalid commit/welcome for transition {0}.", transitionId); return _gateway.SendAsync(new VoiceGatewayPayloadJsonModel { @@ -537,9 +849,13 @@ private Task SendInvalidCommitWelcomeAsync(int transitionId, CancellationToken c public void Dispose() { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + _session?.Dispose(); _session = null; - _encryptor?.Dispose(); - _decryptor?.Dispose(); + _encryptor.Dispose(); + ClearDecryptors(); + _decryptorsLock.Dispose(); } } diff --git a/src/Disqord.Voice/Default/DefaultVoiceConnection.cs b/src/Disqord.Voice/Default/DefaultVoiceConnection.cs index 8a2013626..90323f60c 100644 --- a/src/Disqord.Voice/Default/DefaultVoiceConnection.cs +++ b/src/Disqord.Voice/Default/DefaultVoiceConnection.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -11,8 +12,10 @@ namespace Disqord.Voice.Default; -public class DefaultVoiceConnection : IVoiceConnection +public class DefaultVoiceConnection : IVoiceConnectionHost { + private volatile VoicePacketSinkDelegate? _packetSink; + public ILogger Logger { get; } public IVoiceGatewayClient Gateway => _gateway!; @@ -36,6 +39,14 @@ public Snowflake ChannelId public Snowflake CurrentMemberId { get; } + private static readonly IReadOnlySet EmptyUserIds = new HashSet(); + + public IReadOnlySet ConnectedUserIds => _gateway?.ConnectedUserIds ?? EmptyUserIds; + + public event VoiceUserPresenceDelegate? UserConnected; + + public event VoiceUserPresenceDelegate? UserDisconnected; + private readonly SetVoiceStateDelegate _setVoiceStateDelegate; private readonly ILoggerFactory _loggerFactory; private readonly IVoiceGatewayClientFactory _gatewayFactory; @@ -47,9 +58,13 @@ public Snowflake ChannelId private IVoiceUdpClient? _udp; private DaveProtocolHandler? _daveHandler; private Snowflake _channelId; - private SpeakingFlags? _lastSpeakingFlags; + private volatile int _lastSpeakingFlags = -1; + private Cts? _receiveCts; + private Task? _receiveTask; private readonly object _stateLock = new(); + private readonly object _receiveLock = new(); + private volatile bool _disposed; private Cts _stateUpdateCts; private Tcs _readyTcs; @@ -97,8 +112,10 @@ public void OnVoiceStateUpdate(Snowflake? channelId, string sessionId) lock (_stateLock) { - if (_gateway != null && (_gateway.SessionId != sessionId || ChannelId == channelId)) + if (_gateway != null && (_gateway.SessionId != sessionId || _channelId == channelId)) + { return; + } OnStateUpdate(); @@ -173,17 +190,24 @@ await Gateway.SendAsync(new VoiceGatewayPayloadJsonModel } }, cancellationToken).ConfigureAwait(false); - _lastSpeakingFlags = flags; + _lastSpeakingFlags = (int) flags; success = true; } catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) { throw; } + catch (ObjectDisposedException) + { + // The connection is shutting down; sending Speaking is best-effort. + return; + } catch (Exception ex) { if (ex is VoiceConnectionException) + { throw; + } await WaitUntilReadyAsync(cancellationToken).ConfigureAwait(false); } @@ -193,7 +217,293 @@ await Gateway.SendAsync(new VoiceGatewayPayloadJsonModel public ValueTask SendPacketAsync(ReadOnlyMemory opus, CancellationToken cancellationToken = default) { - return Udp.SendAsync(opus, cancellationToken); + var udp = _udp; + if (udp != null) + { + return udp.SendAsync(opus, cancellationToken); + } + + return SendPacketAfterReadyAsync(opus, cancellationToken); + } + + private async ValueTask SendPacketAfterReadyAsync(ReadOnlyMemory opus, CancellationToken cancellationToken) + { + await WaitUntilReadyAsync(cancellationToken).ConfigureAwait(false); + await Udp.SendAsync(opus, cancellationToken).ConfigureAwait(false); + } + + public ValueTask SetPacketSinkAsync(VoicePacketSinkDelegate? sink, CancellationToken cancellationToken = default) + { + _packetSink = sink; + + lock (_receiveLock) + { + if (sink != null) + { + TryStartReceiveLoop(CancellationToken.None); + } + else + { + StopReceiveLoop(); + } + } + + return default; + } + + private void TryStartReceiveLoop(CancellationToken parentToken = default) + { + Debug.Assert(Monitor.IsEntered(_receiveLock)); + + if (_packetSink == null) + { + return; + } + + if (_udp == null) + { + return; + } + + if (_receiveTask != null && !_receiveTask.IsCompleted) + { + return; + } + + _receiveCts?.Dispose(); + _receiveCts = parentToken.CanBeCanceled ? Cts.Linked(parentToken) : new Cts(); + _receiveTask = RunReceiveLoopAsync(_receiveCts.Token); + } + + private void StopReceiveLoop() + { + Debug.Assert(Monitor.IsEntered(_receiveLock)); + + _receiveCts?.Cancel(); + } + + private async Task RunReceiveLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + VoiceReceivePacket? packet; + try + { + packet = await Udp.ReceiveAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested || _disposed) + { + return; + } + catch (Exception ex) + { + if (_disposed) + { + return; + } + + Logger.LogError(ex, "An exception occurred while receiving a voice packet."); + continue; + } + + if (packet == null) + { + continue; + } + + var mappedPacket = packet.Value; + if (mappedPacket.UserId == null) + { + var gateway = _gateway; + if (gateway != null && gateway.TryGetUserId(mappedPacket.Ssrc, out var userId)) + { + mappedPacket.UserId = userId; + } + } + + var sink = _packetSink; + if (sink != null) + { + try + { + var task = sink(mappedPacket); + if (!task.IsCompletedSuccessfully) + { + await task.ConfigureAwait(false); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "An exception occurred in the voice packet sink."); + } + } + else + { + mappedPacket.Dispose(); + } + } + } + + /// + /// Tears down the current session's gateway, UDP, DAVE, and receive loop resources. + /// Called in the finally block of after each connection attempt. + /// + private async Task CleanupSessionResourcesAsync() + { + var gatewayDisposeTask = default(ValueTask); + var udpCloseTask = default(ValueTask); + + lock (_receiveLock) + { + StopReceiveLoop(); + } + + // Notify listeners that all users have disconnected before tearing down the gateway. + if (_gateway != null) + { + foreach (var userId in _gateway.ConnectedUserIds) + UserDisconnected?.Invoke(userId); + } + + lock (_stateLock) + { + if (_gateway != null) + { + gatewayDisposeTask = _gateway.DisposeAsync(); + _gateway = null; + } + + if (_udp != null) + { + udpCloseTask = _udp.CloseAsync(default); + _synchronizer.Unsubscribe(_udp); + _udp.Dispose(); + _udp = null; + } + + if (_readyTcs.Task.IsCompleted) + { + _readyTcs = new Tcs(); + } + } + + await gatewayDisposeTask.ConfigureAwait(false); + await udpCloseTask.ConfigureAwait(false); + + // Await receive loop completion BEFORE disposing the DAVE handler. + // The receive loop holds references to DaveDecryptor instances obtained + // via GetDecryptor(); disposing the handler (which calls ClearDecryptors) + // while a decrypt operation is in-flight would free native handles prematurely. + if (_receiveTask != null) + { + try + { + await _receiveTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { } + + _receiveTask = null; + } + + _daveHandler?.Dispose(); + _daveHandler = null; + + _receiveCts?.Dispose(); + _receiveCts = null; + } + + /// + /// Creates and configures the voice gateway, UDP client, and (optionally) DAVE handler + /// for a single session, then marks the connection as ready. + /// + /// The gateway run task to await for the lifetime of this session. + private async Task EstablishSessionAsync( + string sessionId, string token, string endpoint, + CancellationToken cancellationToken) + { + Logger.LogDebug("Created voice gateway: Session ID: {0}, Token: {1}", sessionId, token); + _gateway = _gatewayFactory.Create(GuildId, CurrentMemberId, sessionId, token, endpoint, Dave.IsAvailable ? Dave.MaxSupportedVersion : 0, Logger); + + Gateway.UserConnected = userId => UserConnected?.Invoke(userId); + Gateway.UserDisconnected = userId => UserDisconnected?.Invoke(userId); + + Gateway.SuspendAfterSessionDescription(); + var gatewayRunTask = Gateway.RunAsync(cancellationToken); + + var readyModel = await Gateway.WaitForReadyAsync(cancellationToken).ConfigureAwait(false); + + var encryption = _encryptionProvider.GetEncryption(readyModel.Modes); + if (encryption == null || !readyModel.Modes.AsSpan().Contains(encryption.ModeName)) + { + throw new VoiceConnectionException($"The encryption provider does not support any of the encryption modes that Discord returned ({string.Join(", ", readyModel.Modes)})."); + } + + _udp = _udpFactory.Create(readyModel.Ssrc, readyModel.Ip, readyModel.Port, Logger, encryption); + await Udp.ConnectAsync(cancellationToken).ConfigureAwait(false); + + await Gateway.SendAsync(new VoiceGatewayPayloadJsonModel + { + Op = VoiceGatewayPayloadOperation.SelectProtocol, + D = new SelectProtocolJsonModel + { + Protocol = "udp", + Data = new SelectProtocolDataJsonModel + { + Address = Udp.RemoteHostName!, + Port = Udp.RemotePort!.Value, + Mode = encryption.ModeName + } + } + }, cancellationToken).ConfigureAwait(false); + + var sessionDescriptionModel = await Gateway.WaitForSessionDescriptionAsync(cancellationToken).ConfigureAwait(false); + if (sessionDescriptionModel.DaveProtocolVersion is > 0) + { + if (!Dave.IsAvailable) + { + throw new VoiceConnectionException( + $"The voice server requires DAVE end-to-end encryption (protocol version {sessionDescriptionModel.DaveProtocolVersion}), " + + "but the native 'libdave' library could not be found. " + + "Ensure the native library is available in the application's search path."); + } + + _daveHandler = new DaveProtocolHandler(Gateway, (ushort) sessionDescriptionModel.DaveProtocolVersion, GuildId, CurrentMemberId, _loggerFactory); + await _daveHandler.InitializeAsync(cancellationToken).ConfigureAwait(false); + } + + Gateway.ResumeAfterSessionDescription(_daveHandler != null ? _daveHandler.HandleMessageAsync : null); + + Udp.Initialize(sessionDescriptionModel.SecretKey, _daveHandler?.Encryptor); + + if (_udp is DefaultVoiceUdpClient defaultUdp) + { + defaultUdp.SetDaveHandler(_daveHandler, _daveHandler != null ? Gateway : null); + } + + _synchronizer.Subscribe(_udp); + + lock (_receiveLock) + { + TryStartReceiveLoop(cancellationToken); + } + + var lastSpeakingFlags = _lastSpeakingFlags; + if (lastSpeakingFlags >= 0) + { + await SetSpeakingFlagsAsync((SpeakingFlags) lastSpeakingFlags, cancellationToken).ConfigureAwait(false); + } + + lock (_stateLock) + { + _readyTcs.Complete(); + } + + return gatewayRunTask; } public async Task RunAsync(CancellationToken stoppingToken) @@ -256,6 +566,7 @@ public async Task RunAsync(CancellationToken stoppingToken) linkedCancellationToken.ThrowIfCancellationRequested(); var exception = new VoiceConnectionException("Forcibly disconnected from the voice channel."); _readyTcs.Throw(exception); + await _setVoiceStateDelegate(GuildId, null, default).ConfigureAwait(false); return; } @@ -288,75 +599,7 @@ public async Task RunAsync(CancellationToken stoppingToken) } var voiceState = voiceStateUpdateTask.Result; - - Logger.LogDebug("Created voice gateway: Session ID: {0}, Token: {1}", voiceState.SessionId, voiceServer.Token); - _gateway = _gatewayFactory.Create(GuildId, CurrentMemberId, voiceState.SessionId, voiceServer.Token, voiceServer.Endpoint, Dave.IsAvailable ? Dave.MaxSupportedVersion : 0, Logger); - - Gateway.SuspendAfterSessionDescription(); - var gatewayRunTask = Gateway.RunAsync(linkedCancellationToken); - - var readyModel = await Gateway.WaitForReadyAsync(linkedCancellationToken).ConfigureAwait(false); - - var encryption = _encryptionProvider.GetEncryption(readyModel.Modes); - if (encryption == null || !readyModel.Modes.AsSpan().Contains(encryption.ModeName)) - { - var exception = new VoiceConnectionException($"The encryption provider does not support any of the encryption modes that Discord returned ({string.Join(", ", readyModel.Modes)})."); - _readyTcs.Throw(exception); - return; - } - - _udp = _udpFactory.Create(readyModel.Ssrc, readyModel.Ip, readyModel.Port, Logger, encryption); - await Udp.ConnectAsync(linkedCancellationToken).ConfigureAwait(false); - - await Gateway.SendAsync(new VoiceGatewayPayloadJsonModel - { - Op = VoiceGatewayPayloadOperation.SelectProtocol, - D = new SelectProtocolJsonModel - { - Protocol = "udp", - Data = new SelectProtocolDataJsonModel - { - Address = Udp.RemoteHostName!, - Port = Udp.RemotePort!.Value, - Mode = encryption.ModeName - } - } - }, linkedCancellationToken).ConfigureAwait(false); - - var sessionDescriptionModel = await Gateway.WaitForSessionDescriptionAsync(linkedCancellationToken).ConfigureAwait(false); - if (sessionDescriptionModel.DaveProtocolVersion is > 0) - { - if (!Dave.IsAvailable) - { - var exception = new VoiceConnectionException( - $"The voice server requires DAVE end-to-end encryption (protocol version {sessionDescriptionModel.DaveProtocolVersion}), " - + "but the native 'libdave' library could not be found. " - + "Ensure the native library is available in the application's search path."); - - _readyTcs.Throw(exception); - return; - } - - _daveHandler = new DaveProtocolHandler(Gateway, (ushort) sessionDescriptionModel.DaveProtocolVersion, GuildId, CurrentMemberId, _loggerFactory); - await _daveHandler.InitializeAsync(linkedCancellationToken).ConfigureAwait(false); - } - - Gateway.ResumeAfterSessionDescription(_daveHandler != null ? _daveHandler.HandleMessageAsync : null); - - Udp.Initialize(sessionDescriptionModel.SecretKey, _daveHandler?.Encryptor); - - _synchronizer.Subscribe(_udp); - - if (_lastSpeakingFlags != null) - { - await SetSpeakingFlagsAsync(_lastSpeakingFlags.Value, linkedCancellationToken).ConfigureAwait(false); - } - - lock (_stateLock) - { - _readyTcs.Complete(); - } - + var gatewayRunTask = await EstablishSessionAsync(voiceState.SessionId, voiceServer.Token, voiceServer.Endpoint, linkedCancellationToken).ConfigureAwait(false); await gatewayRunTask.ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) @@ -367,6 +610,12 @@ await Gateway.SendAsync(new VoiceGatewayPayloadJsonModel } catch (OperationCanceledException) when (stateCancellationToken.IsCancellationRequested) { + if (_disposed) + { + await _setVoiceStateDelegate(GuildId, null, default).ConfigureAwait(false); + return; + } + lastCloseCode = VoiceGatewayCloseCode.ForciblyDisconnected; } catch (WebSocketClosedException ex) @@ -375,35 +624,7 @@ await Gateway.SendAsync(new VoiceGatewayPayloadJsonModel } finally { - var gatewayDisposeTask = default(ValueTask); - var udpCloseTask = default(ValueTask); - lock (_stateLock) - { - if (_gateway != null) - { - gatewayDisposeTask = _gateway.DisposeAsync(); - _gateway = null; - } - - if (_udp != null) - { - udpCloseTask = _udp.CloseAsync(default); - _synchronizer.Unsubscribe(_udp); - _udp.Dispose(); - _udp = null; - } - - _daveHandler?.Dispose(); - _daveHandler = null; - - if (_readyTcs.Task.IsCompleted) - { - _readyTcs = new Tcs(); - } - } - - await gatewayDisposeTask.ConfigureAwait(false); - await udpCloseTask.ConfigureAwait(false); + await CleanupSessionResourcesAsync().ConfigureAwait(false); } } } @@ -412,10 +633,7 @@ await Gateway.SendAsync(new VoiceGatewayPayloadJsonModel { await _setVoiceStateDelegate(GuildId, null, default).ConfigureAwait(false); - lock (_readyTcs) - { - _readyTcs.Throw(ex); - } + _readyTcs.Throw(ex); Logger.LogError(ex, "An exception occurred in the voice connection."); } @@ -435,21 +653,78 @@ public Task WaitUntilReadyAsync(CancellationToken cancellationToken = default) public ValueTask DisposeAsync() { - _stateUpdateCts.Dispose(); + lock (_receiveLock) + { + StopReceiveLoop(); + } - if (_udp != null) + if (_gateway != null) { - _synchronizer.Unsubscribe(_udp); - _udp.Dispose(); + foreach (var userId in _gateway.ConnectedUserIds) + UserDisconnected?.Invoke(userId); } - _daveHandler?.Dispose(); + IVoiceGatewayClient? gateway; + IVoiceUdpClient? udp; + DaveProtocolHandler? daveHandler; + lock (_stateLock) + { + _disposed = true; - if (_gateway != null) + if (!_stateUpdateCts.IsCancellationRequested) + { + _stateUpdateCts.Cancel(); + } + + if (!_readyTcs.Task.IsCompleted) + { + _readyTcs.Throw(new VoiceConnectionException("The voice connection has been disposed.")); + } + + gateway = _gateway; + _gateway = null; + + udp = _udp; + _udp = null; + + daveHandler = _daveHandler; + _daveHandler = null; + } + + return DisposeAsyncCore(udp, daveHandler, gateway); + } + + private async ValueTask DisposeAsyncCore(IVoiceUdpClient? udp, DaveProtocolHandler? daveHandler, IVoiceGatewayClient? gateway) + { + // Await receive loop before disposing resources it depends on. + if (_receiveTask != null) { - return Gateway.DisposeAsync(); + try + { + await _receiveTask.ConfigureAwait(false); + } + catch + { } + + _receiveTask = null; } - return default; + _receiveCts?.Dispose(); + _receiveCts = null; + + _stateUpdateCts.Dispose(); + + if (udp != null) + { + _synchronizer.Unsubscribe(udp); + udp.Dispose(); + } + + daveHandler?.Dispose(); + + if (gateway != null) + { + await gateway.DisposeAsync().ConfigureAwait(false); + } } } diff --git a/src/Disqord.Voice/Default/DefaultVoiceUdpClient.cs b/src/Disqord.Voice/Default/DefaultVoiceUdpClient.cs index ec74147a7..08108353c 100644 --- a/src/Disqord.Voice/Default/DefaultVoiceUdpClient.cs +++ b/src/Disqord.Voice/Default/DefaultVoiceUdpClient.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using System.Threading.Tasks.Sources; using Disqord.Udp; +using Disqord.Voice.Api; using Microsoft.Extensions.Logging; using Qommon; using Qommon.Pooling; @@ -14,6 +15,9 @@ namespace Disqord.Voice.Default; public class DefaultVoiceUdpClient : IVoiceUdpClient, IValueTaskSource { + private const int MaxUdpDatagramSize = 65535; + private const int ReceivePacketPoolCapacity = 16; + public IUdpClient UdpClient { get; } public uint Ssrc { get; } @@ -35,8 +39,12 @@ public class DefaultVoiceUdpClient : IVoiceUdpClient, IValueTaskSource public uint Timestamp => _timestamp; private readonly ILogger _logger; + private readonly ObjectPool _receivePacketPool; private byte[] _encryptionKey = Array.Empty(); private DaveEncryptor? _daveEncryptor; + private volatile DaveProtocolHandler? _daveHandler; + private volatile IVoiceGatewayClient? _gateway; + private readonly byte[] _receiveBuffer = new byte[MaxUdpDatagramSize]; private ushort _sequence; private uint _timestamp; @@ -44,8 +52,9 @@ public class DefaultVoiceUdpClient : IVoiceUdpClient, IValueTaskSource private const int TaskPendingState = 0; private const int TaskCompletedState = 1; - private volatile bool _isDisposed; + private int _isDisposed; + private int _isSending; private int _taskState = TaskCompletedState; private ManualResetValueTaskSourceCore _mrvtsc = new() { @@ -65,6 +74,9 @@ public DefaultVoiceUdpClient( Port = port; _logger = logger; Encryption = encryption; + var receivePacketPoolPolicy = new ReceivePacketStatePoolPolicy(); + _receivePacketPool = new DefaultObjectPool(receivePacketPoolPolicy, ReceivePacketPoolCapacity); + receivePacketPoolPolicy.Bind(_receivePacketPool); UdpClient = udpClientFactory.CreateClient(); } @@ -76,11 +88,19 @@ public void Initialize(byte[] encryptionKey, DaveEncryptor? daveEncryptor) daveEncryptor?.AssignSsrcToCodec(Ssrc, Dave.Codec.Opus); } + public void SetDaveHandler(DaveProtocolHandler? handler, IVoiceGatewayClient? gateway) + { + _daveHandler = handler; + _gateway = gateway; + } + /// public void OnSynchronizerTick() { if (Interlocked.Exchange(ref _taskState, TaskCompletedState) != TaskPendingState) + { return; + } _mrvtsc.SetResult(true); } @@ -112,25 +132,271 @@ public ValueTask CloseAsync(CancellationToken cancellationToken = default) /// public async ValueTask SendAsync(ReadOnlyMemory opus, CancellationToken cancellationToken = default) { - if (_isDisposed) + if (Volatile.Read(ref _isDisposed) != 0) + { Throw.ObjectDisposedException(GetType().Name); + } if (opus.Length == 0) + { return; + } + + if (Interlocked.Exchange(ref _isSending, 1) != 0) + { + Throw.InvalidOperationException($"Invalid concurrent {nameof(SendAsync)} call."); + } + + try + { + _mrvtsc.Reset(); + + if (Interlocked.Exchange(ref _taskState, TaskPendingState) != TaskCompletedState) + { + Throw.InvalidOperationException($"Invalid {nameof(SendAsync)} state."); + } + + await new ValueTask(this, _mrvtsc.Version).ConfigureAwait(false); + + using (var packet = CreateVoicePacket(opus.Span)) + { + await UdpClient.SendAsync(packet, cancellationToken).ConfigureAwait(false); + } + + _sequence++; + _timestamp += VoiceConstants.AudioSize; + } + finally + { + Volatile.Write(ref _isSending, 0); + } + } + + /// + public async ValueTask ReceiveAsync(CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _isDisposed) != 0) + { + Throw.ObjectDisposedException(GetType().Name); + } + + var bytesRead= await UdpClient.ReceiveAsync(_receiveBuffer, cancellationToken).ConfigureAwait(false); + if (bytesRead == 0) + { + return null; + } + + var packet = _receiveBuffer.AsSpan(0, bytesRead); + if (packet.Length <= 8) + { + return null; + } + + // Discovery response packet. + if (packet.Length == 74 && packet[1] == 2) + { + return null; + } + + if ((packet[0] >> 6) != 2) + { + return null; + } + + // RTCP packet types use the full second byte (RFC 5761). + // Do not mask with the RTP marker bit here, otherwise RTCP packets can be misclassified as RTP. + var packetType = packet[1]; + if (packetType is >= 192 and <= 223) + { + return null; + } + + var csrcCount = packet[0] & 0x0F; + var rtpHeaderLength = VoiceConstants.RtpHeaderSize + csrcCount * 4; + if (packet.Length <= rtpHeaderLength) + { + return null; + } + + var sequence = BinaryPrimitives.ReadUInt16BigEndian(packet[2..]); + var timestamp = BinaryPrimitives.ReadUInt32BigEndian(packet[4..]); + var ssrc = BinaryPrimitives.ReadUInt32BigEndian(packet[8..]); + + var hasRtpExtension = (packet[0] & 0x10) != 0; + var extensionPreambleLength = hasRtpExtension ? 4 : 0; + var encryptedAudioOffset = rtpHeaderLength + extensionPreambleLength; + if (packet.Length <= encryptedAudioOffset) + { + return null; + } + + var encryptedAudio = packet[encryptedAudioOffset..]; + var decryptedLength = Encryption.GetDecryptedLength(encryptedAudio.Length); + if (decryptedLength <= 0) + { + return null; + } + + byte[]? rentedTransportBuffer = null; + try + { + var transportAudio = decryptedLength <= 4096 + ? stackalloc byte[decryptedLength] + : rentedTransportBuffer = ArrayPool.Shared.Rent(decryptedLength); + + Encryption.Decrypt(packet[..encryptedAudioOffset], transportAudio[..decryptedLength], encryptedAudio, _encryptionKey); + + // Strip RTP padding if present (RFC 3550 §5.1). + // Padding bytes are at the end of the decrypted payload; the last byte indicates the count. + // Padding must be stripped BEFORE extension stripping and DAVE decrypt; otherwise + // the extra bytes at the end corrupt the DAVE supplemental structure (magic marker [0xFA, 0xFA]). + var decryptedPayload = transportAudio[..decryptedLength]; + var hasPadding = (packet[0] & 0x20) != 0; + if (hasPadding && decryptedPayload.Length > 0) + { + var paddingLength = decryptedPayload[^1]; + if (paddingLength > 0 && paddingLength <= decryptedPayload.Length) + { + decryptedPayload = decryptedPayload[..^paddingLength]; + } + } + + var media = StripRtpHeaderExtensions(packet, rtpHeaderLength, decryptedPayload); + if (media.Length == 0) + { + return null; + } + + if (_daveHandler != null) + { + // DAVE requires per-user decryption. Map SSRC -> UserId -> DaveDecryptor. + // If the mapping or decryptor isn't available yet (Speaking/ClientConnect not + // yet processed), drop the packet. AudioReceiver fills the resulting gap with + // silence, and the lost packets are typically warm-up silence frames anyway. + var mappedUserId = _gateway != null && _gateway.TryGetUserId(ssrc, out var uid) ? uid : (Snowflake?) null; + if (!mappedUserId.HasValue) + { + return null; + } + + using var lease = _daveHandler.GetDecryptor(mappedUserId.Value); + if (lease.Decryptor == null) + { + return null; + } + + if (!TryDecryptDavePacket(lease.Decryptor, media, hasRtpExtension, ssrc, out var rentedOpusArray, out var opusLength)) + { + return null; + } + + return rentedOpusArray != null + ? RentReceivePacket(sequence, timestamp, ssrc, mappedUserId, rentedOpusArray, opusLength) + : RentReceivePacket(sequence, timestamp, ssrc, mappedUserId, VoiceConstants.SilencePacket); + } + + var rentedMedia = ArrayPool.Shared.Rent(media.Length); + media.CopyTo(rentedMedia); + return RentReceivePacket(sequence, timestamp, ssrc, userId: null, rentedMedia, media.Length); + } + catch (VoiceEncryptionException) + { + throw; + } + catch (Exception ex) + { + throw new VoiceEncryptionException("Failed to decrypt a received voice packet.", ex); + } + finally + { + if (rentedTransportBuffer != null) + { + ArrayPool.Shared.Return(rentedTransportBuffer); + } + } + } + + private bool TryDecryptDavePacket(DaveDecryptor daveDecryptor, ReadOnlySpan media, bool hasRtpExtension, uint ssrc, out byte[]? rentedOpusArray, out int opusLength) + { + rentedOpusArray = null; + opusLength = 0; + + var maxDavePlaintextByteSize = (int) daveDecryptor.GetMaxPlaintextByteSize(Dave.MediaType.Audio, (nuint) media.Length); + if (maxDavePlaintextByteSize <= 0) + { + return false; + } + + // Rent the output buffer directly - decrypt into it, no intermediate copy. + rentedOpusArray = ArrayPool.Shared.Rent(maxDavePlaintextByteSize); + + var result = daveDecryptor.Decrypt(Dave.MediaType.Audio, media, rentedOpusArray.AsSpan(0, maxDavePlaintextByteSize), out var bytesWritten); + + if (result != Dave.DecryptorResultCode.Success) + { + _daveHandler?.OnDecryptResult(false); + ArrayPool.Shared.Return(rentedOpusArray); + rentedOpusArray = null; + + // Log at Debug - but only when not transitioning. During transitions, decrypt failures + // are expected and self-recover once the new key ratchet is committed. + if (_logger.IsEnabled(LogLevel.Debug) && !(_daveHandler?.IsTransitioning ?? false)) + { + _logger.LogDebug( + "DAVE decryption failed for SSRC {Ssrc}, result={Result}, MediaLen={MediaLen}.", + ssrc, result, media.Length); + } + + return false; + } + + _daveHandler?.OnDecryptResult(true); + + if (bytesWritten == 0) + { + ArrayPool.Shared.Return(rentedOpusArray); + rentedOpusArray = null; + } + else + { + opusLength = (int) bytesWritten; + } - if (Interlocked.Exchange(ref _taskState, TaskPendingState) != TaskCompletedState) - Throw.InvalidOperationException($"Invalid {nameof(SendAsync)} call."); + return true; + } - _mrvtsc.Reset(); - await new ValueTask(this, _mrvtsc.Version).ConfigureAwait(false); + private VoiceReceivePacket RentReceivePacket(ushort sequence, uint timestamp, uint ssrc, Snowflake? userId, ReadOnlyMemory opus) + { + var state = _receivePacketPool.Rent(); + return state.Initialize(sequence, timestamp, ssrc, userId, opus, rentedArray: null); + } + + private VoiceReceivePacket RentReceivePacket(ushort sequence, uint timestamp, uint ssrc, Snowflake? userId, byte[] rentedArray, int length) + { + var state = _receivePacketPool.Rent(); + return state.Initialize(sequence, timestamp, ssrc, userId, new ReadOnlyMemory(rentedArray, 0, length), rentedArray); + } + + private static ReadOnlySpan StripRtpHeaderExtensions(ReadOnlySpan packet, int rtpHeaderLength, ReadOnlySpan payload) + { + if ((packet[0] & 0x10) == 0) + { + return payload; + } - using (var packet = CreateVoicePacket(opus.Span)) + if (packet.Length < rtpHeaderLength + 4) { - await UdpClient.SendAsync(packet, cancellationToken).ConfigureAwait(false); + return ReadOnlySpan.Empty; } - _sequence++; - _timestamp += VoiceConstants.AudioSize; + // Discord encrypts the RTP header extension contents but leaves the 4-byte extension preamble + // in cleartext and authenticates it as part of the RTP header. + var extensionLengthWords = BinaryPrimitives.ReadUInt16BigEndian(packet[(rtpHeaderLength + 2)..]); + var extensionLength = extensionLengthWords * 4; + + return extensionLength >= payload.Length + ? ReadOnlySpan.Empty + : payload[extensionLength..]; } private RentedArray CreateVoicePacket(ReadOnlySpan opus) @@ -222,7 +488,9 @@ private void ReadDiscovery(ReadOnlySpan packet) var addressSpan = packet.Slice(8, 64); var nullIndex = addressSpan.IndexOf((byte) 0); if (nullIndex >= 0) + { addressSpan = addressSpan[..nullIndex]; + } RemoteHostName = Encoding.UTF8.GetString(addressSpan); RemotePort = BinaryPrimitives.ReadUInt16BigEndian(packet[72..]); @@ -230,15 +498,16 @@ private void ReadDiscovery(ReadOnlySpan packet) public void Dispose() { - if (_isDisposed) + if (Interlocked.Exchange(ref _isDisposed, 1) != 0) + { return; + } if (Interlocked.Exchange(ref _taskState, TaskPendingState) == TaskPendingState) { _mrvtsc.SetException(new ObjectDisposedException(GetType().Name)); } - _isDisposed = true; UdpClient.Dispose(); } @@ -256,4 +525,25 @@ void IValueTaskSource.OnCompleted(Action continuation, object? state, s { _mrvtsc.OnCompleted(continuation, state, token, flags); } + + private sealed class ReceivePacketStatePoolPolicy : PooledObjectPolicy + { + private ObjectPool? _pool; + + public void Bind(ObjectPool pool) + { + _pool = pool; + } + + public override VoiceReceivePacket.PacketState Create() + { + return new VoiceReceivePacket.PacketState(_pool!); + } + + public override bool OnReturn(VoiceReceivePacket.PacketState obj) + { + obj.ResetForPooling(); + return true; + } + } } diff --git a/src/Disqord.Voice/Encryption/Dave/Dave.cs b/src/Disqord.Voice/Encryption/Dave/Dave.cs index 2a67b87aa..4f03394c1 100644 --- a/src/Disqord.Voice/Encryption/Dave/Dave.cs +++ b/src/Disqord.Voice/Encryption/Dave/Dave.cs @@ -23,8 +23,10 @@ public static unsafe partial class Dave /// public static ushort MaxSupportedVersion { get; } - private static readonly LogSinkCallback? _logSinkCallback; + private static readonly LogSinkCallback? _noOpLogSinkCallback; + private static LogSinkCallback? _forwardingLogSinkCallback; private static ILogger? _nativeLogger; + private static volatile bool _nativeLoggingEnabled; static Dave() { @@ -38,36 +40,88 @@ static Dave() IsAvailable = false; } - _logSinkCallback = OnNativeLog; - SetLogSinkCallback(_logSinkCallback); + if (IsAvailable) + { + // Suppress the default stdout logging in libdave. By default, native logs are disabled + // and only forwarded to the ILogger when EnableNativeLogging is called. + _noOpLogSinkCallback = static (_, _, _, _) => { }; + SetLogSinkCallback(_noOpLogSinkCallback); + } + } + + /// + /// Enables or disables native DAVE library logging. + /// When enabled, native logs are forwarded to the + /// under the "Voice DAVE" category at and levels. + /// + public static bool IsNativeLoggingEnabled + { + get => _nativeLoggingEnabled; + set + { + _nativeLoggingEnabled = value; + + if (!IsAvailable) + { + return; + } + + if (value && _forwardingLogSinkCallback != null) + { + SetLogSinkCallback(_forwardingLogSinkCallback); + } + else + { + SetLogSinkCallback(_noOpLogSinkCallback!); + } + } } internal static void SetLoggerFactory(ILoggerFactory loggerFactory) { if (Volatile.Read(ref _nativeLogger) != null) + { return; + } var logger = loggerFactory.CreateLogger("Voice DAVE"); - Interlocked.CompareExchange(ref _nativeLogger, logger, null); + if (Interlocked.CompareExchange(ref _nativeLogger, logger, null) != null) + { + return; + } + + _forwardingLogSinkCallback = OnNativeLog; + + if (_nativeLoggingEnabled) + { + SetLogSinkCallback(_forwardingLogSinkCallback); + } } private static void OnNativeLog(LoggingSeverity severity, byte* file, int line, byte* message) { var logger = _nativeLogger; if (logger == null) + { return; + } + // Downgrade all native log levels. The native library logs protocol-level + // issues (e.g., "unexpected group", "Decrypt failed") at Error/Warning, but these are + // expected during DAVE transitions and handled by our C# protocol handler. var logLevel = severity switch { - LoggingSeverity.Verbose => LogLevel.Trace, + LoggingSeverity.Error => LogLevel.Debug, + LoggingSeverity.Warning => LogLevel.Debug, LoggingSeverity.Info => LogLevel.Debug, - LoggingSeverity.Warning => LogLevel.Warning, - LoggingSeverity.Error => LogLevel.Error, + LoggingSeverity.Verbose => LogLevel.Trace, _ => LogLevel.None }; if (!logger.IsEnabled(logLevel)) + { return; + } var messageStr = Marshal.PtrToStringUTF8((nint) message); logger.Log(logLevel, "{Message}", messageStr); diff --git a/src/Disqord.Voice/Encryption/Dave/DaveDecryptor.cs b/src/Disqord.Voice/Encryption/Dave/DaveDecryptor.cs index a3437f43c..1613ceb43 100644 --- a/src/Disqord.Voice/Encryption/Dave/DaveDecryptor.cs +++ b/src/Disqord.Voice/Encryption/Dave/DaveDecryptor.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; namespace Disqord.Voice; @@ -20,7 +21,7 @@ public nint Handle } private nint _handle; - private bool _isDisposed; + private int _disposed; /// /// Creates a new DAVE decryptor. @@ -36,7 +37,7 @@ private DaveDecryptor(nint handle) private void ThrowIfDisposed() { - ObjectDisposedException.ThrowIf(_isDisposed, this); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); } /// @@ -104,10 +105,9 @@ public Dave.DecryptorStats GetStats(Dave.MediaType mediaType) /// public void Dispose() { - if (_isDisposed) + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; - _isDisposed = true; Dave.DecryptorDestroy(_handle); _handle = 0; } diff --git a/src/Disqord.Voice/Encryption/Dave/DaveEncryptor.cs b/src/Disqord.Voice/Encryption/Dave/DaveEncryptor.cs index 8f5dfb160..970e18fbf 100644 --- a/src/Disqord.Voice/Encryption/Dave/DaveEncryptor.cs +++ b/src/Disqord.Voice/Encryption/Dave/DaveEncryptor.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; namespace Disqord.Voice; @@ -20,7 +21,7 @@ public nint Handle } private nint _handle; - private bool _isDisposed; + private int _disposed; /// /// Creates a new DAVE encryptor. @@ -36,7 +37,7 @@ private DaveEncryptor(nint handle) private void ThrowIfDisposed() { - ObjectDisposedException.ThrowIf(_isDisposed, this); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); } /// @@ -147,10 +148,9 @@ public Dave.EncryptorStats GetStats(Dave.MediaType mediaType) /// public void Dispose() { - if (_isDisposed) + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; - _isDisposed = true; Dave.EncryptorDestroy(_handle); _handle = 0; } diff --git a/src/Disqord.Voice/Encryption/Dave/DaveSession.cs b/src/Disqord.Voice/Encryption/Dave/DaveSession.cs index 2d5bfba19..fa2240e56 100644 --- a/src/Disqord.Voice/Encryption/Dave/DaveSession.cs +++ b/src/Disqord.Voice/Encryption/Dave/DaveSession.cs @@ -1,6 +1,7 @@ using System; using System.Buffers.Text; using System.Diagnostics; +using System.Threading; namespace Disqord.Voice; @@ -26,7 +27,7 @@ public nint Handle private nint _handle; private Dave.MlsFailureCallback? _failureCallback; - private bool _isDisposed; + private int _disposed; private DaveSession(nint handle, Dave.MlsFailureCallback? failureCallback) { @@ -36,7 +37,7 @@ private DaveSession(nint handle, Dave.MlsFailureCallback? failureCallback) private void ThrowIfDisposed() { - ObjectDisposedException.ThrowIf(_isDisposed, this); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); } /// @@ -219,10 +220,9 @@ public void GetPairwiseFingerprint(ushort version, Snowflake userId, /// public void Dispose() { - if (_isDisposed) + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; - _isDisposed = true; Dave.SessionDestroy(_handle); _handle = 0; _failureCallback = null; diff --git a/src/Disqord.Voice/Encryption/Default/Modes/AEADAes256GcmRtpSizeEncryption.cs b/src/Disqord.Voice/Encryption/Default/Modes/AEADAes256GcmRtpSizeEncryption.cs index 684788331..4c405a0a4 100644 --- a/src/Disqord.Voice/Encryption/Default/Modes/AEADAes256GcmRtpSizeEncryption.cs +++ b/src/Disqord.Voice/Encryption/Default/Modes/AEADAes256GcmRtpSizeEncryption.cs @@ -33,6 +33,12 @@ public int GetEncryptedLength(int length) return length + _abytes + 4; } + /// + public int GetDecryptedLength(int length) + { + return length - _abytes - 4; + } + /// public unsafe void Encrypt(ReadOnlySpan rtpHeader, Span encryptedAudio, ReadOnlySpan audio, ReadOnlySpan key) { @@ -59,4 +65,29 @@ public unsafe void Encrypt(ReadOnlySpan rtpHeader, Span encryptedAud _nonceValue++; } + + /// + public unsafe void Decrypt(ReadOnlySpan rtpHeader, Span audio, ReadOnlySpan encryptedAudio, ReadOnlySpan key) + { + var nonce = (stackalloc byte[_npubbytes]); + encryptedAudio[^4..].CopyTo(nonce); + + var ciphertext = encryptedAudio[..^4]; + fixed (byte* audioPtr = audio) + fixed (byte* rtpHeaderPtr = rtpHeader) + fixed (byte* ciphertextPtr = ciphertext) + fixed (byte* noncePtr = nonce) + fixed (byte* keyPtr = key) + { + var result = Sodium.crypto_aead_aes256gcm_decrypt(m: audioPtr, mlen_p: null, + nsec: null, c: ciphertextPtr, clen: (ulong) ciphertext.Length, + ad: rtpHeaderPtr, adlen: (ulong) rtpHeader.Length, + npub: noncePtr, k: keyPtr); + + if (result != 0) + { + throw new VoiceEncryptionException($"Failed to decrypt with '{ModeName}' ({result})."); + } + } + } } diff --git a/src/Disqord.Voice/Encryption/Default/Modes/AEADXChaCha20Poly1305RtpSizeEncryption.cs b/src/Disqord.Voice/Encryption/Default/Modes/AEADXChaCha20Poly1305RtpSizeEncryption.cs index 449aa92ad..557721124 100644 --- a/src/Disqord.Voice/Encryption/Default/Modes/AEADXChaCha20Poly1305RtpSizeEncryption.cs +++ b/src/Disqord.Voice/Encryption/Default/Modes/AEADXChaCha20Poly1305RtpSizeEncryption.cs @@ -28,6 +28,12 @@ public int GetEncryptedLength(int length) return length + _abytes + 4; } + /// + public int GetDecryptedLength(int length) + { + return length - _abytes - 4; + } + /// public unsafe void Encrypt(ReadOnlySpan rtpHeader, Span encryptedAudio, ReadOnlySpan audio, ReadOnlySpan key) { @@ -54,4 +60,29 @@ public unsafe void Encrypt(ReadOnlySpan rtpHeader, Span encryptedAud _nonceValue++; } + + /// + public unsafe void Decrypt(ReadOnlySpan rtpHeader, Span audio, ReadOnlySpan encryptedAudio, ReadOnlySpan key) + { + var nonce = (stackalloc byte[_npubbytes]); + encryptedAudio[^4..].CopyTo(nonce); + + var ciphertext = encryptedAudio[..^4]; + fixed (byte* audioPtr = audio) + fixed (byte* rtpHeaderPtr = rtpHeader) + fixed (byte* ciphertextPtr = ciphertext) + fixed (byte* noncePtr = nonce) + fixed (byte* keyPtr = key) + { + var result = Sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(m: audioPtr, mlen_p: null, + nsec: null, c: ciphertextPtr, clen: (ulong) ciphertext.Length, + ad: rtpHeaderPtr, adlen: (ulong) rtpHeader.Length, + npub: noncePtr, k: keyPtr); + + if (result != 0) + { + throw new VoiceEncryptionException($"Failed to decrypt with '{ModeName}' ({result})."); + } + } + } } diff --git a/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305Encryption.cs b/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305Encryption.cs index 145fd7133..56b773509 100644 --- a/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305Encryption.cs +++ b/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305Encryption.cs @@ -17,6 +17,12 @@ public int GetEncryptedLength(int length) return length + Sodium.XSalsa20Poly1305MacLength; } + /// + public int GetDecryptedLength(int length) + { + return length - Sodium.XSalsa20Poly1305MacLength; + } + /// public void Encrypt(ReadOnlySpan rtpHeader, Span encryptedAudio, ReadOnlySpan audio, ReadOnlySpan key) { @@ -25,4 +31,13 @@ public void Encrypt(ReadOnlySpan rtpHeader, Span encryptedAudio, Rea Sodium.Encrypt(encryptedAudio, audio, nonce, key); } + + /// + public void Decrypt(ReadOnlySpan rtpHeader, Span audio, ReadOnlySpan encryptedAudio, ReadOnlySpan key) + { + var nonce = (stackalloc byte[Sodium.XSalsa20Poly1305NonceLength]); + rtpHeader.CopyTo(nonce); + + Sodium.Decrypt(audio, encryptedAudio, nonce, key); + } } diff --git a/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305LiteEncryption.cs b/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305LiteEncryption.cs index 191fab014..9a3dfb5b4 100644 --- a/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305LiteEncryption.cs +++ b/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305LiteEncryption.cs @@ -20,6 +20,12 @@ public int GetEncryptedLength(int length) return length + Sodium.XSalsa20Poly1305MacLength + 4; } + /// + public int GetDecryptedLength(int length) + { + return length - Sodium.XSalsa20Poly1305MacLength - 4; + } + /// public void Encrypt(ReadOnlySpan rtpHeader, Span encryptedAudio, ReadOnlySpan audio, ReadOnlySpan key) { @@ -31,4 +37,13 @@ public void Encrypt(ReadOnlySpan rtpHeader, Span encryptedAudio, Rea _nonceValue++; } + + /// + public void Decrypt(ReadOnlySpan rtpHeader, Span audio, ReadOnlySpan encryptedAudio, ReadOnlySpan key) + { + var nonce = (stackalloc byte[Sodium.XSalsa20Poly1305NonceLength]); + encryptedAudio[^4..].CopyTo(nonce); + + Sodium.Decrypt(audio, encryptedAudio[..^4], nonce, key); + } } diff --git a/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305SuffixEncryption.cs b/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305SuffixEncryption.cs index b600ddd3b..6abb898a0 100644 --- a/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305SuffixEncryption.cs +++ b/src/Disqord.Voice/Encryption/Default/Modes/XSalsa20Poly1305SuffixEncryption.cs @@ -17,6 +17,12 @@ public int GetEncryptedLength(int length) return length + Sodium.XSalsa20Poly1305MacLength + Sodium.XSalsa20Poly1305NonceLength; } + /// + public int GetDecryptedLength(int length) + { + return length - Sodium.XSalsa20Poly1305MacLength - Sodium.XSalsa20Poly1305NonceLength; + } + /// public void Encrypt(ReadOnlySpan rtpHeader, Span encryptedAudio, ReadOnlySpan audio, ReadOnlySpan key) { @@ -26,4 +32,10 @@ public void Encrypt(ReadOnlySpan rtpHeader, Span encryptedAudio, Rea Sodium.Encrypt(encryptedAudio[..^Sodium.XSalsa20Poly1305NonceLength], audio, nonce, key); nonce.CopyTo(encryptedAudio[^Sodium.XSalsa20Poly1305NonceLength..]); } + + /// + public void Decrypt(ReadOnlySpan rtpHeader, Span audio, ReadOnlySpan encryptedAudio, ReadOnlySpan key) + { + Sodium.Decrypt(audio, encryptedAudio[..^Sodium.XSalsa20Poly1305NonceLength], encryptedAudio[^Sodium.XSalsa20Poly1305NonceLength..], key); + } } diff --git a/src/Disqord.Voice/Encryption/IVoiceEncryption.cs b/src/Disqord.Voice/Encryption/IVoiceEncryption.cs index 162e1ff7d..ba74db72b 100644 --- a/src/Disqord.Voice/Encryption/IVoiceEncryption.cs +++ b/src/Disqord.Voice/Encryption/IVoiceEncryption.cs @@ -21,6 +21,15 @@ public interface IVoiceEncryption /// int GetEncryptedLength(int length); + /// + /// Gets the data length after decryption for the specified encrypted data length. + /// + /// The encrypted data length. + /// + /// The data length after decryption. + /// + int GetDecryptedLength(int length); + /// /// Encrypts into using . /// @@ -29,4 +38,13 @@ public interface IVoiceEncryption /// The source span. /// The encryption key span. void Encrypt(ReadOnlySpan rtpHeader, Span encryptedAudio, ReadOnlySpan audio, ReadOnlySpan key); + + /// + /// Decrypts into using . + /// + /// The RTP header. + /// The destination span. + /// The source span. + /// The encryption key span. + void Decrypt(ReadOnlySpan rtpHeader, Span audio, ReadOnlySpan encryptedAudio, ReadOnlySpan key); } diff --git a/src/Disqord.Voice/Factory/Default/DefaultVoiceConnectionFactory.cs b/src/Disqord.Voice/Factory/Default/DefaultVoiceConnectionFactory.cs index b58c80402..00c12af99 100644 --- a/src/Disqord.Voice/Factory/Default/DefaultVoiceConnectionFactory.cs +++ b/src/Disqord.Voice/Factory/Default/DefaultVoiceConnectionFactory.cs @@ -13,10 +13,10 @@ public DefaultVoiceConnectionFactory(IServiceProvider services) _services = services; } - public IVoiceConnection Create(Snowflake guildId, Snowflake channelId, Snowflake currentMemberId, SetVoiceStateDelegate setVoiceStateDelegate) + public IVoiceConnectionHost Create(Snowflake guildId, Snowflake channelId, Snowflake currentMemberId, SetVoiceStateDelegate setVoiceStateDelegate) { var connection = Factory(_services, new object[] { guildId, channelId, currentMemberId, setVoiceStateDelegate }); - return Unsafe.As(connection); + return Unsafe.As(connection); } private static readonly ObjectFactory Factory; diff --git a/src/Disqord.Voice/Factory/IVoiceConnectionFactory.cs b/src/Disqord.Voice/Factory/IVoiceConnectionFactory.cs index ce408dd8d..221ac9b0e 100644 --- a/src/Disqord.Voice/Factory/IVoiceConnectionFactory.cs +++ b/src/Disqord.Voice/Factory/IVoiceConnectionFactory.cs @@ -2,5 +2,5 @@ public interface IVoiceConnectionFactory { - IVoiceConnection Create(Snowflake guildId, Snowflake channelId, Snowflake currentMemberId, SetVoiceStateDelegate setVoiceStateDelegate); + IVoiceConnectionHost Create(Snowflake guildId, Snowflake channelId, Snowflake currentMemberId, SetVoiceStateDelegate setVoiceStateDelegate); } diff --git a/src/Disqord.Voice/IVoiceConnection.cs b/src/Disqord.Voice/IVoiceConnection.cs index 6cf1d895e..88757508b 100644 --- a/src/Disqord.Voice/IVoiceConnection.cs +++ b/src/Disqord.Voice/IVoiceConnection.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Disqord.Voice.Api; @@ -7,31 +8,132 @@ namespace Disqord.Voice; public delegate ValueTask SetVoiceStateDelegate(Snowflake guildId, Snowflake? channelId, CancellationToken cancellationToken); -public interface IVoiceConnection : IAsyncDisposable -{ - IVoiceGatewayClient Gateway { get; } +/// +/// Represents the delegate for handling received voice packets. +/// The delegate takes ownership of the packet and is responsible for disposing it. +/// +/// The received voice packet. +public delegate ValueTask VoicePacketSinkDelegate(VoiceReceivePacket packet); - IVoiceUdpClient Udp { get; } - - IVoiceSynchronizer Synchronizer { get; } +/// +/// Represents the delegate invoked when a user connects to or disconnects from a voice session. +/// +/// The ID of the user. +public delegate void VoiceUserPresenceDelegate(Snowflake userId); +/// +/// Represents a voice connection to a Discord voice channel. +/// +public interface IVoiceConnection : IAsyncDisposable +{ + /// + /// Gets the ID of the guild this connection belongs to. + /// Snowflake GuildId { get; } + /// + /// Gets the ID of the voice channel this connection is attached to. + /// Snowflake ChannelId { get; } + /// + /// Gets the ID of the current member (bot user) on this connection. + /// Snowflake CurrentMemberId { get; } - void OnVoiceStateUpdate(Snowflake? channelId, string sessionId); + /// + /// Gets a snapshot of the currently connected user IDs in this voice session. + /// + IReadOnlySet ConnectedUserIds { get; } - void OnVoiceServerUpdate(string token, string? endpoint); + /// + /// Occurs when a user connects to this voice session. + /// Fired after has been updated. + /// + event VoiceUserPresenceDelegate? UserConnected; + /// + /// Occurs when a user disconnects from this voice session. + /// Fired after has been updated. + /// + event VoiceUserPresenceDelegate? UserDisconnected; + + /// + /// Moves this connection to a different voice channel within the same guild. + /// + /// The ID of the target voice channel. + /// The cancellation token to observe. ValueTask SetChannelIdAsync(Snowflake channelId, CancellationToken cancellationToken = default); + /// + /// Sets the speaking flags for this connection. + /// + /// The speaking flags to set. + /// The cancellation token to observe. ValueTask SetSpeakingFlagsAsync(SpeakingFlags flags, CancellationToken cancellationToken = default); + /// + /// Sends an Opus-encoded audio packet over the voice connection. + /// + /// The Opus-encoded audio data to send. + /// The cancellation token to observe. ValueTask SendPacketAsync(ReadOnlyMemory opus, CancellationToken cancellationToken = default); - Task RunAsync(CancellationToken stoppingToken); + /// + /// Sets the delegate that receives voice packets, enabling or disabling voice packet receiving. + /// The sink takes ownership of each packet and is responsible for calling . + /// Pass to disable receiving. + /// + /// The delegate to receive voice packets, or to disable receiving. + /// The cancellation token to observe. + ValueTask SetPacketSinkAsync(VoicePacketSinkDelegate? sink, CancellationToken cancellationToken = default); +} +/// +/// Extends with members for managing +/// the connection lifecycle and accessing low-level components. +/// +public interface IVoiceConnectionHost : IVoiceConnection +{ + /// + /// Gets the voice gateway client for this connection. + /// + IVoiceGatewayClient Gateway { get; } + + /// + /// Gets the voice UDP client for this connection. + /// + IVoiceUdpClient Udp { get; } + + /// + /// Gets the voice synchronizer responsible for timing audio packets. + /// + IVoiceSynchronizer Synchronizer { get; } + + /// + /// Waits until this voice connection is ready to send and receive audio. + /// Resets during reconnection, allowing callers to await readiness again after a connection drop. + /// + /// The cancellation token to observe. Task WaitUntilReadyAsync(CancellationToken cancellationToken = default); + + /// + /// Notifies this connection of a voice state update from the gateway. + /// + /// The channel ID from the voice state, or if disconnected. + /// The session ID from the voice state. + void OnVoiceStateUpdate(Snowflake? channelId, string sessionId); + + /// + /// Notifies this connection of a voice server update from the gateway. + /// + /// The voice server token. + /// The voice server endpoint, or if the server is being reallocated. + void OnVoiceServerUpdate(string token, string? endpoint); + + /// + /// Runs the voice connection lifecycle, including connecting, reconnecting, and handling gateway events. + /// + /// The cancellation token that signals the connection should stop. + Task RunAsync(CancellationToken stoppingToken); } diff --git a/src/Disqord.Voice/IVoiceUdpClient.cs b/src/Disqord.Voice/IVoiceUdpClient.cs index 39f7c23ba..25af99535 100644 --- a/src/Disqord.Voice/IVoiceUdpClient.cs +++ b/src/Disqord.Voice/IVoiceUdpClient.cs @@ -36,4 +36,6 @@ public interface IVoiceUdpClient : IDisposable ValueTask CloseAsync(CancellationToken cancellationToken = default); ValueTask SendAsync(ReadOnlyMemory opus, CancellationToken cancellationToken = default); + + ValueTask ReceiveAsync(CancellationToken cancellationToken = default); } diff --git a/src/Disqord.Voice/VoiceConstants.cs b/src/Disqord.Voice/VoiceConstants.cs index 6bff3faa1..3b521859d 100644 --- a/src/Disqord.Voice/VoiceConstants.cs +++ b/src/Disqord.Voice/VoiceConstants.cs @@ -4,7 +4,9 @@ namespace Disqord.Voice; public static class VoiceConstants { - public static ReadOnlyMemory SilencePacket => new byte[] { 0xF8, 0xFF, 0xFE }; + public static ReadOnlyMemory SilencePacket => _silencePacket; + + private static readonly ReadOnlyMemory _silencePacket = new byte[] { 0xF8, 0xFF, 0xFE }; public const int DurationMilliseconds = 20; diff --git a/src/Disqord.Voice/VoiceReceivePacket.cs b/src/Disqord.Voice/VoiceReceivePacket.cs new file mode 100644 index 000000000..7bd069e24 --- /dev/null +++ b/src/Disqord.Voice/VoiceReceivePacket.cs @@ -0,0 +1,192 @@ +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using Qommon.Pooling; + +namespace Disqord.Voice; + +/// +/// Represents a received voice packet containing decrypted Opus audio data. +/// +public readonly struct VoiceReceivePacket : IDisposable +{ + /// + /// Gets the RTP sequence number of this packet. + /// + public ushort Sequence => State.GetSequence(_generation); + + /// + /// Gets the RTP timestamp of this packet. + /// + public uint Timestamp => State.GetTimestamp(_generation); + + /// + /// Gets the SSRC (synchronization source) identifier of the audio stream. + /// + public uint Ssrc => State.GetSsrc(_generation); + + /// + /// Gets or sets the ID of the user who sent this packet, + /// or if the SSRC has not yet been mapped to a user. + /// + public Snowflake? UserId + { + get => State.GetUserId(_generation); + set => State.SetUserId(_generation, value); + } + + /// + /// Gets the decrypted Opus audio payload. + /// + public ReadOnlyMemory Opus => State.GetOpus(_generation); + + private PacketState State + { + get + { + var state = _state; + if (state == null) + { + ThrowDisposed(); + } + + return state; + } + } + + private readonly PacketState? _state; + private readonly int _generation; + + internal VoiceReceivePacket(PacketState state, int generation) + { + _state = state; + _generation = generation; + } + + [DoesNotReturn] + private static void ThrowDisposed() + { + throw new ObjectDisposedException(nameof(VoiceReceivePacket)); + } + + /// + /// Returns this packet's pooled resources to their pools. + /// Safe to call multiple times across struct copies. + /// + public void Dispose() + { + _state?.Dispose(_generation); + } + + internal sealed class PacketState + { + private readonly ObjectPool _pool; + + private int _nextGeneration; + private int _version; + + private ushort _sequence; + private uint _timestamp; + private uint _ssrc; + private Snowflake? _userId; + private ReadOnlyMemory _opus; + private byte[]? _rentedArray; + + internal PacketState(ObjectPool pool) + { + _pool = pool; + } + + internal VoiceReceivePacket Initialize(ushort sequence, uint timestamp, uint ssrc, Snowflake? userId, ReadOnlyMemory opus, byte[]? rentedArray) + { + var generation = unchecked(_nextGeneration + 1); + if (generation == 0 || generation == int.MinValue) + { + generation = 1; + } + + _nextGeneration = generation; + _sequence = sequence; + _timestamp = timestamp; + _ssrc = ssrc; + _userId = userId; + _opus = opus; + _rentedArray = rentedArray; + Volatile.Write(ref _version, generation); + + return new VoiceReceivePacket(this, generation); + } + + internal void ResetForPooling() + { + _sequence = 0; + _timestamp = 0; + _ssrc = 0; + _userId = null; + _opus = default; + _rentedArray = null; + Volatile.Write(ref _version, 0); + } + + internal ushort GetSequence(int expectedGeneration) + { + Validate(expectedGeneration); + return _sequence; + } + + internal uint GetTimestamp(int expectedGeneration) + { + Validate(expectedGeneration); + return _timestamp; + } + + internal uint GetSsrc(int expectedGeneration) + { + Validate(expectedGeneration); + return _ssrc; + } + + internal Snowflake? GetUserId(int expectedGeneration) + { + Validate(expectedGeneration); + return _userId; + } + + internal void SetUserId(int expectedGeneration, Snowflake? userId) + { + Validate(expectedGeneration); + _userId = userId; + } + + internal ReadOnlyMemory GetOpus(int expectedGeneration) + { + Validate(expectedGeneration); + return _opus; + } + + private void Validate(int expectedGeneration) + { + if (Volatile.Read(ref _version) != expectedGeneration) + { + ThrowDisposed(); + } + } + + internal void Dispose(int expectedGeneration) + { + if (Interlocked.CompareExchange(ref _version, -expectedGeneration, expectedGeneration) != expectedGeneration) + { + return; + } + + var rentedArray = Interlocked.Exchange(ref _rentedArray, null); + if (rentedArray != null) + { + ArrayPool.Shared.Return(rentedArray); + } + + _pool.Return(this); + } + } +}