Add encrypted managed UDP relay and native voice conformance

This commit is contained in:
2026-09-15 22:53:54 +02:00
parent 4067bab7c2
commit 05eacb3092
19 changed files with 1068 additions and 46 deletions
+24 -2
View File
@@ -137,5 +137,27 @@ managed code imports and authenticates it, then C++ authenticates a managed-crea
account. CI also regenerates the libsodium password fixture. Native checks require
the optional `VOICECAT_BUILD_DOTNET_ORACLE=ON` configure flag and a real-deps build.
Phase 4 remains in progress: UDP/SFU relay, streams, protected channel joins,
administration, moderation, and production configuration are the next server work.
The server also advertises UDP on the TCP port number, supports voice subscription
and stream signaling, and reseals encoded audio for subscribers in the same channel.
UDP binding fixes the first endpoint for the session; reconnect after endpoint changes.
Protected joins, administration, moderation, production configuration and full
media-aware reaping remain before Phase 4 completion.
Enable deterministic native voice interoperability (no audio hardware required):
```powershell
cmake --build --preset dev --target voicecat-dotnet-voice-oracle
$env:VOICECAT_VOICE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-voice-oracle.exe).Path
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
```
Two existing C++ clients authenticate, join Lobby or Music Room, publish three
concurrent streams, feed PCM, and verify decoded energy and metadata in both directions.
The native clients use external capture/playback to avoid device dependencies in CI.
`MediaFanoutTests` separately verifies 50-subscriber routing/resealing without managed
allocations after warm-up and reports throughput; socket scheduling is excluded.
The transport load test delivers all 2,500 recipient packets from a paced 50 pps sender.
Native `vccli --test-tone-ms 4000` runs finite external capture/playback, feeds a tone,
and fails without decoded remote audio. Tests start two CLI processes in mono/stereo
channels and also verify channel text. Normal `--voice` now explicitly subscribes before
announcing its microphone stream. No C ABI or wire changes were needed.
+4
View File
@@ -8,6 +8,10 @@ target_link_libraries(voicecat-dotnet-tls-oracle PRIVATE voicecat::voicecat)
target_include_directories(voicecat-dotnet-tls-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
target_compile_features(voicecat-dotnet-tls-oracle PRIVATE cxx_std_20)
add_executable(voicecat-dotnet-voice-oracle voice.cpp)
target_link_libraries(voicecat-dotnet-voice-oracle PRIVATE voicecat::voicecat)
target_compile_features(voicecat-dotnet-voice-oracle PRIVATE cxx_std_20)
add_executable(voicecat-dotnet-dsp-oracle dsp.cpp)
target_link_libraries(voicecat-dotnet-dsp-oracle PRIVATE voicecat::voicecat)
target_include_directories(voicecat-dotnet-dsp-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
+127
View File
@@ -0,0 +1,127 @@
#include "voicecat.h"
#include <array>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdio>
#include <cstdlib>
#include <memory>
#include <mutex>
#include <thread>
#include <vector>
struct ClientState {
vc_client* client = nullptr;
std::mutex gate;
std::condition_variable changed;
bool authenticated = false;
bool subscribed = false;
bool joined = false;
uint32_t user = 0;
std::vector<std::pair<uint32_t, uint32_t>> streams;
std::array<int, 3> received{};
long long energy = 0;
uint32_t channels = 0;
};
static void event(void* context, const vc_event* value) {
auto& state = *static_cast<ClientState*>(context);
if (value->type == VC_EVENT_SERVER_IDENTITY) {
vc_confirm_server_identity(state.client, 1);
return;
}
std::lock_guard lock(state.gate);
switch (value->type) {
case VC_EVENT_AUTH_RESULT:
state.authenticated = value->result == VC_OK;
state.user = value->user_id;
break;
case VC_EVENT_VOICE_STATE: state.subscribed = value->u32a == 1; break;
case VC_EVENT_JOIN_RESULT: state.joined = value->result == VC_OK; break;
case VC_EVENT_STREAM_STARTED: state.streams.emplace_back(value->user_id, value->stream_id); break;
default: break;
}
state.changed.notify_all();
}
static void sink(void* context, uint32_t, uint32_t stream, const int16_t* pcm,
size_t samples, uint32_t channels, uint32_t rate) {
auto& state = *static_cast<ClientState*>(context);
if (rate != 48000 || stream >= state.received.size()) return;
std::lock_guard lock(state.gate);
++state.received[stream];
state.channels = channels;
for (size_t index = 0; index < samples * channels; ++index) state.energy += std::abs(static_cast<int>(pcm[index]));
state.changed.notify_all();
}
template<class Predicate>
static bool wait(ClientState& state, Predicate predicate) {
std::unique_lock lock(state.gate);
return state.changed.wait_for(lock, std::chrono::seconds(8), predicate);
}
struct Destroy {
void operator()(vc_client* client) const { vc_disconnect(client); vc_client_destroy(client); }
};
using Client = std::unique_ptr<vc_client, Destroy>;
static Client connect(ClientState& state, uint16_t port, uint32_t channel, const char* nickname) {
vc_config config{"dotnet-voice-oracle", "1", VC_LOG_OFF};
Client client(vc_client_create(&config, {event, nullptr, &state}));
state.client = client.get();
if (!client || vc_set_external_playback(client.get(), 1) != VC_OK ||
vc_connect(client.get(), "127.0.0.1", port) != VC_OK ||
vc_authenticate_guest(client.get(), nickname) != VC_OK ||
!wait(state, [&] { return state.authenticated; }) ||
vc_join_channel(client.get(), channel, nullptr) != VC_OK ||
!wait(state, [&] { return state.joined; }) ||
vc_join_voice(client.get()) != VC_OK ||
!wait(state, [&] { return state.subscribed; }) ||
vc_set_pcm_sink(client.get(), sink, &state) != VC_OK) return {};
return client;
}
int main(int argc, char** argv) {
if (argc != 3) return 1;
uint16_t port = static_cast<uint16_t>(std::strtoul(argv[1], nullptr, 10));
uint32_t channel = static_cast<uint32_t>(std::strtoul(argv[2], nullptr, 10));
ClientState alice, bob;
Client a = connect(alice, port, channel, "Native Alice");
Client b = connect(bob, port, channel, "Native Bob");
if (!a || !b) { std::fprintf(stderr, "native authentication/join/subscription failed\n"); return 1; }
std::array<uint32_t, 3> ids{};
vc_stream_desc mic{};
mic.kind = VC_STREAM_MIC;
mic.external_feed = 1;
vc_stream_desc screen = mic;
screen.kind = VC_STREAM_SCREEN_AUDIO;
if (vc_stream_start(a.get(), &mic, &ids[0]) != VC_OK ||
vc_stream_start(a.get(), &screen, &ids[1]) != VC_OK ||
vc_stream_start(b.get(), &mic, &ids[2]) != VC_OK ||
!wait(alice, [&] { return alice.streams.size() >= 3; }) ||
!wait(bob, [&] { return bob.streams.size() >= 3; })) {
std::fprintf(stderr, "native stream signaling failed\n"); return 1;
}
uint32_t channels = channel == 2 ? 2 : 1;
std::vector<int16_t> pcm(960 * channels);
for (size_t sample = 0; sample < 960; ++sample)
for (uint32_t side = 0; side < channels; ++side)
pcm[sample * channels + side] = static_cast<int16_t>(12000 * std::sin(sample * (side == 0 ? 0.058 : 0.083)));
for (int frame = 0; frame < 100; ++frame) {
if (vc_stream_feed_pcm(a.get(), ids[0], pcm.data(), 960, channels) != VC_OK ||
vc_stream_feed_pcm(a.get(), ids[1], pcm.data(), 960, channels) != VC_OK ||
vc_stream_feed_pcm(b.get(), ids[2], pcm.data(), 960, channels) != VC_OK) return 1;
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
bool received = wait(alice, [&] { return alice.received[ids[2]] >= 5 && alice.energy > 0; }) &&
wait(bob, [&] { return bob.received[ids[0]] >= 5 && bob.received[ids[1]] >= 5 && bob.energy > 0; });
{
std::scoped_lock lock(alice.gate, bob.gate);
std::printf("channel=%u channels=%u alice=%d bob-mic=%d bob-screen=%d energy=%lld/%lld\n",
channel, channels, alice.received[ids[2]], bob.received[ids[0]], bob.received[ids[1]], alice.energy, bob.energy);
received = received && alice.channels == channels && bob.channels == channels;
}
return received ? 0 : 1;
}
@@ -0,0 +1,50 @@
using System.Net;
using System.Security.Cryptography;
using VoiceCat.Protocol;
namespace VoiceCat.Server.Transport;
// One packet at a time. Each returned buffer must be sent before preparing the next recipient.
internal sealed class MediaFanout : IDisposable
{
private readonly byte[] plaintext = new byte[65535];
private readonly byte[] output = new byte[65535];
private MediaRoute[] routes = [];
private MediaRoute? source;
private VoiceFrameHeader header;
private int length;
private int index;
public bool TryStart(ReadOnlySpan<byte> packet, MediaRoute sender, MediaRoute[] recipients)
{
source = null;
if (!VoiceFrameHeader.TryRead(packet, out var candidate) || candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
!sender.Subscribed || sender.Muted || !sender.Sources.Contains(candidate.Ssrc) ||
packet.Length <= VoiceFrameHeader.Size + 16 || packet.Length > output.Length) return false;
if (!sender.Peer.Crypto.Decryptor.TryDecrypt(packet, plaintext, out header, out length)) return false;
source = sender;
routes = recipients;
index = 0;
return true;
}
public bool TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint)
{
packet = default;
endpoint = null;
if (source is null) return false;
while (index < routes.Length)
{
MediaRoute recipient = routes[index++];
if (ReferenceEquals(recipient.Peer, source.Peer) || recipient.ChannelId != source.ChannelId ||
!recipient.Subscribed || recipient.Deafened || recipient.Peer.Endpoint is null) continue;
int size = recipient.Peer.Crypto.Encryptor.Encrypt(header, plaintext.AsSpan(0, length), output);
packet = output.AsMemory(0, size);
endpoint = recipient.Peer.Endpoint;
return true;
}
return false;
}
public void Dispose() => CryptographicOperations.ZeroMemory(plaintext);
}
@@ -0,0 +1,150 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Threading.Channels;
using VoiceCat.Protocol;
namespace VoiceCat.Server.Transport;
internal sealed class MediaPeer(byte[] token, MediaSessionCrypto crypto)
{
public byte[] Token { get; } = token;
public MediaSessionCrypto Crypto { get; } = crypto;
// Only the UDP loop reads or changes the endpoint and binding state.
public SocketAddress? Endpoint { get; set; }
public void Dispose() { Crypto.Dispose(); CryptographicOperations.ZeroMemory(Token); }
}
internal sealed record MediaRoute(MediaPeer Peer, uint ChannelId, bool Subscribed, bool Muted, bool Deafened, uint[] Sources);
internal sealed class MediaRelay : IAsyncDisposable
{
private readonly Socket socket;
private readonly CancellationTokenSource shutdown = new();
private readonly ConcurrentQueue<MediaPeer> retired = new();
private readonly Channel<byte> changed = Channel.CreateBounded<byte>(1);
private MediaRoute[] routes = [];
private readonly byte[] input = new byte[65535];
private readonly MediaFanout fanout = new();
private readonly Task receiving;
public IPEndPoint EndPoint { get; }
public event Action<Exception>? Failed;
public MediaRelay(IPEndPoint endpoint)
{
socket = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
try { socket.Bind(endpoint); EndPoint = (IPEndPoint)socket.LocalEndPoint!; }
catch { socket.Dispose(); shutdown.Dispose(); throw; }
receiving = ReceiveAsync();
}
// Publications are serialized by the server's session gate. Crypto ownership transfers here.
public void Publish(MediaRoute[] next)
{
MediaRoute[] previous = Volatile.Read(ref routes);
Volatile.Write(ref routes, next);
foreach (MediaRoute route in previous)
if (!next.Any(candidate => ReferenceEquals(candidate.Peer, route.Peer))) retired.Enqueue(route.Peer);
changed.Writer.TryWrite(0);
}
private void DrainRetired()
{
while (retired.TryDequeue(out MediaPeer? peer)) peer.Dispose();
}
private async Task ReceiveAsync()
{
var sender = new SocketAddress(socket.AddressFamily);
Task<int>? receive = null;
Task<bool>? update = null;
try
{
while (true)
{
receive ??= socket.ReceiveFromAsync(input, SocketFlags.None, sender, shutdown.Token).AsTask();
update ??= changed.Reader.WaitToReadAsync(shutdown.Token).AsTask();
await Task.WhenAny(receive, update).ConfigureAwait(false);
if (update.IsCompleted)
{
await update.ConfigureAwait(false);
while (changed.Reader.TryRead(out _)) { }
update = null;
DrainRetired();
}
if (!receive.IsCompleted) continue;
int length;
try { length = await receive.ConfigureAwait(false); }
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.MessageSize or SocketError.ConnectionReset) { continue; }
finally { receive = null; }
DrainRetired();
MediaRoute[] current = Volatile.Read(ref routes);
if (!VoiceFrameHeader.TryRead(input.AsSpan(0, length), out var header)) continue;
MediaRoute? source = null;
foreach (MediaRoute route in current)
if (route.Peer.Endpoint?.Equals(sender) == true) { source = route; break; }
if (header.Type == MediaFrameType.UdpBinding)
{
if (length != VoiceFrameHeader.Size + 16 || source is not null) continue;
foreach (MediaRoute route in current)
{
if (route.Peer.Endpoint is not null || !CryptographicOperations.FixedTimeEquals(route.Peer.Token, input.AsSpan(VoiceFrameHeader.Size, 16))) continue;
var bound = new SocketAddress(sender.Family, sender.Size);
for (int index = 0; index < sender.Size; index++) bound[index] = sender[index];
route.Peer.Endpoint = bound;
break;
}
continue;
}
if (source is null) continue;
if (header.Type == MediaFrameType.Keepalive)
{
if (length == VoiceFrameHeader.Size) await SendAsync(input.AsMemory(0, length), sender).ConfigureAwait(false);
continue;
}
if (!fanout.TryStart(input.AsSpan(0, length), source, current)) continue;
while (fanout.TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint))
await SendAsync(packet, endpoint!).ConfigureAwait(false);
}
}
catch (Exception exception) when (shutdown.IsCancellationRequested && exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
catch (Exception exception) { Failed?.Invoke(exception); throw; }
finally
{
shutdown.Cancel();
socket.Dispose();
fanout.Dispose();
if (receive is not null)
{
try { await receive.ConfigureAwait(false); }
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
}
if (update is not null)
{
try { await update.ConfigureAwait(false); }
catch (OperationCanceledException) { }
}
}
}
private async ValueTask SendAsync(ReadOnlyMemory<byte> packet, SocketAddress endpoint)
{
try { await socket.SendToAsync(packet, SocketFlags.None, endpoint, shutdown.Token).ConfigureAwait(false); }
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionReset or SocketError.HostUnreachable or SocketError.NetworkUnreachable) { }
}
public async ValueTask DisposeAsync()
{
shutdown.Cancel();
socket.Dispose();
try { await receiving.ConfigureAwait(false); }
finally
{
DrainRetired();
foreach (MediaRoute route in Volatile.Read(ref routes)) route.Peer.Dispose();
shutdown.Dispose();
}
}
}
@@ -0,0 +1,10 @@
using VoiceCat.Crypto;
namespace VoiceCat.Server.Transport;
internal sealed class MediaSessionCrypto(MediaEncryptor encryptor, MediaDecryptor decryptor) : IDisposable
{
public MediaEncryptor Encryptor { get; } = encryptor;
public MediaDecryptor Decryptor { get; } = decryptor;
public void Dispose() { Encryptor.Dispose(); Decryptor.Dispose(); }
}
@@ -21,6 +21,8 @@ internal sealed class TlsControlConnection : IAsyncDisposable
private int prefixBytes;
private byte[]? payload;
private int payloadBytes;
private readonly TaskCompletionSource mediaReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private MediaSessionCrypto? mediaCrypto;
public Task Completion { get; }
public CancellationToken CancellationToken => lifetime.Token;
@@ -48,6 +50,12 @@ internal sealed class TlsControlConnection : IAsyncDisposable
public void CompleteWrites() => outgoing.Writer.TryComplete();
internal async Task<MediaSessionCrypto> TakeMediaCryptoAsync(CancellationToken cancellationToken)
{
await mediaReady.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
return Interlocked.Exchange(ref mediaCrypto, null) ?? throw new InvalidOperationException("Media crypto already has an owner.");
}
private async Task RunAsync()
{
byte[] ciphertext = new byte[16384];
@@ -80,6 +88,13 @@ internal sealed class TlsControlConnection : IAsyncDisposable
break;
}
tls.ReceiveCiphertext(ciphertext.AsSpan(0, count));
if (tls.IsReady && !mediaReady.Task.IsCompleted)
{
var encryptor = tls.CreateMediaEncryptor();
try { mediaCrypto = new(encryptor, tls.CreateMediaDecryptor()); }
catch { encryptor.Dispose(); throw; }
mediaReady.SetResult();
}
if (tls.IsReady) lifetime.CancelAfter(TimeSpan.FromSeconds(60));
while ((count = tls.ReadPlaintext(plaintext)) > 0) Parse(plaintext.AsSpan(0, count));
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
@@ -104,6 +119,7 @@ internal sealed class TlsControlConnection : IAsyncDisposable
}
finally
{
mediaReady.TrySetCanceled();
lifetime.Cancel();
socket.Dispose();
if (receive is not null)
@@ -163,7 +179,7 @@ internal sealed class TlsControlConnection : IAsyncDisposable
public async ValueTask DisposeAsync()
{
lifetime.Cancel();
await Completion.ConfigureAwait(false);
lifetime.Dispose();
try { await Completion.ConfigureAwait(false); }
finally { Interlocked.Exchange(ref mediaCrypto, null)?.Dispose(); lifetime.Dispose(); }
}
}
+89 -8
View File
@@ -13,6 +13,7 @@ namespace VoiceCat.Server;
public sealed class VoiceServer : IAsyncDisposable
{
private readonly Socket listener;
private readonly MediaRelay media;
private readonly ServerCredentials credentials;
private readonly AccountStore accounts;
private readonly IReadOnlyList<Voicecat.V1.Channel> channels;
@@ -24,10 +25,12 @@ public sealed class VoiceServer : IAsyncDisposable
private readonly List<Task> connections = [];
private ulong nextSession;
private uint nextUser;
private uint nextSsrc;
private readonly Task accepting;
private int disposed;
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
public IPEndPoint MediaEndPoint => media.EndPoint;
public event Action<Exception>? ConnectionFailed;
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
@@ -42,6 +45,8 @@ public sealed class VoiceServer : IAsyncDisposable
listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
listener.Bind(endpoint);
listener.Listen(64);
media = new((IPEndPoint)listener.LocalEndPoint!);
media.Failed += exception => ConnectionFailed?.Invoke(exception);
}
catch
{
@@ -96,7 +101,8 @@ public sealed class VoiceServer : IAsyncDisposable
Reject(session, "Unsupported protocol version or banned address.");
break;
}
var hello = new ServerHello { ProtoVersion = 2, ServerName = name, ServerVersion = "0.1.0-dotnet", ServerIdentityFingerprint = ByteString.CopyFrom(SHA256.HashData(credentials.Identity.PublicKey)) };
session.Media = new(RandomNumberGenerator.GetBytes(16), await session.Connection.TakeMediaCryptoAsync(shutdown.Token).ConfigureAwait(false));
var hello = new ServerHello { ProtoVersion = 2, ServerName = name, ServerVersion = "0.1.0-dotnet", UdpPort = checked((uint)media.EndPoint.Port), ServerIdentityFingerprint = ByteString.CopyFrom(SHA256.HashData(credentials.Identity.PublicKey)) };
if (allowGuests) hello.AuthMethods.Add("guest");
hello.AuthMethods.Add("password");
session.Connection.TrySend(new() { RequestId = envelope.RequestId, ServerHello = hello });
@@ -114,8 +120,14 @@ public sealed class VoiceServer : IAsyncDisposable
case Envelope.BodyOneofCase.TextMessage: RelayText(session, envelope.TextMessage); break;
case Envelope.BodyOneofCase.Subscribe: SendSnapshot(session); break;
case Envelope.BodyOneofCase.JoinChannel: Join(session, envelope.RequestId, envelope.JoinChannel.ChannelId); break;
case Envelope.BodyOneofCase.SubscribeVoice:
session.Connection.TrySend(new() { RequestId = envelope.RequestId, VoiceSubscriptionResult = new() { Error = "Managed media relay is not implemented yet." } });
case Envelope.BodyOneofCase.SubscribeVoice: SubscribeVoice(session, envelope.RequestId, true); break;
case Envelope.BodyOneofCase.UnsubscribeVoice: SubscribeVoice(session, envelope.RequestId, false); break;
case Envelope.BodyOneofCase.StreamAnnounce: AnnounceStream(session, envelope.RequestId, envelope.StreamAnnounce); break;
case Envelope.BodyOneofCase.StreamStop: StopStream(session, envelope.StreamStop.StreamId); break;
case Envelope.BodyOneofCase.StreamState: UpdateStream(session, envelope.StreamState); break;
case Envelope.BodyOneofCase.UdpBinding:
if (!envelope.UdpBinding.Ack && CryptographicOperations.FixedTimeEquals(envelope.UdpBinding.UdpToken.Span, session.Media!.Token))
session.Connection.TrySend(new() { RequestId = envelope.RequestId, UdpBinding = new() { Ack = true } });
break;
default:
session.Connection.TrySend(new() { RequestId = envelope.RequestId, GenericResult = new() { Code = 1, Message = "Operation is not implemented by this server checkpoint." } });
@@ -133,6 +145,8 @@ public sealed class VoiceServer : IAsyncDisposable
lock (gate)
{
sessions.Remove(session.Id);
if (session.User is null) session.Media?.Dispose();
else PublishMedia();
if (session.User is not null) Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Left, LeftId = session.User.Id } });
}
await session.Connection.DisposeAsync().ConfigureAwait(false);
@@ -170,9 +184,10 @@ public sealed class VoiceServer : IAsyncDisposable
session.User = user;
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new()
{
Ok = true, SessionId = session.Id, Self = user.Clone(),
Ok = true, SessionId = session.Id, Self = user.Clone(), UdpToken = ByteString.CopyFrom(session.Media!.Token),
Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin }
} });
PublishMedia();
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Joined, User = user.Clone() } }, session.Id);
SendSnapshot(session);
}
@@ -199,7 +214,9 @@ public sealed class VoiceServer : IAsyncDisposable
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = new() { Error = "Channel unavailable." } });
return;
}
session.User!.ChannelId = channelId;
if (session.User!.ChannelId != channelId) session.User.Streams.Clear();
session.User.ChannelId = channelId;
PublishMedia();
var result = new JoinChannelResult { Ok = true, ChannelId = channelId, Audio = channel.Audio.Clone() };
result.Members.Add(sessions.Values.Where(peer => peer.User?.ChannelId == channelId).Select(peer => peer.User!.Clone()));
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = result });
@@ -229,6 +246,69 @@ public sealed class VoiceServer : IAsyncDisposable
}
}
private void PublishMedia()
{
media.Publish(sessions.Values.Where(peer => peer.User is not null).Select(peer => new MediaRoute(
peer.Media!, peer.User!.ChannelId, peer.User.VoiceSubscribed, peer.User.ServerMuted, peer.User.SelfDeafened || peer.User.ServerDeafened,
peer.User.Streams.Select(stream => stream.Ssrc).ToArray())).ToArray());
}
private void BroadcastUser(Session session) => Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Updated, User = session.User!.Clone() } });
private void SubscribeVoice(Session session, ulong requestId, bool subscribed)
{
lock (gate)
{
session.User!.VoiceSubscribed = subscribed;
if (!subscribed) session.User.Streams.Clear();
PublishMedia();
session.Connection.TrySend(new() { RequestId = requestId, VoiceSubscriptionResult = new() { Ok = true, Subscribed = subscribed } });
BroadcastUser(session);
}
}
private void AnnounceStream(Session session, ulong requestId, StreamAnnounce request)
{
lock (gate)
{
if (!session.User!.VoiceSubscribed || !Enum.IsDefined(request.Kind) || request.Label.Length > 128 || session.User.Streams.Count >= 16 ||
nextSsrc == uint.MaxValue || session.NextStream == uint.MaxValue || request.RequestedAudio?.BitrateBps is > 0 and < 500)
{
session.Connection.TrySend(new() { RequestId = requestId, StreamAnnounceResult = new() { Error = "Voice subscription required, invalid stream, or stream limit reached." } });
return;
}
AudioConfig audio = channels.First(channel => channel.Id == session.User.ChannelId).Audio.Clone();
if (request.RequestedAudio?.BitrateBps > 0) audio.BitrateBps = Math.Min(audio.BitrateBps, request.RequestedAudio.BitrateBps);
var stream = new StreamInfo { StreamId = ++session.NextStream, Ssrc = ++nextSsrc, Kind = request.Kind, Label = request.Label, Audio = audio };
session.User.Streams.Add(stream);
PublishMedia();
session.Connection.TrySend(new() { RequestId = requestId, StreamAnnounceResult = new() { Ok = true, StreamId = stream.StreamId, Ssrc = stream.Ssrc, EffectiveAudio = audio.Clone() } });
BroadcastUser(session);
}
}
private void StopStream(Session session, uint streamId)
{
lock (gate)
{
StreamInfo? stream = session.User!.Streams.FirstOrDefault(candidate => candidate.StreamId == streamId);
if (stream is null) return;
session.User.Streams.Remove(stream);
PublishMedia();
BroadcastUser(session);
}
}
private void UpdateStream(Session session, StreamStateUpdate update)
{
lock (gate)
{
StreamInfo? stream = session.User!.Streams.FirstOrDefault(candidate => candidate.StreamId == update.StreamId);
if (stream is null) return;
Broadcast(new() { StreamState = new() { UserId = session.User.Id, StreamId = stream.StreamId, Muted = update.Muted, Talking = update.Talking } });
}
}
private void Broadcast(Envelope envelope, ulong excluded = 0)
{
foreach (Session recipient in sessions.Values.Where(peer => peer.Id != excluded && peer.User is not null)) recipient.Connection.TrySend(envelope);
@@ -248,9 +328,8 @@ public sealed class VoiceServer : IAsyncDisposable
}
finally
{
accounts.Dispose();
credentials.Dispose();
shutdown.Dispose();
try { await media.DisposeAsync().ConfigureAwait(false); }
finally { accounts.Dispose(); credentials.Dispose(); shutdown.Dispose(); }
}
}
@@ -261,5 +340,7 @@ public sealed class VoiceServer : IAsyncDisposable
public string Address { get; } = address;
public bool HelloReceived { get; set; }
public User? User { get; set; }
public MediaPeer? Media { get; set; }
public uint NextStream;
}
}
@@ -0,0 +1,110 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using VoiceCat.Server.Transport;
using Xunit.Abstractions;
namespace VoiceCat.Tests;
public sealed class MediaFanoutTests(ITestOutputHelper output)
{
[Fact]
public async Task UdpRelayDeliversFiftyPacketsPerSecondToFiftySubscribers()
{
await using var relay = new MediaRelay(new(IPAddress.Loopback, 0));
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
Socket[] sockets = Enumerable.Range(0, 51).Select(_ => new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)).ToArray();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
try
{
foreach (Socket socket in sockets)
{
socket.ReceiveBufferSize = 1024 * 1024;
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
}
MediaRoute[] routes = sockets.Select(socket => new MediaRoute(
new(new byte[16], new(new(key), new(key))) { Endpoint = ((IPEndPoint)socket.LocalEndPoint!).Serialize() },
1, true, false, false, [42])).ToArray();
relay.Publish(routes);
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
Task[] receivers = sockets.Skip(1).Select(async socket =>
{
using var decryptor = new MediaDecryptor(key);
byte[] packet = new byte[4000];
byte[] decoded = new byte[4000];
for (ulong sequence = 0; sequence < 50; sequence++)
{
int length = await socket.ReceiveAsync(packet, SocketFlags.None, timeout.Token);
Assert.True(decryptor.TryDecrypt(packet.AsSpan(0, length), decoded, out var header, out int bytes));
Assert.Equal(sequence, header.Sequence);
Assert.Equal(payload, decoded[..bytes]);
}
}).ToArray();
using var encryptor = new MediaEncryptor(key);
byte[] outgoing = new byte[VoiceFrameHeader.Size + payload.Length + 16];
var elapsed = Stopwatch.StartNew();
for (uint index = 0; index < 50; index++)
{
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, index * 960), payload, outgoing);
await sockets[0].SendToAsync(outgoing, SocketFlags.None, relay.EndPoint, timeout.Token);
await Task.Delay(20, timeout.Token);
}
await Task.WhenAll(receivers);
output.WriteLine($"Delivered all 2,500 recipient packets in {elapsed.Elapsed.TotalMilliseconds:F1} ms at a paced 50 pps input.");
}
finally { foreach (Socket socket in sockets) socket.Dispose(); }
}
[PlatformCipherFact]
public void FiftySubscriberFanoutAllocatesNoManagedMemoryAndPreservesPayload()
{
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
MediaRoute[] routes = Enumerable.Range(0, 51).Select(i => new MediaRoute(
new(new byte[16], new(new(key), new(key))) { Endpoint = new IPEndPoint(IPAddress.Loopback, 10000 + i).Serialize() },
1, true, false, false, [42])).ToArray();
using var sender = new MediaEncryptor(key);
using var receiver = new MediaDecryptor(key);
using var fanout = new MediaFanout();
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
byte[] decoded = new byte[payload.Length];
ReadOnlyMemory<byte> last = default;
try
{
for (int i = 0; i < 100; i++) Cycle();
long before = GC.GetAllocatedBytesForCurrentThread();
long started = Stopwatch.GetTimestamp();
for (int i = 0; i < 1000; i++) Cycle();
TimeSpan elapsed = Stopwatch.GetElapsedTime(started);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
Assert.True(receiver.TryDecrypt(last.Span, decoded, out var header, out int length));
Assert.Equal(payload.Length, length);
Assert.Equal(payload, decoded);
Assert.Equal(42U, header.Ssrc);
Assert.Equal(1099UL, header.Sequence);
output.WriteLine($"50,000 recipient seals in {elapsed.TotalMilliseconds:F1} ms; {allocated} managed bytes. Transport scheduling is excluded.");
}
finally { foreach (MediaRoute route in routes) route.Peer.Dispose(); }
void Cycle()
{
sender.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), payload, packet);
if (!fanout.TryStart(packet, routes[0], routes)) throw new InvalidOperationException("Valid packet rejected.");
int recipients = 0;
while (fanout.TryNext(out var next, out _)) { last = next; recipients++; }
if (recipients != 50) throw new InvalidOperationException("Incorrect fanout.");
}
}
private sealed class PlatformCipherFactAttribute : FactAttribute
{
public PlatformCipherFactAttribute()
{
if (!ChaCha20Poly1305.IsSupported) Skip = "The allocation guarantee requires platform ChaCha20-Poly1305; fallback conformance is tested separately.";
}
}
}
@@ -0,0 +1,303 @@
using System.Net;
using System.Diagnostics;
using System.Net.Sockets;
using VoiceCat.Protocol;
using VoiceCat.Server.Transport;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
namespace VoiceCat.Tests;
public sealed class MediaRelayTests
{
[CppCliVoiceTheory]
[InlineData(1)]
[InlineData(2)]
public async Task TwoCppCliProcessesJoinChatAndExchangeVoice(int channel)
{
await using var fixture = new ServerFixture();
await using var observer = await fixture.ConnectAsync();
await observer.LoginAsync("Observer");
observer.Send(new() { JoinChannel = new() { ChannelId = checked((uint)channel) } });
Assert.True((await observer.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await Task.WhenAll(RunAsync("Cli Alice"), RunAsync("Cli Bob"));
var first = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
var second = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
Assert.Equal("CLI voice checkpoint", first.Body);
Assert.Equal(first.Body, second.Body);
Assert.NotEqual(first.SenderId, second.SenderId);
async Task RunAsync(string nickname)
{
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
{
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true
};
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
"--nick", nickname, "--channel", channel.ToString(System.Globalization.CultureInfo.InvariantCulture), "--text", "CLI voice checkpoint", "--test-tone-ms", "4000" })
start.ArgumentList.Add(argument);
using var process = Process.Start(start)!;
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
Task<string> stderr = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(timeout.Token);
string log = await stdout + await stderr;
Assert.True(process.ExitCode == 0, log);
Assert.Contains("[test-tone] received=", log);
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
}
private sealed class CppCliVoiceTheoryAttribute : TheoryAttribute
{
public CppCliVoiceTheoryAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
}
}
[VoiceOracleTheory]
[InlineData(1)]
[InlineData(2)]
public async Task ExistingCppClientsExchangeBidirectionalVoiceThroughManagedServer(int channel)
{
await using var fixture = new ServerFixture();
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE")!)
{
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true
};
start.ArgumentList.Add(fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture));
start.ArgumentList.Add(channel.ToString(System.Globalization.CultureInfo.InvariantCulture));
using var process = Process.Start(start)!;
Task<string> output = process.StandardOutput.ReadToEndAsync();
Task<string> error = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(45));
try
{
await process.WaitForExitAsync(timeout.Token);
Assert.True(process.ExitCode == 0, await output + await error);
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
private sealed class VoiceOracleTheoryAttribute : TheoryAttribute
{
public VoiceOracleTheoryAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE"))) Skip = "Set VOICECAT_VOICE_ORACLE to the native voice conformance executable.";
}
}
[Fact]
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
alice.Client.Send(new() { Disconnect = new() });
await bob.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left && e.UserEvent.LeftId == alice.Client.Authentication!.Self.Id);
await alice.SendAsync(alice.Seal(stream.Ssrc, [1]));
await bob.AssertNoVoiceAsync();
}
[Fact]
public async Task AnnounceRequiresSubscriptionAndUsesAuthoritativeMusicSettings()
{
await using var fixture = new ServerFixture();
await using var client = await fixture.ConnectAsync();
await client.LoginAsync("Alice");
client.Send(new() { StreamAnnounce = new() { Kind = StreamKind.StreamMic } });
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
client.Send(new() { SubscribeVoice = new() });
await client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null);
client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
await client.ReadUntilAsync(e => e.JoinChannelResult is not null);
client.Send(new() { RequestId = 21, StreamAnnounce = new() { Kind = StreamKind.StreamScreenAudio, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 64000 } } });
var announced = await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null);
Assert.Equal(21UL, announced.RequestId);
Assert.True(announced.StreamAnnounceResult.Ok);
Assert.Equal(64000U, announced.StreamAnnounceResult.EffectiveAudio.BitrateBps);
Assert.Equal(48000U, announced.StreamAnnounceResult.EffectiveAudio.SampleRate);
Assert.Equal(ChannelMode.ModeStereo, announced.StreamAnnounceResult.EffectiveAudio.Mode);
client.Send(new() { StreamAnnounce = new() { Kind = (StreamKind)99 } });
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
}
[Fact]
public async Task EncryptedOpusIsResealedWithRecipientCountersAcrossMultipleStreamsAndSenders()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
await using var carol = await VoicePeer.ConnectAsync(fixture, "Carol");
StreamAnnounceResult mic = await alice.AnnounceAsync(StreamKind.StreamMic);
StreamAnnounceResult screen = await alice.AnnounceAsync(StreamKind.StreamScreenAudio);
StreamAnnounceResult other = await carol.AnnounceAsync(StreamKind.StreamMic);
Assert.NotEqual(mic.StreamId, screen.StreamId);
Assert.NotEqual(mic.Ssrc, screen.Ssrc);
Assert.Equal(48000U, screen.EffectiveAudio.SampleRate);
Assert.Equal(24000U, screen.EffectiveAudio.BitrateBps);
using var encoder = new Codec.OpusEncoder(new());
short[] samples = Enumerable.Range(0, 960).Select(i => (short)(8000 * Math.Sin(i * 0.1))).ToArray();
byte[] payload = new byte[4000];
int length = encoder.Encode(samples, payload);
payload = payload[..length];
foreach (var (sender, stream) in new[] { (alice, mic), (carol, other), (alice, screen) })
{
byte[] packet = sender.Seal(stream.Ssrc, payload, 960, VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent);
await sender.SendAsync(packet);
var received = await bob.ReceiveVoiceAsync();
Assert.Equal(payload, received.Payload);
Assert.Equal(stream.Ssrc, received.Header.Ssrc);
Assert.Equal(960U, received.Header.Timestamp);
Assert.Equal(VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent, received.Header.Flags);
}
Assert.Equal(2UL, bob.LastSequence);
}
[Fact]
public async Task ReplayForgeryAndSpoofedStreamsAreDroppedWithoutBreakingValidMedia()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
StreamAnnounceResult stream = await alice.AnnounceAsync(StreamKind.StreamMic);
byte[] packet = alice.Seal(stream.Ssrc, [1, 2, 3]);
await alice.SendAsync(packet);
Assert.Equal(new byte[] { 1, 2, 3 }, (await bob.ReceiveVoiceAsync()).Payload);
await alice.SendAsync(packet);
byte[] forged = alice.Seal(stream.Ssrc, [4]);
forged[^1] ^= 1;
await alice.SendAsync(forged);
await alice.SendAsync(alice.Seal(stream.Ssrc + 1000, [5]));
await alice.SendAsync([1]);
await alice.SendAsync(alice.Seal(stream.Ssrc, [6]));
Assert.Equal(new byte[] { 6 }, (await bob.ReceiveVoiceAsync()).Payload);
Assert.Equal(1UL, bob.LastSequence);
}
[Fact]
public async Task SubscriptionChannelMovementAndStreamStopIsolateMedia()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
var mic = await alice.AnnounceAsync(StreamKind.StreamMic);
await bob.SubscribeAsync(false);
await alice.SendAsync(alice.Seal(mic.Ssrc, [1]));
await bob.AssertNoVoiceAsync();
await bob.SubscribeAsync(true);
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
Assert.True((await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
await alice.SendAsync(alice.Seal(mic.Ssrc, [2]));
await bob.AssertNoVoiceAsync();
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 1 } });
await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null);
alice.Client.Send(new() { StreamStop = new() { StreamId = mic.StreamId } });
await alice.Client.ReadUntilAsync(e => e.UserEvent?.User?.Id == alice.Client.Authentication!.Self.Id && e.UserEvent.User.Streams.Count == 0);
await alice.SendAsync(alice.Seal(mic.Ssrc, [3]));
await bob.AssertNoVoiceAsync();
var replacement = await alice.AnnounceAsync(StreamKind.StreamMic);
await alice.SendAsync(alice.Seal(replacement.Ssrc, [4]));
Assert.Equal(new byte[] { 4 }, (await bob.ReceiveVoiceAsync()).Payload);
}
[Fact]
public async Task BadTokensCannotBindAndExistingBindingCannotBeStolen()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
using var rogue = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
rogue.Bind(new IPEndPoint(IPAddress.Loopback, 0));
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
alice.Client.Authentication!.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
byte[] keepalive = new byte[VoiceFrameHeader.Size];
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
await rogue.SendToAsync(keepalive, SocketFlags.None, fixture.Server.MediaEndPoint);
using var timeout = new CancellationTokenSource(200);
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await rogue.ReceiveAsync(new byte[100], SocketFlags.None, timeout.Token));
await alice.SendAsync(keepalive);
Assert.Equal(keepalive, await alice.ReceivePacketAsync());
}
internal sealed class VoicePeer : IAsyncDisposable
{
public Client Client { get; }
private readonly Socket udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
private readonly IPEndPoint endpoint;
private readonly MediaSessionCrypto crypto;
public ulong LastSequence { get; private set; }
private VoicePeer(Client client, IPEndPoint endpoint, MediaSessionCrypto crypto)
{
Client = client; this.endpoint = endpoint; this.crypto = crypto;
udp.Bind(new IPEndPoint(IPAddress.Loopback, 0));
}
public static async Task<VoicePeer> ConnectAsync(ServerFixture fixture, string nickname)
{
Client client = await fixture.ConnectAsync();
await client.LoginAsync(nickname);
var peer = new VoicePeer(client, fixture.Server.MediaEndPoint, await client.TakeMediaCryptoAsync());
client.Send(new() { UdpBinding = new() { UdpToken = client.Authentication!.UdpToken } });
Assert.True((await client.ReadUntilAsync(e => e.UdpBinding is not null)).UdpBinding.Ack);
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
client.Authentication.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
await peer.SendAsync(binding);
byte[] keepalive = new byte[VoiceFrameHeader.Size];
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
await peer.SendAsync(keepalive);
Assert.Equal(keepalive, await peer.ReceivePacketAsync());
await peer.SubscribeAsync(true);
return peer;
}
public async Task SubscribeAsync(bool subscribed)
{
Client.Send(subscribed ? new() { SubscribeVoice = new() } : new() { UnsubscribeVoice = new() });
var result = (await Client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null)).VoiceSubscriptionResult;
Assert.True(result.Ok); Assert.Equal(subscribed, result.Subscribed);
}
public async Task<StreamAnnounceResult> AnnounceAsync(StreamKind kind)
{
Client.Send(new() { StreamAnnounce = new() { Kind = kind, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 900000 } } });
var result = (await Client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult;
Assert.True(result.Ok, result.Error);
return result;
}
public byte[] Seal(uint ssrc, byte[] payload, uint timestamp = 0, VoiceFrameFlags flags = 0)
{
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
crypto.Encryptor.Encrypt(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload, packet);
return packet;
}
public async Task SendAsync(byte[] packet) => await udp.SendToAsync(packet, SocketFlags.None, endpoint, Client.Timeout.Token);
public async Task<byte[]> ReceivePacketAsync()
{
byte[] buffer = new byte[65535];
int size = await udp.ReceiveAsync(buffer, SocketFlags.None, Client.Timeout.Token);
return buffer[..size];
}
public async Task<(VoiceFrameHeader Header, byte[] Payload)> ReceiveVoiceAsync()
{
byte[] packet = await ReceivePacketAsync();
byte[] plain = new byte[65535];
Assert.True(crypto.Decryptor.TryDecrypt(packet, plain, out var header, out int length));
LastSequence = header.Sequence;
return (header, plain[..length]);
}
public async Task AssertNoVoiceAsync()
{
using var timeout = new CancellationTokenSource(200);
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await udp.ReceiveAsync(new byte[65535], SocketFlags.None, timeout.Token));
}
public async ValueTask DisposeAsync() { udp.Dispose(); crypto.Dispose(); await Client.DisposeAsync(); }
}
}
+5 -2
View File
@@ -131,7 +131,7 @@ public sealed class ServerTests
}
}
private sealed class ServerFixture : IAsyncDisposable
internal sealed class ServerFixture : IAsyncDisposable
{
public string Directory { get; } = Path.Combine(Path.GetTempPath(), "voicecat-server-" + Guid.NewGuid().ToString("N"));
public VoiceServer Server { get; }
@@ -156,7 +156,7 @@ public sealed class ServerTests
}
}
private sealed class Client : IAsyncDisposable
internal sealed class Client : IAsyncDisposable
{
public CancellationTokenSource Timeout { get; } = new(TimeSpan.FromSeconds(30));
private readonly TlsControlConnection connection;
@@ -167,6 +167,8 @@ public sealed class ServerTests
messages = connection.ReadAsync(Timeout.Token).GetAsyncEnumerator();
}
public void Send(Envelope envelope) => Assert.True(connection.TrySend(envelope));
public AuthResult? Authentication { get; private set; }
public Task<MediaSessionCrypto> TakeMediaCryptoAsync() => connection.TakeMediaCryptoAsync(Timeout.Token);
public async Task<Envelope> ReadUntilAsync(Func<Envelope, bool> predicate)
{
while (await messages.MoveNextAsync()) if (predicate(messages.Current)) return messages.Current;
@@ -178,6 +180,7 @@ public sealed class ServerTests
Assert.Equal(1UL, (await ReadUntilAsync(e => e.ServerHello is not null)).RequestId);
Send(new() { RequestId = 2, AuthRequest = new() { Guest = new() { Nickname = nickname } } });
AuthResult auth = (await ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
Authentication = auth;
Assert.True(auth.Ok, auth.Error);
ServerStateSnapshot state = (await ReadUntilAsync(e => e.ServerState is not null)).ServerState;
Assert.Equal(2, state.Channels.Count);