Add adaptive packet loss handling
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user