Add adaptive packet loss handling
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

This commit is contained in:
2026-09-22 16:54:49 +02:00
parent 7c4708c3de
commit 7b0c003ad4
35 changed files with 518 additions and 60 deletions
+13
View File
@@ -112,6 +112,19 @@ public sealed class AudioEngine : IDisposable
if (stream.Info.StreamId == streamId) return stream.Diagnostics;
return default;
}
public void SetExpectedPacketLoss(int percent)
{
if (percent is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(percent));
foreach (LocalStream stream in Volatile.Read(ref routes).Local)
if (stream.Info.Audio.PacketLossMode != PacketLossMode.PacketLossManual)
stream.DesiredExpectedPacketLoss = percent;
}
internal int GetAppliedExpectedPacketLoss(uint streamId)
{
foreach (LocalStream stream in Volatile.Read(ref routes).Local)
if (stream.Info.StreamId == streamId) return stream.AppliedExpectedPacketLoss;
return -1;
}
public void SetLocalGain(uint streamId, float gain)
{
if (!float.IsFinite(gain) || gain < 0 || gain > 4) throw new ArgumentOutOfRangeException(nameof(gain));
+6
View File
@@ -30,9 +30,12 @@ internal sealed class LocalStream : IDisposable
private int starvedSamples;
private long cycles, starvedCycles, encodedPackets, rejectedPackets;
private uint timestamp;
private int desiredPacketLoss;
private bool wasTransmitting, marker;
internal LocalAudioDiagnostics Diagnostics => new(Volatile.Read(ref cycles), Volatile.Read(ref starvedCycles),
Volatile.Read(ref encodedPackets), Volatile.Read(ref rejectedPackets), Input.CountFrames);
internal int AppliedExpectedPacketLoss => encoder.ExpectedPacketLossPercent;
internal int DesiredExpectedPacketLoss { set => Volatile.Write(ref desiredPacketLoss, value); }
internal bool Feed(ReadOnlySpan<short> pcm, int channels)
{
@@ -64,6 +67,7 @@ internal sealed class LocalStream : IDisposable
DiscontinuousTransmission = stream.Audio.Dtx, DeepRedundancy = stream.Audio.Dred,
Application = stream.Audio.Application switch { OpusApplication.OpusAudio => VoiceCat.Codec.OpusApplication.Audio, OpusApplication.OpusLowdelay => VoiceCat.Codec.OpusApplication.LowDelay, _ => VoiceCat.Codec.OpusApplication.Voip }
});
desiredPacketLoss = checked((int)stream.Audio.ExpectedPacketLoss);
try { left = new(); } catch { encoder.Dispose(); throw; }
try { right = new(); } catch { left.Dispose(); encoder.Dispose(); throw; }
}
@@ -71,6 +75,8 @@ internal sealed class LocalStream : IDisposable
internal void Process(AudioEngine engine, EncodedVoiceSender sender)
{
Interlocked.Increment(ref cycles);
int desired = Volatile.Read(ref desiredPacketLoss);
if (desired != encoder.ExpectedPacketLossPercent) encoder.SetExpectedPacketLossPercent(desired);
var input = capture.AsSpan(0, 960 * CaptureChannels);
if (Input.Read(input) != input.Length)
{
+11
View File
@@ -7,6 +7,7 @@ public sealed class OpusEncoder : IDisposable
private readonly OpusEncoderHandle handle;
public OpusOptions Options { get; }
public bool SupportsDeepRedundancy { get; }
public int ExpectedPacketLossPercent { get; private set; }
public static string Version => Marshal.PtrToStringUTF8(NativeMethods.Version())!;
public OpusEncoder(OpusOptions? options = null)
@@ -24,6 +25,7 @@ public sealed class OpusEncoder : IDisposable
Set(4012, Options.ForwardErrorCorrection ? 1 : 0);
Set(4016, Options.DiscontinuousTransmission ? 1 : 0);
Set(4014, Options.ExpectedPacketLossPercent);
ExpectedPacketLossPercent = Options.ExpectedPacketLossPercent;
int support = NativeMethods.EncoderGetDred(handle, out _);
if (support != -5) OpusException.Check(support);
SupportsDeepRedundancy = support == 0 && Options.SampleRate >= 16000;
@@ -38,6 +40,15 @@ public sealed class OpusEncoder : IDisposable
private void Set(int request, int value) => OpusException.Check(NativeMethods.EncoderSet(handle, request, value));
public void SetExpectedPacketLossPercent(int value)
{
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
if (value is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(value));
if (value == ExpectedPacketLossPercent) return;
Set(4014, value);
ExpectedPacketLossPercent = value;
}
public unsafe int Encode(ReadOnlySpan<short> pcm, Span<byte> packet)
{
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
+34 -4
View File
@@ -39,6 +39,8 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
private long nextRequest;
private AuthResult? authentication;
private ServerHello? hello;
private uint adaptiveLossChannel;
private int adaptiveLossPercent = -1;
private ClientConnectionState state;
public event Action<ClientConnectionState>? ConnectionStateChanged;
@@ -47,6 +49,7 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
public Task Completion => reader;
public AuthResult? Authentication { get { lock (stateGate) return authentication?.Clone(); } }
public ServerHello? ServerHello { get { lock (stateGate) return hello?.Clone(); } }
public bool SupportsAdaptivePacketLoss { get { lock (stateGate) return hello?.Features.Contains(ProtocolFeatures.AdaptivePacketLoss) == true; } }
public IReadOnlyList<Channel> Channels { get { lock (stateGate) return channels.Values.Select(c => c.Clone()).ToArray(); } }
public IReadOnlyList<User> Users { get { lock (stateGate) return users.Values.Select(u => u.Clone()).ToArray(); } }
public bool TryReadEvent(out Envelope? envelope) => events.Reader.TryRead(out envelope);
@@ -94,7 +97,9 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
pins.Pin(host, port, certificatePin);
}
reader = ReadAsync(control, connectionLifetime.Token);
Envelope response = await RequestAsync(new() { ClientHello = new() { ProtoVersion = 2, ClientName = clientName, ClientVersion = clientVersion } }, token).ConfigureAwait(false);
var clientHello = new ClientHello { ProtoVersion = 2, ClientName = clientName, ClientVersion = clientVersion };
clientHello.Features.Add(ProtocolFeatures.AdaptivePacketLoss);
Envelope response = await RequestAsync(new() { ClientHello = clientHello }, token).ConfigureAwait(false);
if (response.ServerHello?.ProtoVersion != 2) throw new IOException("Unsupported server protocol.");
lock (stateGate) hello = response.ServerHello.Clone();
keepalive = KeepaliveAsync(connectionLifetime.Token);
@@ -147,7 +152,14 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
var info = new StreamInfo { StreamId = response.StreamId, Ssrc = response.Ssrc, Kind = kind, Audio = response.EffectiveAudio.Clone(), Label = label };
try
{
lock (stateGate) { if (State != ClientConnectionState.Connected) throw new InvalidOperationException("Client disconnected during stream negotiation."); Audio.AddLocalStream(info, captureChannels); localStreams[info.StreamId] = info; }
lock (stateGate)
{
if (State != ClientConnectionState.Connected) throw new InvalidOperationException("Client disconnected during stream negotiation.");
Audio.AddLocalStream(info, captureChannels); localStreams[info.StreamId] = info;
User? self = authentication is null ? null : users.GetValueOrDefault(authentication.Self.Id, authentication.Self);
if (self is not null && self.ChannelId == adaptiveLossChannel && adaptiveLossPercent >= 0)
Audio.SetExpectedPacketLoss(adaptiveLossPercent);
}
return info.Clone();
}
catch { if (State == ClientConnectionState.Connected) Send(new() { StreamStop = new() { StreamId = info.StreamId } }); throw; }
@@ -226,13 +238,31 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
if (message.ChannelEvent is not null)
{
if (message.ChannelEvent.Kind == ChannelEvent.Types.Kind.Deleted) channels.Remove(message.ChannelEvent.DeletedId);
else if (message.ChannelEvent.Channel is not null) channels[message.ChannelEvent.Channel.Id] = message.ChannelEvent.Channel.Clone();
else if (message.ChannelEvent.Channel is not null)
{
PacketLossMode previousMode = channels.GetValueOrDefault(message.ChannelEvent.Channel.Id)?.Audio.PacketLossMode
?? PacketLossMode.PacketLossManual;
channels[message.ChannelEvent.Channel.Id] = message.ChannelEvent.Channel.Clone();
User? self = authentication is null ? null : users.GetValueOrDefault(authentication.Self.Id, authentication.Self);
if (self?.ChannelId == message.ChannelEvent.Channel.Id && previousMode != message.ChannelEvent.Channel.Audio.PacketLossMode)
{ adaptiveLossChannel = 0; adaptiveLossPercent = -1; }
}
}
if (message.UserEvent is not null)
{
if (message.UserEvent.Kind == UserEvent.Types.Kind.Left) users.Remove(message.UserEvent.LeftId);
else if (message.UserEvent.User is not null) users[message.UserEvent.User.Id] = message.UserEvent.User.Clone();
}
if (message.PacketLossUpdate is not null && authentication is not null)
{
User self = users.GetValueOrDefault(authentication.Self.Id, authentication.Self);
if (self.ChannelId == message.PacketLossUpdate.ChannelId)
{
adaptiveLossChannel = self.ChannelId;
adaptiveLossPercent = checked((int)message.PacketLossUpdate.AppliedPercent);
Audio.SetExpectedPacketLoss(checked((int)message.PacketLossUpdate.AppliedPercent));
}
}
if (authentication is not null && (message.ServerState is not null || message.UserEvent is not null))
{
User self = users.GetValueOrDefault(authentication.Self.Id, authentication.Self);
@@ -273,7 +303,7 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
media = null;
mediaCrypto?.Dispose(); mediaCrypto = null;
connectionLifetime?.Dispose(); connectionLifetime = null;
lock (stateGate) { authentication = null; hello = null; channels.Clear(); users.Clear(); }
lock (stateGate) { authentication = null; hello = null; channels.Clear(); users.Clear(); adaptiveLossChannel = 0; adaptiveLossPercent = -1; }
lock (stateGate)
{
foreach (var id in localStreams.Keys) Audio.RemoveLocalStream(id);
@@ -0,0 +1,6 @@
namespace VoiceCat.Protocol;
public static class ProtocolFeatures
{
public const string AdaptivePacketLoss = "adaptive-packet-loss";
}
+8 -1
View File
@@ -12,6 +12,13 @@ public sealed partial class VoiceServer
{
bool create = request.CreateChannel is not null;
Channel? input = create ? request.CreateChannel!.Channel : request.EditChannel?.Channel;
if (!create && input is not null && !actor.Features.Contains(VoiceCat.Protocol.ProtocolFeatures.AdaptivePacketLoss))
{
input = input.Clone();
if (input.Audio is not null)
input.Audio.PacketLossMode = channels.FirstOrDefault(channel => channel.Id == input.Id)?.Audio.PacketLossMode
?? PacketLossMode.PacketLossManual;
}
bool permitted = actor.Permissions.IsAdmin || create && actor.Permissions.CanCreateTempChannel && input?.Type == ChannelType.ChannelTemporary;
if (!permitted) { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
try
@@ -65,7 +72,7 @@ public sealed partial class VoiceServer
Encoding.UTF8.GetByteCount(channel.Topic) > 4096 || Encoding.UTF8.GetByteCount(password) > 1024 || !Enum.IsDefined(channel.Type) ||
channel.MaxUsers > int.MaxValue || a is null || a.Codec != 0 || !Enum.IsDefined(a.Mode) || !Enum.IsDefined(a.Application) ||
a.SampleRate != 48000 || a.BitrateBps is < 500 or > 512000 || a.FrameMs is not (5 or 10 or 20 or 40 or 60) ||
a.Complexity > 10 || a.ExpectedPacketLoss > 100 ||
a.Complexity > 10 || a.ExpectedPacketLoss > 100 || !Enum.IsDefined(a.PacketLossMode) ||
!create && !channels.Any(c => c.Id == channel.Id) || channel.ParentId != 0 && !channels.Any(c => c.Id == channel.ParentId))
throw new ArgumentException("Invalid channel or audio configuration.");
if (channel.Id == 1 && !create && (password.Length != 0 || channel.ParentId != 0)) throw new ArgumentException("Lobby must remain an unprotected root channel.");
+10 -4
View File
@@ -28,7 +28,7 @@ public sealed partial class AccountStore : IDisposable
version.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
object? stored = version.ExecuteScalar();
int revision = 0;
if (stored is not null && (!int.TryParse((string)stored, NumberStyles.None, CultureInfo.InvariantCulture, out revision) || revision is < 1 or > 3))
if (stored is not null && (!int.TryParse((string)stored, NumberStyles.None, CultureInfo.InvariantCulture, out revision) || revision is < 1 or > 4))
throw new InvalidDataException("Unsupported server database schema version.");
using var resource = typeof(AccountStore).Assembly.GetManifestResourceStream("VoiceCat.Server.Data.schema.sql")!;
using var reader = new StreamReader(resource);
@@ -41,7 +41,12 @@ public sealed partial class AccountStore : IDisposable
migrate.CommandText = "ALTER TABLE channels ADD COLUMN audio_dred INTEGER NOT NULL DEFAULT 0;";
migrate.ExecuteNonQuery();
}
migrate.CommandText = "INSERT INTO server_meta (key,value) VALUES ('schema_version','3') ON CONFLICT(key) DO UPDATE SET value='3';";
if (stored is not null && revision < 4)
{
migrate.CommandText = "ALTER TABLE channels ADD COLUMN audio_packet_loss_mode INTEGER NOT NULL DEFAULT 0;";
migrate.ExecuteNonQuery();
}
migrate.CommandText = "INSERT INTO server_meta (key,value) VALUES ('schema_version','4') ON CONFLICT(key) DO UPDATE SET value='4';";
migrate.ExecuteNonQuery();
transaction.Commit();
}
@@ -129,7 +134,7 @@ public sealed partial class AccountStore : IDisposable
command.CommandText = """
SELECT id,parent_id,name,topic,password_hash,max_users,type,sort_order,
audio_codec,audio_mode,audio_sample_rate,audio_bitrate_bps,audio_frame_ms,
audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity,audio_dred
audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity,audio_dred,audio_packet_loss_mode
FROM channels ORDER BY sort_order,id
""";
using var reader = command.ExecuteReader();
@@ -147,7 +152,8 @@ public sealed partial class AccountStore : IDisposable
SampleRate = checked((uint)reader.GetInt64(10)), BitrateBps = checked((uint)reader.GetInt64(11)),
FrameMs = checked((uint)reader.GetInt64(12)), Application = (Voicecat.V1.OpusApplication)reader.GetInt32(13),
Fec = reader.GetInt32(14) != 0, ExpectedPacketLoss = checked((uint)reader.GetInt64(15)),
Dtx = reader.GetInt32(16) != 0, Complexity = checked((uint)reader.GetInt64(17)), Dred = reader.GetInt32(18) != 0
Dtx = reader.GetInt32(16) != 0, Complexity = checked((uint)reader.GetInt64(17)), Dred = reader.GetInt32(18) != 0,
PacketLossMode = (Voicecat.V1.PacketLossMode)reader.GetInt32(19)
}
});
}
+2 -2
View File
@@ -43,9 +43,9 @@ public sealed partial class AccountStore
byte[] salt = RandomNumberGenerator.GetBytes(16);
hash = Convert.ToHexString(salt).ToLowerInvariant() + ":" + Convert.ToHexString(ChannelDigest(password, salt)).ToLowerInvariant();
}
string[] columns = ["parent_id", "name", "topic", "max_users", "type", "sort_order", "audio_codec", "audio_mode", "audio_sample_rate", "audio_bitrate_bps", "audio_frame_ms", "audio_application", "audio_fec", "audio_expected_packet_loss", "audio_dtx", "audio_complexity", "audio_dred"];
string[] columns = ["parent_id", "name", "topic", "max_users", "type", "sort_order", "audio_codec", "audio_mode", "audio_sample_rate", "audio_bitrate_bps", "audio_frame_ms", "audio_application", "audio_fec", "audio_expected_packet_loss", "audio_dtx", "audio_complexity", "audio_dred", "audio_packet_loss_mode"];
var a = channel.Audio;
object[] values = [channel.ParentId, channel.Name, channel.Topic, channel.MaxUsers, (int)channel.Type, channel.Order, a.Codec, (int)a.Mode, a.SampleRate, a.BitrateBps, a.FrameMs, (int)a.Application, a.Fec, a.ExpectedPacketLoss, a.Dtx, a.Complexity, a.Dred];
object[] values = [channel.ParentId, channel.Name, channel.Topic, channel.MaxUsers, (int)channel.Type, channel.Order, a.Codec, (int)a.Mode, a.SampleRate, a.BitrateBps, a.FrameMs, (int)a.Application, a.Fec, a.ExpectedPacketLoss, a.Dtx, a.Complexity, a.Dred, (int)a.PacketLossMode];
for (int i = 0; i < columns.Length; i++) command.Parameters.AddWithValue("$" + columns[i], values[i]);
command.Parameters.AddWithValue("$hash", hash);
command.Parameters.AddWithValue("$id", channel.Id);
+1
View File
@@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS channels (
audio_dtx INTEGER NOT NULL DEFAULT 1,
audio_complexity INTEGER NOT NULL DEFAULT 5,
audio_dred INTEGER NOT NULL DEFAULT 0,
audio_packet_loss_mode INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS bans (
+3 -1
View File
@@ -15,13 +15,15 @@ internal sealed class MediaFanout : IDisposable
private int length;
private int index;
public bool TryStart(ReadOnlySpan<byte> packet, MediaRoute sender, MediaRoute[] recipients)
public bool TryStart(ReadOnlySpan<byte> packet, MediaRoute sender, MediaRoute[] recipients, out PacketLossSample? loss)
{
loss = null;
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;
loss = sender.Peer.PacketLoss.Observe(header.Sequence, sender.ChannelId, sender.LossMode);
source = sender;
routes = recipients;
index = 0;
+7 -3
View File
@@ -5,20 +5,23 @@ using System.Net.Sockets;
using System.Security.Cryptography;
using System.Threading.Channels;
using VoiceCat.Protocol;
using PacketLossMode = Voicecat.V1.PacketLossMode;
namespace VoiceCat.Server.Transport;
internal sealed class MediaPeer(byte[] token, MediaSessionCrypto crypto, SessionActivity? activity = null)
internal sealed class MediaPeer(byte[] token, MediaSessionCrypto crypto, SessionActivity? activity = null, TimeProvider? clock = null)
{
public byte[] Token { get; } = token;
public MediaSessionCrypto Crypto { get; } = crypto;
public SessionActivity Activity { get; } = activity ?? new(TimeProvider.System);
public PacketLossTracker PacketLoss { get; } = new(clock ?? TimeProvider.System);
// 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 record MediaRoute(MediaPeer Peer, uint ChannelId, bool Subscribed, bool Muted, bool Deafened, uint[] Sources,
PacketLossMode LossMode = PacketLossMode.PacketLossManual, Action<PacketLossSample>? LossUpdated = null);
internal sealed class MediaRelay : IAsyncDisposable
{
@@ -111,8 +114,9 @@ internal sealed class MediaRelay : IAsyncDisposable
}
continue;
}
if (!fanout.TryStart(input.AsSpan(0, length), source, current)) continue;
if (!fanout.TryStart(input.AsSpan(0, length), source, current, out PacketLossSample? loss)) continue;
source.Peer.Activity.Touch();
if (loss is { } sample) source.LossUpdated?.Invoke(sample);
while (fanout.TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint))
await SendAsync(packet, endpoint!).ConfigureAwait(false);
}
@@ -0,0 +1,93 @@
using Voicecat.V1;
namespace VoiceCat.Server.Transport;
internal readonly record struct PacketLossSample(uint MeasuredPercent, uint AppliedPercent);
internal sealed class PacketLossTracker(TimeProvider clock)
{
// Cumulative expected/received deltas follow the RTCP receiver-report model. Fixed
// checkpoints avoid allocating on the UDP owner while still permitting late packets to
// correct a later interval.
private readonly Checkpoint[] checkpoints = new Checkpoint[34];
private int next, count;
private bool initialized;
private ulong firstSequence, highestSequence, received;
private long lastCheckpoint;
private uint channelId;
private PacketLossMode mode;
private int lastApplied = -1;
internal PacketLossSample? Observe(ulong sequence, uint channel, PacketLossMode currentMode)
{
if (currentMode == PacketLossMode.PacketLossManual)
{
Reset(channel, currentMode);
return null;
}
if (channel != channelId || currentMode != mode) Reset(channel, currentMode);
long now = clock.GetTimestamp();
if (!initialized)
{
initialized = true;
firstSequence = highestSequence = sequence;
received = 1;
lastCheckpoint = now;
AddCheckpoint(now, 1, 1);
return null;
}
if (sequence > highestSequence) highestSequence = sequence;
received++;
if (clock.GetElapsedTime(lastCheckpoint, now) < TimeSpan.FromSeconds(1)) return null;
lastCheckpoint = now;
ulong expected = highestSequence - firstSequence + 1;
AddCheckpoint(now, expected, received);
int seconds = currentMode switch
{
PacketLossMode.PacketLossAutoFast => 3,
PacketLossMode.PacketLossAutoBalanced => 10,
PacketLossMode.PacketLossAutoStable => 30,
_ => throw new ArgumentOutOfRangeException(nameof(currentMode))
};
Checkpoint? baseline = null;
for (int i = 1; i < count; i++)
{
Checkpoint candidate = checkpoints[(next - 1 - i + checkpoints.Length) % checkpoints.Length];
if (clock.GetElapsedTime(candidate.Timestamp, now) >= TimeSpan.FromSeconds(seconds))
{ baseline = candidate; break; }
}
if (baseline is null) return null;
ulong expectedDelta = expected - baseline.Value.Expected;
ulong receivedDelta = received - baseline.Value.Received;
if (expectedDelta < 20) return null;
ulong lost = expectedDelta > receivedDelta ? expectedDelta - receivedDelta : 0;
uint measured = checked((uint)Math.Round(lost * 100.0 / expectedDelta, MidpointRounding.AwayFromZero));
uint applied = Math.Min(measured, 30);
if (applied == lastApplied) return null;
lastApplied = checked((int)applied);
return new(measured, applied);
}
private void AddCheckpoint(long timestamp, ulong expected, ulong receivedCount)
{
checkpoints[next] = new(timestamp, expected, receivedCount);
next = (next + 1) % checkpoints.Length;
if (count < checkpoints.Length) count++;
}
private void Reset(uint channel, PacketLossMode currentMode)
{
channelId = channel;
mode = currentMode;
next = count = 0;
initialized = false;
firstSequence = highestSequence = received = 0;
lastCheckpoint = 0;
lastApplied = -1;
}
private readonly record struct Checkpoint(long Timestamp, ulong Expected, ulong Received);
}
+16 -4
View File
@@ -5,6 +5,7 @@ using System.Security.Cryptography;
using System.Text;
using Google.Protobuf;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using VoiceCat.Server.Data;
using VoiceCat.Server.Transport;
using Voicecat.V1;
@@ -128,8 +129,10 @@ public sealed partial class VoiceServer : IAsyncDisposable
Reject(session, "Unsupported protocol version or banned address.");
break;
}
session.Media = new(RandomNumberGenerator.GetBytes(16), await session.Connection.TakeMediaCryptoAsync(shutdown.Token).ConfigureAwait(false), session.Activity);
session.Features.UnionWith(envelope.ClientHello.Features);
session.Media = new(RandomNumberGenerator.GetBytes(16), await session.Connection.TakeMediaCryptoAsync(shutdown.Token).ConfigureAwait(false), session.Activity, clock);
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)) };
hello.Features.Add(ProtocolFeatures.AdaptivePacketLoss);
if (allowGuests) hello.AuthMethods.Add("guest");
hello.AuthMethods.Add("password");
session.Connection.TrySend(new() { RequestId = envelope.RequestId, ServerHello = hello });
@@ -315,9 +318,17 @@ public sealed partial class VoiceServer : IAsyncDisposable
private void PublishMedia()
{
media.Publish(sessions.Values.Where(peer => peer.User is not null && !peer.Closing).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());
media.Publish(sessions.Values.Where(peer => peer.User is not null && !peer.Closing).Select(peer =>
{
uint channelId = peer.User!.ChannelId;
PacketLossMode mode = peer.Features.Contains(ProtocolFeatures.AdaptivePacketLoss)
? channels.First(channel => channel.Id == channelId).Audio.PacketLossMode
: PacketLossMode.PacketLossManual;
return new MediaRoute(peer.Media!, channelId, peer.User.VoiceSubscribed, peer.User.ServerMuted,
peer.User.SelfDeafened || peer.User.ServerDeafened, peer.User.Streams.Select(stream => stream.Ssrc).ToArray(), mode,
sample => peer.Connection.TrySend(new() { PacketLossUpdate = new()
{ ChannelId = channelId, MeasuredPercent = sample.MeasuredPercent, AppliedPercent = sample.AppliedPercent } }));
}).ToArray());
}
private void BroadcastUser(Session session) => Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Updated, User = session.User!.Clone() } });
@@ -415,6 +426,7 @@ public sealed partial class VoiceServer : IAsyncDisposable
public bool Closing { get; set; }
public string DepartureReason { get; set; } = "";
public bool HelloReceived { get; set; }
public HashSet<string> Features { get; } = new(StringComparer.Ordinal);
public User? User { get; set; }
public Permissions Permissions { get; set; } = new();
public MediaPeer? Media { get; set; }