diff --git a/Aerochat/Aerochat.csproj b/Aerochat/Aerochat.csproj
index f1eaa67b..202b4f15 100644
--- a/Aerochat/Aerochat.csproj
+++ b/Aerochat/Aerochat.csproj
@@ -570,6 +570,7 @@
+
diff --git a/Aerochat/Aerochat.sln b/Aerochat/Aerochat.sln
new file mode 100644
index 00000000..a9c4a5ea
--- /dev/null
+++ b/Aerochat/Aerochat.sln
@@ -0,0 +1,30 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.5.2.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aerochat - Backup", "Aerochat - Backup.csproj", "{ED5B95D5-B0D5-62F8-71E6-4FC049C771E8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aerochat", "Aerochat.csproj", "{2F7F88E9-35D7-18CE-8024-F96DADC79732}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {ED5B95D5-B0D5-62F8-71E6-4FC049C771E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {ED5B95D5-B0D5-62F8-71E6-4FC049C771E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {ED5B95D5-B0D5-62F8-71E6-4FC049C771E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {ED5B95D5-B0D5-62F8-71E6-4FC049C771E8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2F7F88E9-35D7-18CE-8024-F96DADC79732}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2F7F88E9-35D7-18CE-8024-F96DADC79732}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2F7F88E9-35D7-18CE-8024-F96DADC79732}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2F7F88E9-35D7-18CE-8024-F96DADC79732}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {C47F4002-1679-4B5E-83C6-32EFCEA09CDF}
+ EndGlobalSection
+EndGlobal
diff --git a/Aerochat/Settings/SettingsManager.cs b/Aerochat/Settings/SettingsManager.cs
index 36924ad7..f1833345 100644
--- a/Aerochat/Settings/SettingsManager.cs
+++ b/Aerochat/Settings/SettingsManager.cs
@@ -1,6 +1,7 @@
using Aerochat.Enums;
using Aerochat.ViewModels;
using System;
+using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
@@ -79,22 +80,28 @@ private static string SettingsFilePath
public static void Save()
{
- // Serialize non-static properties
- var properties = Instance.GetType()
- .GetProperties(BindingFlags.Public | BindingFlags.Instance)
- .Where(prop => prop.CanWrite && !prop.GetMethod.IsStatic)
- .ToDictionary(prop => prop.Name, prop => prop.GetValue(Instance));
+ try
+ {
+ // Serialize non-static properties
+ var properties = Instance.GetType()
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(prop => prop.CanWrite && !prop.GetMethod.IsStatic)
+ .ToDictionary(prop => prop.Name, prop => prop.GetValue(Instance));
- // Ensure that the directory exists:
- Directory.CreateDirectory(Path.GetDirectoryName(SettingsFilePath)!);
+ // Ensure that the directory exists:
+ Directory.CreateDirectory(Path.GetDirectoryName(SettingsFilePath)!);
- var json = JsonSerializer.Serialize(properties, new JsonSerializerOptions { WriteIndented = true });
- File.WriteAllText(SettingsFilePath, json);
+ var json = JsonSerializer.Serialize(properties, new JsonSerializerOptions { WriteIndented = true });
+ File.WriteAllText(SettingsFilePath, json);
- // Call OnPropertyChanged for all properties
- foreach (var property in properties.Keys)
+ // Call OnPropertyChanged for all properties
+ foreach (var property in properties.Keys)
+ {
+ Instance.InvokePropertyChanged(property);
+ }
+ } catch (Exception e)
{
- Instance.InvokePropertyChanged(property);
+ Debug.WriteLine(e);
}
}
diff --git a/Aerochat/ViewModels/User.cs b/Aerochat/ViewModels/User.cs
index 2173e8cd..fb2a7aca 100644
--- a/Aerochat/ViewModels/User.cs
+++ b/Aerochat/ViewModels/User.cs
@@ -20,6 +20,7 @@ public class UserViewModel : ViewModelBase
private SceneViewModel? _scene;
private string? _color = "#525252";
private string? _image;
+ private bool _isSpeaking = false;
public required string Name
{
@@ -66,6 +67,12 @@ public string? Image
set => SetProperty(ref _image, value);
}
+ public bool IsSpeaking
+ {
+ get => _isSpeaking;
+ set => SetProperty(ref _isSpeaking, value);
+ }
+
public static UserViewModel FromUser(DiscordUser user)
{
return new UserViewModel
diff --git a/Aerochat/Voice/VoiceManager.cs b/Aerochat/Voice/VoiceManager.cs
index 126e3af2..2f3b1f74 100644
--- a/Aerochat/Voice/VoiceManager.cs
+++ b/Aerochat/Voice/VoiceManager.cs
@@ -16,6 +16,11 @@ public class VoiceManager : ViewModelBase
{
public static VoiceManager Instance = new();
private VoiceSocket? voiceSocket;
+ public VoiceSocket? VoiceSocket
+ {
+ get => voiceSocket;
+ }
+
public DiscordChannel? Channel => voiceSocket?.Channel;
private ChannelViewModel? _channelVM;
@@ -34,10 +39,10 @@ public async Task LeaveVoiceChannel()
ChannelVM = null;
}
- public async Task JoinVoiceChannel(DiscordChannel channel)
+ public async Task JoinVoiceChannel(DiscordChannel channel, Action onStateChange)
{
await LeaveVoiceChannel();
- voiceSocket = new(Discord.Client);
+ voiceSocket = new(Discord.Client, onStateChange);
await voiceSocket.ConnectAsync(channel);
ChannelVM = ChannelViewModel.FromChannel(channel);
}
diff --git a/Aerochat/Windows/Chat.xaml b/Aerochat/Windows/Chat.xaml
index 507bd006..dd752e35 100644
--- a/Aerochat/Windows/Chat.xaml
+++ b/Aerochat/Windows/Chat.xaml
@@ -474,12 +474,24 @@
-
+
+
+
+
-
+
diff --git a/Aerochat/Windows/Chat.xaml.cs b/Aerochat/Windows/Chat.xaml.cs
index dab8e422..3141559a 100644
--- a/Aerochat/Windows/Chat.xaml.cs
+++ b/Aerochat/Windows/Chat.xaml.cs
@@ -152,7 +152,8 @@ public async Task OnChannelChange(bool initial = false)
// We cannot edit messages or upload images across channels, so close the respective UIs.
// In the future, this design should possibly be reconsidered: the official Discord client
// persists such state between channels, and it only applies to the currently-active channel.
- await Dispatcher.BeginInvoke(() => {
+ await Dispatcher.BeginInvoke(() =>
+ {
ClearReplyTarget();
LeaveEditingMessage();
CloseAttachmentsEditor();
@@ -663,7 +664,8 @@ private void ProcessLastRead()
if (!SettingsManager.Instance.LastReadMessages.TryGetValue(ChannelId, out var msgId))
{
SettingsManager.Instance.LastReadMessages[ChannelId] = message.Id ?? 0;
- } else
+ }
+ else
{
long prevTimestamp = ((long)(msgId >> 22)) + 1420070400000;
DateTime prevLastMessageTime = DateTimeOffset.FromUnixTimeMilliseconds(prevTimestamp).DateTime;
@@ -775,7 +777,8 @@ public Chat(ulong id, bool allowDefault = false, PresenceViewModel? initialPrese
if (firstChannel is not null)
{
ChannelId = firstChannel.Id;
- } else
+ }
+ else
{
UnavailableDialog();
return;
@@ -854,7 +857,8 @@ private void Chat_KeyDown(object sender, KeyEventArgs e)
PresentationSource.FromVisual(MessageTextBox),
0,
e.Key
- ) { RoutedEvent = Keyboard.KeyDownEvent });
+ )
+ { RoutedEvent = Keyboard.KeyDownEvent });
MessageTextBox.Focus();
}
}
@@ -1109,7 +1113,8 @@ private void RunGCRelease()
private async void TypingUsers_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
List tempUsers = new();
- foreach (var user in TypingUsers.ToList()) {
+ foreach (var user in TypingUsers.ToList())
+ {
if (!Discord.Client.TryGetCachedUser(user.Id, out DiscordUser discordUser))
{
// I believe this is fully safe since it'll only occur in a server context,
@@ -1419,7 +1424,8 @@ private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
if (hiddenItems)
{
expandBtn.Visibility = Visibility.Visible;
- } else
+ }
+ else
{
expandBtn.Visibility = Visibility.Collapsed;
}
@@ -1547,7 +1553,26 @@ private async void ItemClick(object sender, MouseButtonEventArgs e)
}
try
{
- await VoiceManager.Instance.JoinVoiceChannel(channel);
+ await VoiceManager.Instance.JoinVoiceChannel(channel, (e) =>
+ {
+ var castedItem = (HomeListItemViewModel)item;
+ var channelID = castedItem.Id;
+ Dispatcher.InvokeAsync(() =>
+ {
+ // FIXME: this code sucks
+ var category = ViewModel.Categories.ToList().Find(f => f.Items.Any(f => f.Id == channelID));
+ if (category == null) return;
+ var channel = category.Items.ToList().Find(f => f.Id == channelID);
+ if (channel == null) return;
+ ulong userID = 0;
+ VoiceManager.Instance.VoiceSocket?.UserSSRCMap.TryGetValue(e.SSRC, out userID);
+ var user = castedItem.ConnectedUsers.FirstOrDefault(u => u.Id == userID, null!);
+ if (user == null) return;
+ var index = castedItem.ConnectedUsers.IndexOf(user);
+ if (index == -1) return;
+ channel.ConnectedUsers[index].IsSpeaking = e.Speaking;
+ });
+ });
}
catch (Exception ex)
{
@@ -1617,38 +1642,38 @@ private async void MessageParser_HyperlinkClicked(object sender, Controls.Hyperl
switch (e.Type)
{
case Controls.HyperlinkType.WebLink:
- {
- string? uri = (string)e.AssociatedObject;
+ {
+ string? uri = (string)e.AssociatedObject;
- if (uri is null)
- return;
+ if (uri is null)
+ return;
- OpenExternalUrl(uri);
- break;
- }
+ OpenExternalUrl(uri);
+ break;
+ }
case Controls.HyperlinkType.Channel:
- {
- var channel = (DiscordChannel)e.AssociatedObject;
- ChannelId = channel.Id;
- foreach (var category in ViewModel.Categories)
{
- foreach (var item in category.Items)
+ var channel = (DiscordChannel)e.AssociatedObject;
+ ChannelId = channel.Id;
+ foreach (var category in ViewModel.Categories)
{
- if (item.Id == channel.Id)
+ foreach (var item in category.Items)
{
- item.IsSelected = true;
- }
- else
- {
- item.IsSelected = false;
+ if (item.Id == channel.Id)
+ {
+ item.IsSelected = true;
+ }
+ else
+ {
+ item.IsSelected = false;
+ }
}
}
+ await OnChannelChange().ConfigureAwait(false);
+ // find the item in the list
+ break;
}
- await OnChannelChange().ConfigureAwait(false);
- // find the item in the list
- break;
- }
}
}
@@ -1788,7 +1813,7 @@ private async void CanvasButton_Click(object sender, RoutedEventArgs e)
}
}
-
+
private int _drawingHeight = 120;
private int _writingHeight = 64;
@@ -1900,7 +1925,7 @@ private void ShowColorMenu(object sender, MouseButtonEventArgs e)
private string _lastValue = "";
Timer typingTimer = new(1000)
- {
+ {
AutoReset = false
};
@@ -1930,7 +1955,8 @@ private void MessageTextBox_TextChanged(object sender, TextChangedEventArgs e)
_isTyping = true;
TypingTimer_Elapsed(null, null!);
typingTimer.Start();
- };
+ }
+ ;
}
private void OnMessageContextMenuOpening(object senderRaw, ContextMenuEventArgs e)
diff --git a/Aerochat/Windows/Home.xaml.cs b/Aerochat/Windows/Home.xaml.cs
index 5fdd1d92..5ae229ee 100644
--- a/Aerochat/Windows/Home.xaml.cs
+++ b/Aerochat/Windows/Home.xaml.cs
@@ -90,6 +90,10 @@ public Home()
// Subscribe to changes in the DisplayAds property
SettingsManager.Instance.PropertyChanged += OnSettingsChange;
+
+ await Task.Delay(1000);
+ Chat chat = new(1287027740452716606);
+ chat.Show();
});
}
diff --git a/Aerovoice/Aerovoice.csproj b/Aerovoice/Aerovoice.csproj
index 3a30deae..099466b9 100644
--- a/Aerovoice/Aerovoice.csproj
+++ b/Aerovoice/Aerovoice.csproj
@@ -1,24 +1,41 @@
-
- net8.0
- enable
- enable
- AnyCPU;x64
-
+
+ net8.0
+ enable
+ enable
+ AnyCPU;x64
+ true
+ true
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Aerovoice/Clients/VoiceSocket.cs b/Aerovoice/Clients/VoiceSocket.cs
index 65ec2f01..8e57796e 100644
--- a/Aerovoice/Clients/VoiceSocket.cs
+++ b/Aerovoice/Clients/VoiceSocket.cs
@@ -24,42 +24,152 @@
using Aerovoice.Encoders;
using Aerovoice.Timestamp;
using static System.Runtime.InteropServices.JavaScript.JSType;
+using System.Runtime.InteropServices;
+using static Vanara.PInvoke.Kernel32;
namespace Aerovoice.Clients
{
+ public class VoiceStateChanged
+ {
+ public uint SSRC;
+ public bool Speaking;
+
+ public VoiceStateChanged(uint SSRC, bool Speaking)
+ {
+ this.SSRC = SSRC;
+ this.Speaking = Speaking;
+ }
+ }
+
+ struct IPInfo
+ {
+ public string Address;
+ public ushort Port;
+ }
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ public delegate void VoiceUserCallback(uint ssrc, bool speaking);
+
+ partial class VoiceSession : IDisposable
+ {
+ unsafe struct RawIPInfo
+ {
+ public fixed byte IP[64];
+ public ushort Port;
+ }
+
+ [LibraryImport("AerovoiceNative.dll")]
+ [UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })]
+ private static partial IntPtr voice_session_new(uint ssrc, ulong channelId, [MarshalAs(UnmanagedType.LPUTF8Str)] string ip, ushort port, VoiceUserCallback onSpeaking);
+ [LibraryImport("AerovoiceNative.dll")]
+ [UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })]
+ private static partial void voice_session_free(IntPtr sessionHandle);
+ [LibraryImport("AerovoiceNative.dll")]
+ [UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })]
+ private static partial void voice_session_init_poll_thread(IntPtr sessionHandle);
+ [LibraryImport("AerovoiceNative.dll")]
+ [UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })]
+ private static partial IntPtr voice_session_discover_ip(IntPtr sessionHandle);
+ [LibraryImport("AerovoiceNative.dll")]
+ [UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })]
+ private unsafe static partial IntPtr voice_session_set_secret(IntPtr sessionHandle, byte* secret, uint secretLen);
+ [LibraryImport("AerovoiceNative.dll")]
+ [UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })]
+ [return: MarshalAs(UnmanagedType.LPUTF8Str)]
+ private unsafe static partial string voice_session_select_cryptor(IntPtr sessionHandle, [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPUTF8Str, SizeParamIndex = 2)] string[] availableMethods, uint availableMethodsLen);
+
+ private IntPtr _sessionHandle;
+
+ public VoiceSession(uint ssrc, ulong channelId, string ip, ushort port, VoiceUserCallback onSpeaking)
+ {
+ AllocConsole();
+ _sessionHandle = voice_session_new(ssrc, channelId, ip, port, onSpeaking);
+ if (_sessionHandle == IntPtr.Zero)
+ {
+ throw new Exception("Failed to create voice session.");
+ }
+ }
+
+ public void BeginPolling()
+ {
+ voice_session_init_poll_thread(_sessionHandle);
+ }
+
+ public IPInfo DiscoverIP()
+ {
+ unsafe
+ {
+ var result = *(RawIPInfo*)voice_session_discover_ip(_sessionHandle).ToPointer();
+ byte[] managedIP = new byte[64];
+ for (int i = 0; i < 64; i++)
+ {
+ managedIP[i] = result.IP[i];
+ }
+
+ string ip = Encoding.UTF8.GetString(managedIP).Trim((char)0);
+ ushort port = result.Port;
+ return new IPInfo { Address = ip, Port = port };
+ }
+ }
+
+ public void SetSecret(byte[] secret)
+ {
+ var len = (uint)secret.Length;
+ unsafe
+ {
+ fixed (byte* secretPtr = &secret[0])
+ {
+ voice_session_set_secret(_sessionHandle, secretPtr, len);
+ }
+ }
+ }
+
+ public string SelectCryptor(string[] availableCryptors)
+ {
+ return voice_session_select_cryptor(_sessionHandle, availableCryptors, (uint)availableCryptors.Length);
+ }
+
+ public void Dispose()
+ {
+ FreeConsole();
+ if (_sessionHandle != IntPtr.Zero)
+ {
+ voice_session_free(_sessionHandle);
+ _sessionHandle = IntPtr.Zero;
+ }
+ }
+ }
+
+
public class VoiceSocket
{
private WebsocketClient _socket;
- private UDPClient UdpClient;
private DiscordClient _client;
private JObject _ready;
private bool _disposed = false;
+ private VoiceSession? _session;
-
- public IPlayer Player = new NAudioPlayer();
- public IDecoder Decoder = new OpusDotNetDecoder();
- public IEncoder Encoder = new ConcentusEncoder();
- public BaseRecorder Recorder = new NAudioRecorder();
- public string? ForceEncryptionName;
-
-
- private BaseCrypt cryptor;
- private byte[] _secretKey;
+ //private BaseCrypt cryptor;
+ private string _serverSdp;
private int _sequence;
private string _sessionId;
private string _voiceToken;
private Uri _endpoint;
public DiscordChannel Channel { get; private set; }
private bool _connected = false;
- private List _availableEncryptionModes;
private RTPTimestamp _timestamp = new(3840);
private uint _ssrc = 0;
+ private VoiceUserCallback _cb;
+ private Dictionary _userSsrcMap = [];
+ public Dictionary UserSSRCMap { get { return _userSsrcMap; } }
public bool Speaking { get; private set; } = false;
System.Timers.Timer _timer;
- public VoiceSocket(DiscordClient client)
+ Action _onStateChange;
+
+ public VoiceSocket(DiscordClient client, Action onStateChange)
{
_client = client;
// every 1/60 seconds, on another thread, incrementTimestamp using 3840
@@ -68,6 +178,34 @@ public VoiceSocket(DiscordClient client)
_timer.AutoReset = true;
_timer.Elapsed += (s, e) => _timestamp.Increment(3840);
_timer.Start();
+ _onStateChange = onStateChange;
+ _cb = new VoiceUserCallback(InternalVoiceCallback);
+ }
+
+ private void InternalVoiceCallback(uint ssrc, bool speaking)
+ {
+ _onStateChange(new VoiceStateChanged(ssrc, speaking));
+ if (ssrc != _ssrc || _socket == null)
+ {
+ return;
+ }
+
+ Debug.WriteLine(speaking);
+
+ Task.Run(async () =>
+ {
+ var msg = new
+ {
+ op = 5,
+ d = new
+ {
+ speaking = speaking ? 1 : 0,
+ delay = 0,
+ ssrc
+ }
+ };
+ await SendMessage(JObject.FromObject(msg));
+ });
}
public async Task SendMessage(JObject message)
@@ -84,7 +222,7 @@ public byte[] ConstructPortScanPacket(uint ssrc, string ip, ushort port)
byte[] packetLength = new byte[2];
BitConverter.GetBytes((ushort)70).CopyTo(packetLength, 0);
- Array.Reverse(packetLength);
+ Array.Reverse(packetLength);
byte[] ssrcBuf = new byte[4];
BitConverter.GetBytes(ssrc).CopyTo(ssrcBuf, 0);
@@ -108,8 +246,12 @@ public byte[] ConstructPortScanPacket(uint ssrc, string ip, ushort port)
return packet;
}
+ private void OnSpeaking(uint ssrc, bool speaking)
+ { }
+
public async Task OnMessageReceived(JObject message)
{
+ Debug.WriteLine(message);
// if message["seq"] exists, set _sequence to it
if (message["seq"] != null)
{
@@ -119,311 +261,92 @@ public async Task OnMessageReceived(JObject message)
switch (op)
{
case 2: // ready
- {
- _ready = message["d"]!.Value()!;
- var ip = _ready["ip"]!.Value()!;
- var port = _ready["port"]!.Value();
- _ssrc = _ready["ssrc"]!.Value();
- var modes = _ready["modes"]!.ToArray().Select(x => x.Value()!);
- _availableEncryptionModes = modes.ToList();
- Logger.Log($"Attempting to open UDP connection to {ip}:{port}.");
- Logger.Log($"Your SSRC is {_ssrc}.");
- UdpClient = new(ip, port);
- UdpClient.MessageReceived += (s, e) => Task.Run(() => UdpClient_MessageReceived(s, e));
-
- var discoveryPacket = ConstructPortScanPacket(_ssrc, ip, port);
-
- UdpClient.SendMessage(discoveryPacket);
- break;
- }
- case 4: // session description
- {
- // secret_key is a number array
- var secretKey = message["d"]!["secret_key"]!.Value()!.Select(x => (byte)x.Value()).ToArray();
- _secretKey = secretKey;
- if (cryptor is null)
{
- cryptor = GetPreferredEncryption();
- }
- break;
- }
- }
- }
-
- private readonly SortedList _packetBuffer = new();
- private readonly object _bufferLock = new();
- private uint _lastPlayedTimestamp = 0;
- private const int BUFFER_THRESHOLD = 5;
-
- private async Task UdpClient_MessageReceived(object? sender, byte[] e)
- {
- byte packetType = e[1];
- switch (packetType)
- {
- case 0x2: // ip discovery
- {
- var address = Encoding.UTF8.GetString(e, 8, 64).TrimEnd('\0');
- var port = (ushort)IPAddress.NetworkToHostOrder(BitConverter.ToInt16(e, 72));
- Logger.Log($"IP discovery was successful, your port is {port}.");
- if (cryptor is null)
- {
- cryptor = GetPreferredEncryption();
- }
- await SendMessage(JObject.FromObject(new
- {
- op = 1,
- d = new
+ _ready = message["d"]!.Value()!;
+ var ip = _ready["ip"]!.Value()!;
+ var port = _ready["port"]!.Value();
+ _ssrc = _ready["ssrc"]!.Value();
+ _userSsrcMap.Add(_ssrc, _client.CurrentUser.Id);
+ var modes = _ready["modes"]!.ToArray().Select(x => x.Value()!);
+ Logger.Log($"Attempting to open UDP connection to {ip}:{port}.");
+ Logger.Log($"Your SSRC is {_ssrc}.");
+ _session = new VoiceSession(_ssrc, this.Channel.Id, ip, port, _cb);
+ var discovered = _session.DiscoverIP();
+ Debug.WriteLine($"IP: {discovered.Address}");
+ Debug.WriteLine($"Port: {discovered.Port}");
+ var cryptor = _session.SelectCryptor([.. modes]);
+ if (cryptor == null) return;
+ var msg = new
{
- protocol = "udp",
- data = new
+ op = 1,
+ d = new
{
- address,
- port,
- mode = cryptor.PName
- },
- codecs = new[]
- {
- new
+ protocol = "udp",
+ data = new
+ {
+ address = discovered.Address,
+ port = discovered.Port,
+ mode = cryptor
+ },
+ codecs = new[]
{
- name = "opus",
- type = "audio",
- priority = 1000,
- payload_type = 120
+ new
+ {
+ name = "opus",
+ type = "audio",
+ priority = 1000,
+ payload_type = 109,
+ }
}
}
- }
- }));
- Recorder.Start();
- break;
- }
- case 0x78:
- {
- // TODO: thread manager where each user gets one thread
- await Task.Run(() =>
- {
- if (cryptor is null || _secretKey is null) return;
+ };
- var rtpTimestamp = BinaryPrimitives.ReadUInt32BigEndian(e.AsSpan(4));
- lock (_bufferLock)
- {
- _packetBuffer[rtpTimestamp] = e;
- }
-
- TryProcessBufferedPackets();
- });
- break;
- }
- }
- }
-
- private void TryProcessBufferedPackets()
- {
- lock (_bufferLock)
- {
- if (_packetBuffer.Count < BUFFER_THRESHOLD)
- return;
-
- foreach (var key in _packetBuffer.Keys.ToList())
- {
- if (key > _lastPlayedTimestamp)
- {
- byte[] packet = _packetBuffer[key];
- _packetBuffer.Remove(key);
- _lastPlayedTimestamp = key;
-
- ProcessPacket(packet);
- }
- }
- }
- }
-
- private void ProcessPacket(byte[] e)
- {
- var ssrc = BinaryPrimitives.ReadUInt32BigEndian(e.AsSpan(8));
-
- byte[] decryptedData = cryptor.Decrypt(e, _secretKey);
- if (decryptedData.Length == 0) return;
- // read ushort increment, 2 bytes in
- ushort increment = BinaryPrimitives.ReadUInt16BigEndian(e.AsSpan(2));
- var decoded = Decoder.Decode(decryptedData, decryptedData.Length, out int decodedLength, ssrc, increment);
- Player.AddSamples(decoded, decodedLength, ssrc);
- }
-
- public BaseCrypt GetPreferredEncryption()
- {
- var decryptors = typeof(BaseCrypt).Assembly.GetTypes().Where(x => x.Namespace == "Aerovoice.Crypts" && x.IsSubclassOf(typeof(BaseCrypt)) && _availableEncryptionModes.Contains((string)x.GetProperty("Name")!.GetValue(null)!));
- var priority = new[] { "aead_aes256_gcm_rtpsize", "aead_xchacha20_poly1305_rtpsize" };
- BaseCrypt? decryptor = null;
- if (ForceEncryptionName != null)
- {
- var forced = decryptors.FirstOrDefault(x => x.GetProperty("Name")!.GetValue(null)!.Equals(ForceEncryptionName));
- if (forced != null)
- {
- decryptor = (BaseCrypt)Activator.CreateInstance(forced)!;
- } else
- {
- Logger.Log($"\"{ForceEncryptionName}\" is not supported, falling back to default.");
- }
- }
- if (decryptor == null)
- {
- foreach (var p in priority)
- {
- var d = decryptors.FirstOrDefault(x => x.GetProperty("Name")!.GetValue(null)!.Equals(p));
- if (d != null && _availableEncryptionModes.Contains(p))
- {
- decryptor = (BaseCrypt)Activator.CreateInstance(d)!;
+ await SendMessage(JObject.FromObject(msg));
break;
}
- }
- }
-
- decryptor = decryptor ?? (BaseCrypt)Activator.CreateInstance(decryptors.FirstOrDefault(x => _availableEncryptionModes.Contains(x.GetProperty("Name")!.GetValue(null))!)!)!;
- // log all encryption modes but make the preferred one bold
- Logger.Log($"Encryption mode selected:");
- var names = decryptors.Select(x => (string)x.GetProperty("Name")!.GetValue(null)!);
- // sort the modes such that the preferred one is first, then the supported ones, then the unsupported ones
- // unsupported modes are modes where Name isn't in names
- _availableEncryptionModes = _availableEncryptionModes.OrderBy(x => x != decryptor.PName).ThenBy(x => !names.Contains(x)).ThenBy(x => x).ToList();
- foreach (var mode in _availableEncryptionModes)
- {
- if (mode == decryptor.PName)
- {
- Console.ForegroundColor = ConsoleColor.Green;
- }
- else if (!names.Contains(mode))
- {
- Console.ForegroundColor = ConsoleColor.DarkGray;
- }
- else
- {
- Console.ForegroundColor = ConsoleColor.White;
- }
- Console.WriteLine($"- {mode}");
- Console.ResetColor();
- }
- return decryptor;
- }
-
- public async Task ConnectAsync(DiscordChannel channel)
- {
- if (_disposed) throw new InvalidOperationException("This voice socket has been disposed!");
- Channel = channel;
- await _client.UpdateVoiceStateAsync(Channel.Guild?.Id ?? Channel.Id, Channel.Id, false, false);
- _client.VoiceStateUpdated += _client_VoiceStateUpdated;
- _client.VoiceServerUpdated += _client_VoiceServerUpdated;
- Recorder.DataAvailable += Recorder_DataAvailable;
- }
-
- private short _udpSequence = (short)new Random().Next(0, short.MaxValue);
-
- private const int BufferDurationMs = 200;
- private const int ChunkDurationMs = 20;
- private const int SampleRate = 48000; // 48kHz
- private const int BytesPerSample = 2; // 16-bit PCM
- private const int Channels = 2; // Stereo
- private const int BufferSizeBytes = (SampleRate * Channels * BytesPerSample * BufferDurationMs) / 1000; // 38400 bytes
- private byte[] _circularBuffer = new byte[BufferSizeBytes];
- private int _bufferOffset = 0;
- private bool _bufferFilled = false;
-
- private async void Recorder_DataAvailable(object? sender, byte[] e)
- {
- await Task.Run(() =>
- {
- // Append incoming 20ms audio to the circular buffer
- AddToCircularBuffer(e);
-
- // Check if the user is speaking using the circular buffer
- var sampleIsSpeaking = IsSpeaking(_circularBuffer, _bufferFilled ? BufferSizeBytes : _bufferOffset);
-
- if (sampleIsSpeaking)
- {
- if (Speaking)
+ case 4: // session description
{
- _ = SendMessage(JObject.FromObject(new
+ //var serverSdp = message["d"]!["sdp"]!.Value()!;
+ //_serverSdp = serverSdp;
+ //_session.SetServerSdp(_serverSdp);
+ var secret = message["d"]!["secret_key"]!.Value()!.Select(x => (byte)x.Value()).ToArray();
+ _session?.SetSecret(secret);
+ _session?.BeginPolling();
+ var msg = new
{
op = 5,
d = new
{
- speaking = 0, // not speaking
+ speaking = 0,
delay = 0,
ssrc = _ssrc
}
- }));
+ };
+ await SendMessage(JObject.FromObject(msg));
+ break;
}
- Speaking = false;
- return;
- }
-
- if (!Speaking)
- {
- _ = SendMessage(JObject.FromObject(new
+ case 5: // speaking
{
- op = 5,
- d = new
- {
- speaking = 1 << 0, // VOICE
- delay = 0,
- ssrc = _ssrc
- }
- }));
- Speaking = true;
- }
+ var userId = ulong.Parse(message["d"]!["user_id"]!.Value()!);
+ var ssrc = message["d"]!["ssrc"]!.Value()!;
- if (cryptor is null) return;
- var opus = Encoder.Encode(e);
- var header = new byte[12];
- header[0] = 0x80; // Version + Flags
- header[1] = 0x78; // Payload Type
- BinaryPrimitives.WriteInt16BigEndian(header.AsSpan(2), _udpSequence++); // Sequence
- BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(4), _timestamp.GetCurrentTimestamp()); // Timestamp
- BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(8), _ssrc); // SSRC
- var packet = new byte[header.Length + opus.Length];
- Array.Copy(header, 0, packet, 0, header.Length);
- Array.Copy(opus, 0, packet, header.Length, opus.Length);
- var encrypted = cryptor.Encrypt(packet, _secretKey);
- UdpClient.SendMessage(encrypted);
- });
- }
-
- private void AddToCircularBuffer(byte[] data)
- {
- int dataLength = data.Length;
+ _userSsrcMap.Remove(ssrc);
+ _userSsrcMap.Add(ssrc, userId);
- if (_bufferOffset + dataLength > BufferSizeBytes)
- {
- int overflow = _bufferOffset + dataLength - BufferSizeBytes;
- Array.Copy(data, 0, _circularBuffer, _bufferOffset, dataLength - overflow);
- Array.Copy(data, dataLength - overflow, _circularBuffer, 0, overflow);
- _bufferOffset = overflow;
- _bufferFilled = true;
- }
- else
- {
- Array.Copy(data, 0, _circularBuffer, _bufferOffset, dataLength);
- _bufferOffset += dataLength;
+ break;
+ }
}
}
- private bool IsSpeaking(byte[] buffer, int length)
+ public async Task ConnectAsync(DiscordChannel channel)
{
- int samples = length / BytesPerSample; // Convert byte length to number of samples
- double sum = 0;
-
- for (int i = 0; i < length; i += 2)
- {
- short sample = (short)((buffer[i + 1] << 8) | buffer[i]); // Convert to 16-bit sample
- sum += sample * sample;
- }
-
- // Calculate RMS
- double rms = Math.Sqrt(sum / samples);
-
- // Threshold for speaking detection
- const double threshold = 300; // Adjust this value based on the microphone and environment
- return rms < threshold;
+ if (_disposed) throw new InvalidOperationException("This voice socket has been disposed!");
+ Channel = channel;
+ await _client.UpdateVoiceStateAsync(Channel.Guild?.Id ?? Channel.Id, Channel.Id, false, false);
+ _client.VoiceStateUpdated += _client_VoiceStateUpdated;
+ _client.VoiceServerUpdated += _client_VoiceServerUpdated;
}
+
private async Task _client_VoiceStateUpdated(DiscordClient sender, DSharpPlus.EventArgs.VoiceStateUpdateEventArgs args)
{
_sessionId = args.SessionId;
@@ -470,10 +393,10 @@ await SendMessage(JObject.FromObject(new
op = 0,
d = new
{
- server_id = Channel.Guild?.Id ?? Channel.Id,
- user_id = _client.CurrentUser.Id,
+ server_id = Channel.Guild?.Id.ToString() ?? "",
+ user_id = _client.CurrentUser.Id.ToString(),
session_id = _sessionId,
- token = _voiceToken
+ token = _voiceToken,
}
}));
}
@@ -486,10 +409,8 @@ public async Task DisconnectAndDispose()
_disposed = true;
await _client.UpdateVoiceStateAsync(Channel.GuildId, null, false, false);
_socket?.Dispose();
- UdpClient?.Dispose();
- Recorder?.Dispose();
_timer.Dispose();
- Encoder?.Dispose();
+ _session?.Dispose();
}
}
}
diff --git a/Aerovoice/Native/.gitignore b/Aerovoice/Native/.gitignore
new file mode 100644
index 00000000..ea8c4bf7
--- /dev/null
+++ b/Aerovoice/Native/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Aerovoice/Native/Cargo.lock b/Aerovoice/Native/Cargo.lock
new file mode 100644
index 00000000..3cd1e65e
--- /dev/null
+++ b/Aerovoice/Native/Cargo.lock
@@ -0,0 +1,1391 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aerovoice-native"
+version = "0.1.0"
+dependencies = [
+ "audiopus",
+ "bytemuck",
+ "byteorder",
+ "bytes",
+ "chacha20poly1305",
+ "cpal",
+ "fixed-resample",
+ "hex",
+ "rand",
+ "rtrb",
+ "sodiumoxide",
+ "thiserror 2.0.12",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alsa"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
+dependencies = [
+ "alsa-sys",
+ "bitflags 2.9.0",
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "alsa-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "audiopus"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3743519567e9135cf6f9f1a509851cb0c8e4cb9d66feb286668afb1923bec458"
+dependencies = [
+ "audiopus_sys",
+]
+
+[[package]]
+name = "audiopus_sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "927791de46f70facea982dbfaf19719a41ce6064443403be631a85de6a58fff9"
+dependencies = [
+ "log",
+ "pkg-config",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
+
+[[package]]
+name = "bindgen"
+version = "0.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f"
+dependencies = [
+ "bitflags 2.9.0",
+ "cexpr",
+ "clang-sys",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex",
+ "syn",
+]
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
+
+[[package]]
+name = "bumpalo"
+version = "3.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
+
+[[package]]
+name = "bytemuck"
+version = "1.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ecc273b49b3205b83d648f0690daa588925572cc5063745bfe547fe7ec8e1a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+
+[[package]]
+name = "cc"
+version = "1.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0"
+dependencies = [
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+ "zeroize",
+]
+
+[[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "coreaudio-rs"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation-sys",
+ "coreaudio-sys",
+]
+
+[[package]]
+name = "coreaudio-sys"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ce857aa0b77d77287acc1ac3e37a05a8c95a2af3647d23b15f263bdaeb7562b"
+dependencies = [
+ "bindgen",
+]
+
+[[package]]
+name = "cpal"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
+dependencies = [
+ "alsa",
+ "core-foundation-sys",
+ "coreaudio-rs",
+ "dasp_sample",
+ "jni",
+ "js-sys",
+ "libc",
+ "mach2",
+ "ndk",
+ "ndk-context",
+ "oboe",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "typenum",
+]
+
+[[package]]
+name = "dasp_sample"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
+
+[[package]]
+name = "ed25519"
+version = "1.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7"
+dependencies = [
+ "signature",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "fast-interleave"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a7e05e2b3c97d4516fa5c177133f3e4decf9c8318841e1545b260535311e3a5"
+
+[[package]]
+name = "fixed-resample"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39a267bc40fae9b208e2a67a99a8d794caf3a9fe1146d01c147f59bd96257a74"
+dependencies = [
+ "arrayvec",
+ "fast-interleave",
+ "ringbuf",
+ "rubato",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasi 0.14.2+wasi-0.2.4",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "indexmap"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+
+[[package]]
+name = "jobserver"
+version = "0.1.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
+dependencies = [
+ "getrandom 0.3.2",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.172"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
+
+[[package]]
+name = "libloading"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
+dependencies = [
+ "cfg-if",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "libsodium-sys"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b779387cd56adfbc02ea4a668e704f729be8d6a6abd2c27ca5ee537849a92fd"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "walkdir",
+]
+
+[[package]]
+name = "log"
+version = "0.4.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+
+[[package]]
+name = "mach2"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memchr"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "ndk"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
+dependencies = [
+ "bitflags 2.9.0",
+ "jni-sys",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.5.0+25.2.9519653"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
+dependencies = [
+ "jni-sys",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179"
+dependencies = [
+ "num_enum_derive",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "oboe"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
+dependencies = [
+ "jni",
+ "ndk",
+ "ndk-context",
+ "num-derive",
+ "num-traits",
+ "oboe-sys",
+]
+
+[[package]]
+name = "oboe-sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "primal-check"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
+dependencies = [
+ "num-integer",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
+
+[[package]]
+name = "rand"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
+dependencies = [
+ "rand_chacha",
+ "rand_core 0.9.3",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.3",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.16",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
+dependencies = [
+ "getrandom 0.3.2",
+]
+
+[[package]]
+name = "realfft"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "390252372b7f2aac8360fc5e72eba10136b166d6faeed97e6d0c8324eb99b2b1"
+dependencies = [
+ "rustfft",
+]
+
+[[package]]
+name = "regex"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
+
+[[package]]
+name = "ringbuf"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c"
+dependencies = [
+ "crossbeam-utils",
+ "portable-atomic",
+ "portable-atomic-util",
+]
+
+[[package]]
+name = "rtrb"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba"
+
+[[package]]
+name = "rubato"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1"
+dependencies = [
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "realfft",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustfft"
+version = "6.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f266ff9b0cfc79de11fd5af76a2bc672fe3ace10c96fa06456740fa70cb1ed49"
+dependencies = [
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "primal-check",
+ "strength_reduce",
+ "transpose",
+ "version_check",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signature"
+version = "1.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c"
+
+[[package]]
+name = "sodiumoxide"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e26be3acb6c2d9a7aac28482586a7856436af4cfe7100031d219de2d2ecb0028"
+dependencies = [
+ "ed25519",
+ "libc",
+ "libsodium-sys",
+ "serde",
+]
+
+[[package]]
+name = "strength_reduce"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
+dependencies = [
+ "thiserror-impl 2.0.12",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3"
+
+[[package]]
+name = "toml_edit"
+version = "0.22.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "winnow",
+]
+
+[[package]]
+name = "transpose"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
+dependencies = [
+ "num-integer",
+ "strength_reduce",
+]
+
+[[package]]
+name = "typenum"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "wasi"
+version = "0.14.2+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
+dependencies = [
+ "wit-bindgen-rt",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
+dependencies = [
+ "bumpalo",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "windows"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
+dependencies = [
+ "windows-core",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
+dependencies = [
+ "windows-result",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9fb597c990f03753e08d3c29efbfcf2019a003b4bf4ba19225c158e1549f0f3"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen-rt"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
+dependencies = [
+ "bitflags 2.9.0",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
diff --git a/Aerovoice/Native/Cargo.toml b/Aerovoice/Native/Cargo.toml
new file mode 100644
index 00000000..1d5998e8
--- /dev/null
+++ b/Aerovoice/Native/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "aerovoice-native"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+audiopus = "0.2.0"
+bytemuck = { version = "1.23.0", features = ["derive"] }
+byteorder = "1.5.0"
+bytes = "1.10.1"
+chacha20poly1305 = "0.10.1"
+cpal = "0.15.3"
+fixed-resample = "0.8.0"
+hex = "0.4.3"
+rand = "0.9.1"
+rtrb = "0.3.2"
+sodiumoxide = "0.2.7"
+thiserror = "2.0.12"
diff --git a/Aerovoice/Native/src/char_ext.rs b/Aerovoice/Native/src/char_ext.rs
new file mode 100644
index 00000000..19d30adb
--- /dev/null
+++ b/Aerovoice/Native/src/char_ext.rs
@@ -0,0 +1,20 @@
+use std::ffi::c_char;
+
+pub trait CCharExt {
+ fn to_string_lossy(&self) -> Option;
+}
+
+impl CCharExt for *const c_char {
+ fn to_string_lossy(&self) -> Option {
+ if self.is_null() {
+ return None;
+ }
+ let str = unsafe {
+ std::ffi::CStr::from_ptr(*self)
+ .to_string_lossy()
+ .into_owned()
+ };
+
+ Some(str)
+ }
+}
diff --git a/Aerovoice/Native/src/crypto/aead_xchacha20_poly1305_rtpsize.rs b/Aerovoice/Native/src/crypto/aead_xchacha20_poly1305_rtpsize.rs
new file mode 100644
index 00000000..a94093be
--- /dev/null
+++ b/Aerovoice/Native/src/crypto/aead_xchacha20_poly1305_rtpsize.rs
@@ -0,0 +1,56 @@
+use crate::rtp::{Decrypted, Encrypted, HeaderExtensionType, RtpPacket};
+
+use super::Cryptor;
+use chacha20poly1305::{
+ KeyInit, XChaCha20Poly1305,
+ aead::{AeadMut, Payload},
+};
+
+const NONCE_BYTES: usize = 4;
+
+#[derive(Default, Clone, Copy)]
+pub struct AeadXChaCha20Poly1305RtpSize {
+ sequence: u32,
+}
+
+impl Cryptor for AeadXChaCha20Poly1305RtpSize {
+ fn decrypt(&mut self, packet: &RtpPacket, key: &[u8]) -> Option> {
+ let mut cipher = XChaCha20Poly1305::new(key.into());
+ let header = packet.header(HeaderExtensionType::Partial);
+ let blob = packet.encrypted_blob();
+ if blob.len() < NONCE_BYTES {
+ return None;
+ }
+
+ let ciphertext = &blob[..blob.len() - NONCE_BYTES];
+ let nonce_bytes = &blob[blob.len() - NONCE_BYTES..];
+ let mut nonce = [0; 24];
+ nonce[..NONCE_BYTES].copy_from_slice(nonce_bytes);
+ let payload = Payload {
+ msg: ciphertext,
+ aad: header,
+ };
+
+ cipher.decrypt(&nonce.into(), payload).ok()
+ }
+
+ fn encrypt(&mut self, packet: &RtpPacket, key: &[u8]) -> Option> {
+ self.sequence = self.sequence.wrapping_add(1);
+ let data = packet.data_to_encrypt();
+ let mut cipher = XChaCha20Poly1305::new(key.into());
+ let header = packet.header(HeaderExtensionType::Partial);
+ let mut nonce = [0; 24];
+ nonce[..NONCE_BYTES].copy_from_slice(&self.sequence.to_be_bytes());
+ let payload = Payload {
+ msg: data,
+ aad: header,
+ };
+ let mut ciphertext = cipher.encrypt(&nonce.into(), payload).ok()?;
+ ciphertext.extend_from_slice(&nonce[..NONCE_BYTES]);
+ Some(ciphertext)
+ }
+
+ fn name(&self) -> &'static str {
+ "aead_xchacha20_poly1305_rtpsize"
+ }
+}
diff --git a/Aerovoice/Native/src/crypto/mod.rs b/Aerovoice/Native/src/crypto/mod.rs
new file mode 100644
index 00000000..543b7e22
--- /dev/null
+++ b/Aerovoice/Native/src/crypto/mod.rs
@@ -0,0 +1,28 @@
+mod aead_xchacha20_poly1305_rtpsize;
+
+pub use aead_xchacha20_poly1305_rtpsize::AeadXChaCha20Poly1305RtpSize;
+
+use crate::rtp::{Decrypted, Encrypted, RtpPacket};
+
+const CRYPTOR_PRIORITY: &[&str] = &["aead_xchacha20_poly1305_rtpsize"];
+
+pub fn encryption_by_priority(available_methods: &[String]) -> Option> {
+ for name in CRYPTOR_PRIORITY {
+ if available_methods.contains(&name.to_string()) {
+ return match *name {
+ "aead_xchacha20_poly1305_rtpsize" => {
+ Some(Box::new(AeadXChaCha20Poly1305RtpSize::default()))
+ }
+ _ => None,
+ };
+ }
+ }
+
+ None
+}
+
+pub trait Cryptor: Send + Sync {
+ fn name(&self) -> &'static str;
+ fn encrypt(&mut self, packet: &RtpPacket, key: &[u8]) -> Option>;
+ fn decrypt(&mut self, packet: &RtpPacket, key: &[u8]) -> Option>;
+}
diff --git a/Aerovoice/Native/src/external.rs b/Aerovoice/Native/src/external.rs
new file mode 100644
index 00000000..ab60f46c
--- /dev/null
+++ b/Aerovoice/Native/src/external.rs
@@ -0,0 +1,116 @@
+use std::ffi::c_char;
+
+use crate::{
+ char_ext::CCharExt,
+ crypto::encryption_by_priority,
+ session::{IPInfo, VoiceSession},
+ snowflake::Snowflake,
+};
+
+#[unsafe(no_mangle)]
+extern "C" fn voice_session_new(
+ ssrc: u32,
+ channel: u64,
+ ip: *const c_char,
+ port: u16,
+ on_speaking: extern "C" fn(u32, bool),
+) -> *mut VoiceSession {
+ let channel = Snowflake::from_u64(channel);
+
+ let Some(ip) = ip.to_string_lossy() else {
+ return std::ptr::null_mut();
+ };
+
+ let session = VoiceSession::new(ssrc, channel, ip, port, on_speaking);
+
+ Box::into_raw(Box::new(session))
+}
+
+// extern "C" fn on_speaking(ssrc: u32, speaking: bool) {
+// println!("SSRC: {}, Speaking: {}", ssrc, speaking);
+// }
+
+#[unsafe(no_mangle)]
+extern "C" fn voice_session_init_poll_thread(session: *mut VoiceSession) {
+ if session.is_null() {
+ return;
+ }
+
+ let session = unsafe { &mut *session };
+ session.init_poll_thread();
+}
+
+#[unsafe(no_mangle)]
+extern "C" fn voice_session_set_secret(session: *mut VoiceSession, secret: *const u8, len: u32) {
+ if session.is_null() {
+ return;
+ }
+
+ let session = unsafe { &mut *session };
+ let len = len as usize;
+ let secret = unsafe { std::slice::from_raw_parts(secret, len) };
+ let secret = secret.to_vec();
+ session.set_secret(secret);
+}
+
+#[unsafe(no_mangle)]
+extern "C" fn voice_session_select_cryptor(
+ session: *mut VoiceSession,
+ available_methods: *const *const c_char,
+ available_methods_len: u32,
+) -> *mut c_char {
+ if session.is_null() {
+ return std::ptr::null_mut();
+ }
+
+ let session = unsafe { &mut *session };
+
+ let available_methods =
+ unsafe { std::slice::from_raw_parts(available_methods, available_methods_len as usize) };
+ // convert to Vec
+ let available_methods = available_methods
+ .iter()
+ .map(|&s| unsafe { std::ffi::CStr::from_ptr(s) })
+ .map(|s| s.to_string_lossy().to_string())
+ .collect::>();
+
+ let Some(send_cryptor) = encryption_by_priority(&available_methods) else {
+ println!("!!! WARNING !!! No encryption method was found. VOICE CHAT WILL NOT WORK!");
+ return std::ptr::null_mut();
+ };
+
+ let Some(recv_cryptor) = encryption_by_priority(&available_methods) else {
+ println!("!!! WARNING !!! No encryption method was found. VOICE CHAT WILL NOT WORK!");
+ return std::ptr::null_mut();
+ };
+
+ let name = recv_cryptor.name().to_string();
+ let name = std::ffi::CString::new(name).unwrap();
+
+ session.set_cryptor(send_cryptor, recv_cryptor);
+
+ name.into_raw()
+}
+
+#[unsafe(no_mangle)]
+extern "C" fn voice_session_discover_ip(session: *mut VoiceSession) -> *const IPInfo {
+ if session.is_null() {
+ return std::ptr::null();
+ }
+
+ let session = unsafe { &mut *session };
+ let ip_info = session.discover_ip();
+
+ ip_info as *const _
+}
+
+#[unsafe(no_mangle)]
+extern "C" fn voice_session_free(session: *mut VoiceSession) {
+ if session.is_null() {
+ return;
+ }
+
+ unsafe {
+ drop(Box::from_raw(session));
+ }
+}
diff --git a/Aerovoice/Native/src/lib.rs b/Aerovoice/Native/src/lib.rs
new file mode 100644
index 00000000..acd6c2ee
--- /dev/null
+++ b/Aerovoice/Native/src/lib.rs
@@ -0,0 +1,24 @@
+use std::sync::LazyLock;
+
+use cpal::{Device, Host, traits::HostTrait as _};
+
+mod char_ext;
+mod crypto;
+mod external;
+mod rtp;
+mod session;
+mod snowflake;
+
+pub static DEFAULT_HOST: LazyLock = LazyLock::new(cpal::default_host);
+
+pub static OUTPUT_DEVICE: LazyLock = LazyLock::new(|| {
+ DEFAULT_HOST
+ .default_output_device()
+ .expect("failed to get default output device")
+});
+
+pub static INPUT_DEVICE: LazyLock = LazyLock::new(|| {
+ DEFAULT_HOST
+ .default_input_device()
+ .expect("failed to get default output device")
+});
diff --git a/Aerovoice/Native/src/rtp.rs b/Aerovoice/Native/src/rtp.rs
new file mode 100644
index 00000000..de28bf41
--- /dev/null
+++ b/Aerovoice/Native/src/rtp.rs
@@ -0,0 +1,316 @@
+use std::marker::PhantomData;
+
+use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
+use thiserror::Error;
+
+use crate::crypto::Cryptor;
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct Encrypted;
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct Decrypted;
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct RtpPacket {
+ pub version_flags: u8,
+ pub payload_type: u8,
+ pub sequence: u16,
+ pub timestamp: u32,
+ pub ssrc: u32,
+ payload: Vec,
+ raw: Vec,
+ is_silence: Option,
+ _marker: PhantomData,
+}
+
+#[derive(Debug, Error)]
+pub enum PacketParseError {
+ #[error("Invalid packet length: {0}")]
+ PacketTooShort(usize),
+ #[error("Invalid header:: {0}")]
+ InvalidHeader(#[from] std::io::Error),
+}
+
+#[derive(Debug, Error)]
+pub enum PacketDecryptError {
+ #[error("Decryption failed for cryptor {0}")]
+ DecryptionFailed(&'static str),
+}
+
+#[derive(Debug, Error)]
+pub enum PacketEncryptError {
+ #[error("Encryption failed for cryptor {0}")]
+ EncryptionFailed(&'static str),
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum HeaderExtensionType {
+ None,
+ Partial,
+ Full,
+}
+
+#[derive(Debug, Error)]
+pub enum PacketConstructError {
+ #[error("Failed to construct packet")]
+ PacketConstructFailed,
+}
+
+impl RtpPacket {
+ pub fn header(&self, header_type: HeaderExtensionType) -> &[u8] {
+ &self.raw[..self.header_length(header_type)]
+ }
+
+ pub fn extension_length(&self) -> usize {
+ if (self.version_flags & 0x10) == 0 {
+ return 0;
+ }
+
+ let mut packet = &self.raw[14..];
+ let extension_length = packet.read_u16::().unwrap_or(0) as usize;
+
+ extension_length * 4
+ }
+
+ pub fn header_length(&self, header_type: HeaderExtensionType) -> usize {
+ const HEADER_LENGTH: usize = 12;
+ if !self.has_extension() {
+ return HEADER_LENGTH;
+ }
+
+ match header_type {
+ HeaderExtensionType::None => HEADER_LENGTH,
+ HeaderExtensionType::Partial => HEADER_LENGTH + 4,
+ HeaderExtensionType::Full => HEADER_LENGTH + self.extension_length() + 4,
+ }
+ }
+
+ pub fn has_extension(&self) -> bool {
+ (self.version_flags & 0x10) != 0
+ }
+
+ pub fn raw(&self) -> &[u8] {
+ &self.raw
+ }
+
+ pub fn is_silent(&self) -> Option {
+ self.is_silence
+ }
+}
+
+impl RtpPacket {
+ pub fn parse(packet: impl AsRef<[u8]>) -> Result {
+ let raw = packet.as_ref().to_vec();
+ let mut packet = packet.as_ref();
+ if packet.len() < 12 {
+ return Err(PacketParseError::PacketTooShort(packet.len()));
+ }
+
+ let version_flags = packet.read_u8()?;
+ let payload_type = packet.read_u8()?;
+ let sequence = packet.read_u16::()?;
+ let timestamp = packet.read_u32::()?;
+ let ssrc = packet.read_u32::()?;
+
+ Ok(RtpPacket:: {
+ version_flags,
+ payload_type,
+ sequence,
+ timestamp,
+ ssrc,
+ raw,
+ payload: vec![],
+ _marker: PhantomData,
+ is_silence: None,
+ })
+ }
+
+ pub fn decrypt(
+ self,
+ cryptor: &mut (impl Cryptor + ?Sized),
+ secret: &[u8],
+ ) -> Result, PacketDecryptError> {
+ let mut decrypted = cryptor
+ .decrypt(&self, secret)
+ .ok_or_else(|| PacketDecryptError::DecryptionFailed(cryptor.name()))?;
+
+ if self.has_extension() {
+ decrypted.drain(..self.extension_length());
+ }
+
+ let header = self.header(HeaderExtensionType::Partial);
+
+ let mut raw = Vec::with_capacity(header.len() + decrypted.len());
+ raw.extend_from_slice(header);
+ raw.extend_from_slice(&decrypted);
+
+ Ok(RtpPacket:: {
+ version_flags: self.version_flags,
+ payload_type: self.payload_type,
+ sequence: self.sequence,
+ timestamp: self.timestamp,
+ ssrc: self.ssrc,
+ payload: decrypted,
+ raw,
+ _marker: PhantomData,
+ is_silence: self.is_silence,
+ })
+ }
+
+ pub fn encrypted_blob(&self) -> &[u8] {
+ &self.raw[self.header_length(HeaderExtensionType::Partial)..]
+ }
+}
+
+impl RtpPacket {
+ pub fn builder() -> RtpPacketBuilder {
+ RtpPacketBuilder::default()
+ }
+
+ pub fn payload(&self) -> &[u8] {
+ &self.payload
+ }
+
+ pub fn encrypt(
+ self,
+ cryptor: &mut (impl Cryptor + ?Sized),
+ secret: &[u8],
+ ) -> Result, PacketEncryptError> {
+ let encrypted_payload = cryptor
+ .encrypt(&self, secret)
+ .ok_or_else(|| PacketEncryptError::EncryptionFailed(cryptor.name()))?;
+
+ let header = self.header(HeaderExtensionType::Partial);
+ let mut raw = Vec::with_capacity(header.len() + encrypted_payload.len());
+ raw.extend_from_slice(header);
+ raw.extend_from_slice(&encrypted_payload);
+
+ Ok(RtpPacket:: {
+ version_flags: self.version_flags,
+ payload_type: self.payload_type,
+ sequence: self.sequence,
+ timestamp: self.timestamp,
+ ssrc: self.ssrc,
+ is_silence: self.is_silence,
+ payload: encrypted_payload,
+ raw,
+ _marker: PhantomData,
+ })
+ }
+
+ pub fn data_to_encrypt(&self) -> &[u8] {
+ &self.raw[self.header_length(HeaderExtensionType::Partial)..]
+ }
+}
+
+#[derive(Debug)]
+pub struct RtpPacketBuilder {
+ version_flags: u8,
+ payload_type: u8,
+ sequence: u16,
+ timestamp: u32,
+ ssrc: u32,
+ payload: Vec,
+ is_silence: Option,
+}
+
+impl Default for RtpPacketBuilder {
+ fn default() -> Self {
+ Self {
+ version_flags: 0x80,
+ payload_type: 0x78,
+ sequence: 0,
+ timestamp: 0,
+ ssrc: 0,
+ payload: vec![],
+ is_silence: None,
+ }
+ }
+}
+
+impl RtpPacketBuilder {
+ pub fn silence(mut self, is_silence: bool) -> Self {
+ self.is_silence = Some(is_silence);
+ self
+ }
+
+ pub fn sequence(mut self, sequence: u16) -> Self {
+ self.sequence = sequence;
+ self
+ }
+
+ pub fn timestamp(mut self, timestamp: u32) -> Self {
+ self.timestamp = timestamp;
+ self
+ }
+
+ pub fn ssrc(mut self, ssrc: u32) -> Self {
+ self.ssrc = ssrc;
+ self
+ }
+
+ pub fn payload(mut self, payload: Vec) -> Self {
+ self.payload = payload;
+ self
+ }
+
+ pub fn build(self) -> Result, PacketConstructError> {
+ let packet_size = 12 + self.payload.len();
+ let mut raw = Vec::with_capacity(packet_size);
+ raw.write_u8(self.version_flags)
+ .map_err(|_| PacketConstructError::PacketConstructFailed)?;
+ raw.write_u8(self.payload_type)
+ .map_err(|_| PacketConstructError::PacketConstructFailed)?;
+ raw.write_u16::(self.sequence)
+ .map_err(|_| PacketConstructError::PacketConstructFailed)?;
+ raw.write_u32::(self.timestamp)
+ .map_err(|_| PacketConstructError::PacketConstructFailed)?;
+ raw.write_u32::(self.ssrc)
+ .map_err(|_| PacketConstructError::PacketConstructFailed)?;
+ raw.extend_from_slice(&self.payload);
+
+ Ok(RtpPacket:: {
+ version_flags: self.version_flags,
+ payload_type: self.payload_type,
+ sequence: self.sequence,
+ timestamp: self.timestamp,
+ ssrc: self.ssrc,
+ payload: self.payload,
+ is_silence: self.is_silence,
+ raw,
+ _marker: PhantomData,
+ })
+ }
+}
+
+mod tests {
+ const SECRET: [u8; 32] = [127; 32];
+ use crate::crypto::AeadXChaCha20Poly1305RtpSize;
+
+ use super::*;
+
+ #[test]
+ fn encrypt_decrypt() {
+ let packet = RtpPacket::builder()
+ .ssrc(250)
+ .sequence(24)
+ .timestamp(100)
+ .payload(vec![1, 2, 3, 4, 5, 6, 7, 8])
+ .build()
+ .expect("failed to construct packet");
+
+ let mut cryptor = AeadXChaCha20Poly1305RtpSize::default();
+
+ let encrypted = packet
+ .clone()
+ .encrypt(&mut cryptor, &SECRET)
+ .expect("failed to encrypt packet");
+
+ let decrypted = encrypted
+ .decrypt(&mut cryptor, &SECRET)
+ .expect("failed to decrypt packet");
+
+ assert_eq!(packet, decrypted);
+ }
+}
diff --git a/Aerovoice/Native/src/session.rs b/Aerovoice/Native/src/session.rs
new file mode 100644
index 00000000..93806fe2
--- /dev/null
+++ b/Aerovoice/Native/src/session.rs
@@ -0,0 +1,521 @@
+use crate::{
+ INPUT_DEVICE, OUTPUT_DEVICE,
+ crypto::Cryptor,
+ rtp::{Encrypted, RtpPacket},
+ snowflake::Snowflake,
+};
+use audiopus::{
+ Application, Channels, SampleRate,
+ coder::{Decoder, Encoder},
+};
+use byteorder::{BigEndian, WriteBytesExt};
+
+use cpal::{
+ InputCallbackInfo, OutputCallbackInfo, Stream, StreamConfig,
+ traits::{DeviceTrait, StreamTrait as _},
+};
+use rtrb::{Consumer, Producer, RingBuffer};
+use std::{
+ collections::{HashMap, hash_map::Entry},
+ net::UdpSocket,
+ sync::mpsc::{self, Receiver, Sender},
+ thread::{self, JoinHandle},
+ time::Instant,
+};
+
+struct RecorderState {
+ packet_tx: Sender>,
+ encoder: Encoder,
+ ssrc: u32,
+ cryptor: Box,
+ sequence: u16,
+ instant: Instant,
+ secret: Vec,
+ last_silences: [bool; 24],
+}
+
+#[derive(Debug)]
+#[repr(C)]
+pub struct IPInfo {
+ ip: [u8; 64],
+ port: u16,
+}
+
+macro_rules! fallible {
+ ($fallible:expr, $return:expr) => {
+ match $fallible {
+ Ok(val) => val,
+ Err(e) => {
+ println!("Error: {}", e);
+ return $return;
+ }
+ }
+ };
+
+ ($fallible:expr) => {
+ match $fallible {
+ Ok(val) => val,
+ Err(e) => {
+ println!("Error: {}", e);
+ continue;
+ }
+ }
+ };
+}
+
+macro_rules! nullable {
+ ($nullable:expr, $return:expr) => {
+ match $nullable {
+ Some(val) => val,
+ None => {
+ println!("Error: None value encountered");
+ return $return;
+ }
+ }
+ };
+
+ ($nullable:expr) => {
+ match $nullable {
+ Some(val) => val,
+ None => {
+ println!("Error: None value encountered");
+ continue;
+ }
+ }
+ };
+}
+
+struct AudioUser {
+ pcm_producer: Producer,
+ stream: Stream,
+ decoder: Decoder,
+ speaking: bool,
+ was_speaking: bool,
+ ssrc: u32,
+}
+
+struct ThreadInitialiser {
+ cryptor: Box,
+ secret: Vec,
+ socket: UdpSocket,
+}
+
+#[repr(C)]
+pub struct VoiceSession {
+ channel: Snowflake,
+ secret: Option>,
+ ip: String,
+ port: u16,
+ discovered_ip: Option,
+ socket: Option,
+ ssrc: u32,
+ recv_cryptor: Option>,
+ send_cryptor: Option>,
+ poll_thread: JoinHandle<()>,
+ poll_thread_initialiser: Sender,
+ poll_thread_killer: Sender<()>,
+ on_speaking: extern "C" fn(u32, bool),
+ packet_tx: Option>>,
+ recording_stream: Option,
+}
+
+impl VoiceSession {
+ pub fn new(
+ ssrc: u32,
+ channel: Snowflake,
+ ip: String,
+ port: u16,
+ on_speaking: extern "C" fn(u32, bool),
+ ) -> Self {
+ println!("connecting to voice session: {}:{}", ip, port);
+ let socket = UdpSocket::bind("0.0.0.0:0").expect("failed to bind UDP socket");
+ socket
+ .set_nonblocking(true)
+ .expect("failed to set non-blocking mode");
+
+ socket
+ .connect(format!("{}:{}", ip, port))
+ .expect("failed to connect to UDP socket");
+
+ let (init_tx, init_rx) = mpsc::channel();
+ let (packet_tx, packet_rx) = mpsc::channel();
+ let (killer_tx, killer_rx) = mpsc::channel();
+
+ let poll_thread = thread::spawn(move || {
+ // wait for the initialiser
+ let initializer: ThreadInitialiser =
+ init_rx.recv().expect("failed to receive initialiser");
+ let cryptor = initializer.cryptor;
+ let secret = initializer.secret;
+ let socket = initializer.socket;
+ let users = HashMap::new();
+
+ Self::poll_thread(
+ socket,
+ cryptor,
+ secret,
+ users,
+ packet_rx,
+ killer_rx,
+ on_speaking,
+ );
+ });
+
+ Self {
+ channel,
+ secret: None,
+ ip,
+ port,
+ discovered_ip: None,
+ socket: Some(socket),
+ ssrc,
+ recv_cryptor: None,
+ send_cryptor: None,
+ poll_thread,
+ poll_thread_initialiser: init_tx,
+ poll_thread_killer: killer_tx,
+ on_speaking,
+ packet_tx: Some(packet_tx),
+ recording_stream: None,
+ }
+ }
+
+ fn record_audio(data: &[i16], _: &InputCallbackInfo, recorder_state: &mut RecorderState) {
+ recorder_state.sequence = recorder_state.sequence.wrapping_add(1);
+ let mut packet = vec![0; 2048];
+
+ let length = match recorder_state.encoder.encode(data, &mut packet) {
+ Ok(length) => length,
+ Err(e) => {
+ println!("Error encoding audio: {}", e);
+ return;
+ }
+ };
+
+ let packet = &packet[..length];
+
+ const CLOCK_RATE: u32 = 48000;
+ let elapsed_seconds = recorder_state.instant.elapsed().as_secs_f64();
+ let timestamp = (elapsed_seconds * CLOCK_RATE as f64) as u32;
+ let is_silent = !is_not_silent(data);
+
+ recorder_state.last_silences.rotate_right(1);
+ recorder_state.last_silences[0] = is_silent;
+
+ let is_silent = recorder_state.last_silences.iter().all(|&x| x);
+
+ let packet = match RtpPacket::builder()
+ .ssrc(recorder_state.ssrc)
+ .sequence(recorder_state.sequence)
+ .timestamp(timestamp)
+ .payload(packet.to_vec())
+ .silence(is_silent)
+ .build()
+ {
+ Ok(packet) => packet,
+ Err(e) => {
+ println!("Error building RTP packet: {}", e);
+ return;
+ }
+ };
+
+ let Ok(packet) = packet.encrypt(
+ recorder_state.cryptor.as_mut(),
+ recorder_state.secret.as_slice(),
+ ) else {
+ println!("failed to encrypt packet");
+ return;
+ };
+
+ if let Err(e) = recorder_state.packet_tx.send(packet) {
+ println!("Error sending packet: {}", e);
+ }
+ }
+
+ pub fn init_poll_thread(&mut self) {
+ let socket = self.socket.take().expect("socket is not set");
+ let recv_cryptor = self.recv_cryptor.take().expect("cryptor is not set");
+ let send_cryptor = self.send_cryptor.take().expect("cryptor is not set");
+ let secret = self.secret.take().expect("secret is not set");
+ let packet_tx = self.packet_tx.take().expect("packet tx is not set");
+ let ssrc = self.ssrc;
+
+ let thread_initialiser = ThreadInitialiser {
+ cryptor: recv_cryptor,
+ secret: secret.clone(),
+ socket,
+ };
+
+ self.poll_thread_initialiser
+ .send(thread_initialiser)
+ .expect("failed to send initialiser to poll thread");
+
+ let config = StreamConfig {
+ channels: 2,
+ sample_rate: cpal::SampleRate(48000),
+ buffer_size: cpal::BufferSize::Fixed(960),
+ };
+
+ println!(
+ "creating input stream with {}",
+ INPUT_DEVICE.name().unwrap_or_default()
+ );
+
+ let mut recorder_state = RecorderState {
+ packet_tx,
+ encoder: Encoder::new(SampleRate::Hz48000, Channels::Stereo, Application::Voip)
+ .expect("failed to create encoder"),
+ ssrc,
+ cryptor: send_cryptor,
+ sequence: 0,
+ instant: Instant::now(),
+ secret,
+ last_silences: [true; 24],
+ };
+
+ let input = match INPUT_DEVICE.build_input_stream(
+ &config,
+ move |data, info| {
+ Self::record_audio(data, info, &mut recorder_state);
+ },
+ |e| println!("An error occured in the recorder: {}", e),
+ None,
+ ) {
+ Ok(stream) => stream,
+ Err(e) => {
+ println!("Error creating input stream: {}", e);
+ return;
+ }
+ };
+
+ if let Err(e) = input.play() {
+ println!("Error playing input stream: {}", e)
+ }
+
+ self.recording_stream = Some(input);
+ }
+
+ fn poll_thread(
+ socket: UdpSocket,
+ mut cryptor: Box,
+ secret: Vec,
+ mut users: HashMap,
+ packet_rx: Receiver>,
+ killer_rx: Receiver<()>,
+ on_speaking: extern "C" fn(u32, bool),
+ ) {
+ let mut last_silent = true;
+ let mut faux_packets = vec![];
+
+ loop {
+ if killer_rx.try_recv().is_ok() {
+ println!("killing poll thread");
+ break;
+ }
+
+ while let Ok(packet) = packet_rx.try_recv() {
+ let is_silent = packet.is_silent().unwrap_or(true);
+ if last_silent != is_silent {
+ (on_speaking)(packet.ssrc, !is_silent);
+ }
+
+ if !is_silent {
+ if let Err(e) = socket.send(packet.raw()) {
+ println!("Error sending packet: {}", e);
+ }
+ }
+
+ last_silent = is_silent;
+
+ faux_packets.push(packet);
+ }
+
+ let mut buf = [0; 1024];
+
+ let len = match socket.recv(&mut buf) {
+ Ok(len) => len,
+ Err(e) => {
+ if e.kind() != std::io::ErrorKind::WouldBlock {
+ println!("Error receiving data: {}", e);
+ }
+
+ continue;
+ }
+ };
+
+ let packet = &buf[..len];
+
+ if packet[0] == 129 && packet[1] == 201 {
+ continue;
+ }
+
+ // let Ok(packet) = RtpPacket::parse(packet) else {
+ // println!("failed to parse packet");
+ // continue;
+ // };
+
+ let Some(packet) = faux_packets.pop() else {
+ continue;
+ };
+
+ if let Entry::Vacant(e) = users.entry(packet.ssrc) {
+ let decoder = fallible!(Decoder::new(SampleRate::Hz48000, Channels::Stereo));
+ let config = StreamConfig {
+ channels: 2,
+ sample_rate: cpal::SampleRate(48000),
+ buffer_size: cpal::BufferSize::Default,
+ };
+
+ // allow up to 1024 packets in the buffer
+ let (producer, mut consumer) = RingBuffer::new(960 * 2 * 2 * 1024);
+
+ let stream = OUTPUT_DEVICE.build_output_stream(
+ &config,
+ move |data, info| {
+ Self::process_audio(&mut consumer, data, info);
+ },
+ move |err| {
+ eprintln!("Error: {}", err);
+ },
+ None,
+ );
+
+ let stream = fallible!(stream);
+ let user = AudioUser {
+ stream,
+ pcm_producer: producer,
+ decoder,
+ speaking: false,
+ was_speaking: false,
+ ssrc: packet.ssrc,
+ };
+
+ fallible!(user.stream.play());
+
+ println!("a new user was detected with ssrc {}", packet.ssrc);
+ e.insert(user);
+ }
+
+ let user = nullable!(users.get_mut(&packet.ssrc));
+ let mut output = vec![0; 960 * 2];
+
+ if let Ok(packet) = packet.decrypt(cryptor.as_mut(), secret.as_slice()) {
+ if user
+ .decoder
+ .decode(Some(packet.payload()), &mut output, false)
+ .is_ok()
+ {
+ for byte in &output {
+ if let Err(e) = user.pcm_producer.push(*byte) {
+ println!("Error pushing to PCM buffer: {}", e);
+ }
+ }
+
+ user.speaking = true;
+ } else {
+ user.speaking = false;
+ }
+ } else {
+ println!("failed to decrypt packet");
+ user.speaking = false;
+ }
+
+ if user.was_speaking != user.speaking {
+ (on_speaking)(user.ssrc, user.speaking);
+ }
+
+ user.was_speaking = user.speaking;
+ }
+ }
+
+ pub fn set_cryptor(&mut self, send_cryptor: Box, recv_cryptor: Box) {
+ self.recv_cryptor = Some(recv_cryptor);
+ self.send_cryptor = Some(send_cryptor);
+ }
+
+ pub fn process_audio(pcm_buffer: &mut Consumer, data: &mut [i16], _: &OutputCallbackInfo) {
+ for item in data.iter_mut() {
+ match pcm_buffer.pop() {
+ Ok(sample) => *item = sample,
+ Err(_) => *item = 0,
+ };
+ }
+ }
+
+ pub fn discover_ip(&mut self) -> &IPInfo {
+ if let Some(ref ip_info) = self.discovered_ip {
+ return ip_info;
+ }
+
+ println!("attempting to discover IP...");
+ let Some(ref socket) = self.socket else {
+ panic!("socket is not set");
+ };
+
+ let mut ip_discovery = Vec::with_capacity(2 + 2 + 4 + 64 + 2);
+ ip_discovery.write_u16::(0x1).unwrap();
+ ip_discovery.write_u16::(70).unwrap();
+ ip_discovery.write_u32::(self.ssrc).unwrap();
+ let ip = self.ip.as_bytes();
+ let mut padded_ip = [0; 64];
+ padded_ip[..ip.len()].copy_from_slice(ip);
+ ip_discovery.extend_from_slice(&padded_ip);
+ ip_discovery.write_u16::(self.port).unwrap();
+
+ println!("written IP discovery packet, blocking until response");
+
+ socket
+ .send(&ip_discovery)
+ .expect("failed to send IP discovery packet");
+ let mut buf = [0; 2 + 2 + 4 + 64 + 2];
+
+ loop {
+ match socket.recv_from(&mut buf) {
+ Ok(_) => {
+ break;
+ }
+ Err(e) => {
+ if e.kind() != std::io::ErrorKind::WouldBlock {
+ panic!("error receiving data: {}", e);
+ }
+ }
+ }
+ }
+
+ let mut ip = [0; 64];
+ ip.copy_from_slice(&buf[8..8 + 64]);
+
+ println!("IP discovered!");
+
+ let port = u16::from_be_bytes([buf[buf.len() - 2], buf[buf.len() - 1]]);
+ let ip_info = IPInfo { ip, port };
+ self.discovered_ip = Some(ip_info);
+ self.discovered_ip.as_ref().unwrap()
+ }
+
+ pub fn set_secret(&mut self, secret: Vec) {
+ self.secret = Some(secret);
+ }
+}
+
+impl Drop for VoiceSession {
+ fn drop(&mut self) {
+ self.poll_thread_killer
+ .send(())
+ .expect("failed to send kill signal to poll thread");
+ }
+}
+
+fn is_not_silent(data: &[i16]) -> bool {
+ const THRESHOLD: f32 = 0.03;
+ let sum_squares: f32 = data
+ .iter()
+ .map(|&sample| {
+ let normalized = sample as f32 / i16::MAX as f32;
+ normalized * normalized
+ })
+ .sum();
+
+ let rms = (sum_squares / data.len() as f32).sqrt();
+ rms > THRESHOLD
+}
diff --git a/Aerovoice/Native/src/snowflake.rs b/Aerovoice/Native/src/snowflake.rs
new file mode 100644
index 00000000..3f3c883c
--- /dev/null
+++ b/Aerovoice/Native/src/snowflake.rs
@@ -0,0 +1,8 @@
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct Snowflake(u64);
+
+impl Snowflake {
+ pub fn from_u64(value: u64) -> Self {
+ Snowflake(value)
+ }
+}