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
+9 -4
View File
@@ -1,5 +1,6 @@
using Microsoft.Data.Sqlite;
using VoiceCat.Server.Data;
using Voicecat.V1;
namespace VoiceCat.Tests;
@@ -56,7 +57,7 @@ public sealed class AccountStoreTests
}
[Fact]
public void VersionTwoDatabaseMigratesDredAsDisabled()
public void VersionTwoDatabaseMigratesDredAndAdaptiveLossAsDisabled()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-v2-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
@@ -68,18 +69,22 @@ public sealed class AccountStoreTests
{
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "ALTER TABLE channels DROP COLUMN audio_dred; UPDATE server_meta SET value='2' WHERE key='schema_version';";
command.CommandText = "ALTER TABLE channels DROP COLUMN audio_dred; ALTER TABLE channels DROP COLUMN audio_packet_loss_mode; UPDATE server_meta SET value='2' WHERE key='schema_version';";
command.ExecuteNonQuery();
}
using (var migrated = new AccountStore(path))
Assert.All(migrated.LoadChannels(), channel => Assert.False(channel.Audio.Dred));
Assert.All(migrated.LoadChannels(), channel =>
{
Assert.False(channel.Audio.Dred);
Assert.Equal(PacketLossMode.PacketLossManual, channel.Audio.PacketLossMode);
});
using var verify = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
verify.Open();
using var query = verify.CreateCommand();
query.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
Assert.Equal("3", query.ExecuteScalar());
Assert.Equal("4", query.ExecuteScalar());
}
finally { Directory.Delete(directory, true); }
}
+20
View File
@@ -120,6 +120,26 @@ public class AudioEngineTests
Assert.True((sent[^1].Flags & VoiceFrameFlags.Marker) != 0);
}
[Fact]
public void AutomaticPacketLossUpdatesOnAudioOwnerAndManualStreamsIgnoreIt()
{
StreamInfo automatic = Stream();
automatic.Audio.PacketLossMode = PacketLossMode.PacketLossAutoBalanced;
using var engine = new AudioEngine((_, _, _, _) => true, false);
engine.AddLocalStream(automatic);
engine.SetExpectedPacketLoss(7);
Assert.Equal(20, engine.GetAppliedExpectedPacketLoss(1));
engine.ProcessCycle();
Assert.Equal(7, engine.GetAppliedExpectedPacketLoss(1));
engine.RemoveLocalStream(1);
engine.ProcessCycle();
engine.AddLocalStream(Stream());
engine.SetExpectedPacketLoss(4);
engine.ProcessCycle();
Assert.Equal(20, engine.GetAppliedExpectedPacketLoss(1));
}
[Theory]
[InlineData(5)] [InlineData(10)] [InlineData(20)] [InlineData(40)] [InlineData(60)]
public void RecoveryLookaheadTracksChannelFrameDuration(int frameMilliseconds)
+34 -1
View File
@@ -1,4 +1,5 @@
using VoiceCat.Server.Data;
using VoiceCat.Protocol;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
@@ -11,7 +12,8 @@ public class ChannelManagementTests
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
await store.CreateAccountAsync("Admin", "secret", true);
Client client = await fixture.ConnectAsync();
client.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
var hello = new ClientHello { ProtoVersion = 2 }; hello.Features.Add(ProtocolFeatures.AdaptivePacketLoss);
client.Send(new() { ClientHello = hello });
await client.ReadUntilAsync(e => e.ServerHello is not null);
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } });
Assert.True((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
@@ -114,6 +116,7 @@ public class ChannelManagementTests
music.Audio.Dtx = true;
music.Audio.Fec = true;
music.Audio.Dred = true;
music.Audio.PacketLossMode = PacketLossMode.PacketLossAutoStable;
music.Audio.Complexity = 10;
Assert.True(await ResultAsync(admin, new() { EditChannel = new() { Channel = music } }));
}
@@ -126,6 +129,36 @@ public class ChannelManagementTests
Assert.True(audio.Dtx);
Assert.True(audio.Fec);
Assert.True(audio.Dred);
Assert.Equal(PacketLossMode.PacketLossAutoStable, audio.PacketLossMode);
Assert.Equal(10U, audio.Complexity);
}
[Fact]
public async Task LegacyAdministratorEditsPreserveAutomaticPacketLossMode()
{
await using var fixture = new ServerFixture();
await using var current = await AdminAsync(fixture);
Channel music;
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
{
music = store.LoadChannels().Single(channel => channel.Name == "Music Room");
music.Audio.PacketLossMode = PacketLossMode.PacketLossAutoBalanced;
}
Assert.True(await ResultAsync(current, new() { EditChannel = new() { Channel = music } }));
await using Client legacy = await fixture.ConnectAsync();
legacy.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
await legacy.ReadUntilAsync(envelope => envelope.ServerHello is not null);
legacy.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } });
Assert.True((await legacy.ReadUntilAsync(envelope => envelope.AuthResult is not null)).AuthResult.Ok);
await legacy.ReadUntilAsync(envelope => envelope.ServerState is not null);
music.Audio.PacketLossMode = PacketLossMode.PacketLossManual;
music.Audio.ExpectedPacketLoss = 23;
Assert.True(await ResultAsync(legacy, new() { EditChannel = new() { Channel = music } }));
using var verify = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
AudioConfig saved = verify.LoadChannels().Single(channel => channel.Id == music.Id).Audio;
Assert.Equal(PacketLossMode.PacketLossAutoBalanced, saved.PacketLossMode);
Assert.Equal(23U, saved.ExpectedPacketLoss);
}
}
+9
View File
@@ -4,6 +4,15 @@ namespace VoiceCat.Tests;
public sealed class CodecTests
{
[Fact]
public void ExpectedPacketLossCanChangeWithoutRecreatingEncoder()
{
using var encoder = new OpusEncoder(new() { ExpectedPacketLossPercent = 5 });
encoder.SetExpectedPacketLossPercent(17);
Assert.Equal(17, encoder.ExpectedPacketLossPercent);
Assert.Throws<ArgumentOutOfRangeException>(() => encoder.SetExpectedPacketLossPercent(101));
}
public static IEnumerable<object[]> Formats()
{
foreach (int rate in new[] { 8000, 12000, 16000, 24000, 48000 })
+1 -1
View File
@@ -93,7 +93,7 @@ public sealed class MediaFanoutTests(ITestOutputHelper output)
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.");
if (!fanout.TryStart(packet, routes[0], routes, out _)) 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.");
+40
View File
@@ -10,6 +10,34 @@ namespace VoiceCat.Tests;
public sealed class MediaRelayTests
{
[Fact]
public async Task AutomaticModeReportsAuthenticatedSenderUplinkLossWithCap()
{
var clock = new ManualClock();
await using var fixture = new ServerFixture(timeProvider: clock);
Client admin = await ChannelManagementTests.AdminAsync(fixture);
using (var store = new VoiceCat.Server.Data.AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
{
Channel lobby = store.LoadChannels().Single(channel => channel.Id == 1);
lobby.Audio.PacketLossMode = PacketLossMode.PacketLossAutoFast;
Assert.True(await ChannelManagementTests.ResultAsync(admin, new() { EditChannel = new() { Channel = lobby } }));
}
await using var alice = await VoicePeer.AttachAsync(fixture, admin);
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
StreamAnnounceResult stream = await alice.AnnounceAsync(StreamKind.StreamMic);
await alice.SendAsync(alice.Seal(stream.Ssrc, [1]));
await bob.ReceiveVoiceAsync();
for (int i = 0; i < 99; i++) alice.Seal(stream.Ssrc, [2]);
clock.Advance(TimeSpan.FromSeconds(3));
await alice.SendAsync(alice.Seal(stream.Ssrc, [3]));
PacketLossUpdate update = (await admin.ReadUntilAsync(envelope => envelope.PacketLossUpdate is not null)).PacketLossUpdate;
Assert.Equal(1U, update.ChannelId);
Assert.Equal(99U, update.MeasuredPercent);
Assert.Equal(30U, update.AppliedPercent);
}
[Fact]
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
{
@@ -162,6 +190,10 @@ public sealed class MediaRelayTests
{
Client client = await fixture.ConnectAsync();
await client.LoginAsync(nickname);
return await AttachAsync(fixture, client);
}
internal static async Task<VoicePeer> AttachAsync(ServerFixture fixture, Client client)
{
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);
@@ -217,4 +249,12 @@ public sealed class MediaRelayTests
}
public async ValueTask DisposeAsync() { udp.Dispose(); crypto.Dispose(); await Client.DisposeAsync(); }
}
private sealed class ManualClock : TimeProvider
{
private long timestamp;
public override long TimestampFrequency => 1_000;
public override long GetTimestamp() => Volatile.Read(ref timestamp);
internal void Advance(TimeSpan duration) => Interlocked.Add(ref timestamp, (long)duration.TotalMilliseconds);
}
}
@@ -0,0 +1,64 @@
using VoiceCat.Server.Transport;
using Voicecat.V1;
namespace VoiceCat.Tests;
public class PacketLossTrackerTests
{
[Fact]
public void FastModeReportsWindowedLossAndCapsExtremeValues()
{
var clock = new ManualClock();
var tracker = new PacketLossTracker(clock);
Assert.Null(tracker.Observe(0, 1, PacketLossMode.PacketLossAutoFast));
for (ulong sequence = 1; sequence <= 100; sequence++)
if (sequence % 10 != 0) Assert.Null(tracker.Observe(sequence, 1, PacketLossMode.PacketLossAutoFast));
clock.Advance(TimeSpan.FromSeconds(3));
PacketLossSample sample = Assert.IsType<PacketLossSample>(tracker.Observe(101, 1, PacketLossMode.PacketLossAutoFast));
Assert.Equal(10U, sample.MeasuredPercent);
Assert.Equal(10U, sample.AppliedPercent);
tracker = new(clock);
Assert.Null(tracker.Observe(0, 1, PacketLossMode.PacketLossAutoFast));
clock.Advance(TimeSpan.FromSeconds(3));
sample = Assert.IsType<PacketLossSample>(tracker.Observe(100, 1, PacketLossMode.PacketLossAutoFast));
Assert.Equal(99U, sample.MeasuredPercent);
Assert.Equal(30U, sample.AppliedPercent);
}
[Fact]
public void ReorderingDoesNotCountAsLossAndStableModeWaitsForItsWindow()
{
var clock = new ManualClock();
var tracker = new PacketLossTracker(clock);
tracker.Observe(0, 1, PacketLossMode.PacketLossAutoStable);
tracker.Observe(2, 1, PacketLossMode.PacketLossAutoStable);
tracker.Observe(1, 1, PacketLossMode.PacketLossAutoStable);
for (ulong sequence = 3; sequence <= 30; sequence++) tracker.Observe(sequence, 1, PacketLossMode.PacketLossAutoStable);
clock.Advance(TimeSpan.FromSeconds(29));
Assert.Null(tracker.Observe(31, 1, PacketLossMode.PacketLossAutoStable));
clock.Advance(TimeSpan.FromSeconds(1));
PacketLossSample sample = Assert.IsType<PacketLossSample>(tracker.Observe(32, 1, PacketLossMode.PacketLossAutoStable));
Assert.Equal(0U, sample.MeasuredPercent);
}
[Fact]
public void ManualModeAndChannelChangesResetMeasurement()
{
var clock = new ManualClock();
var tracker = new PacketLossTracker(clock);
tracker.Observe(0, 1, PacketLossMode.PacketLossAutoFast);
clock.Advance(TimeSpan.FromSeconds(3));
Assert.Null(tracker.Observe(100, 2, PacketLossMode.PacketLossAutoFast));
Assert.Null(tracker.Observe(101, 2, PacketLossMode.PacketLossManual));
}
private sealed class ManualClock : TimeProvider
{
private long timestamp;
public override long TimestampFrequency => 1_000;
public override long GetTimestamp() => timestamp;
internal void Advance(TimeSpan duration) => timestamp += (long)duration.TotalMilliseconds;
}
}
+12 -3
View File
@@ -5,6 +5,7 @@ using System.Net.Sockets;
using VoiceCat.Crypto;
using VoiceCat.Server;
using VoiceCat.Server.Transport;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Tests;
@@ -135,13 +136,21 @@ public sealed class ServerTests
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;
while (await messages.MoveNextAsync())
{
if (messages.Current.AuthResult?.Ok == true) Authentication = messages.Current.AuthResult;
if (predicate(messages.Current)) return messages.Current;
}
throw new IOException("Connection ended before the expected message.");
}
public async Task<User> LoginAsync(string nickname)
{
Send(new() { RequestId = 1, ClientHello = new() { ProtoVersion = 2, ClientName = "Managed test" } });
Assert.Equal(1UL, (await ReadUntilAsync(e => e.ServerHello is not null)).RequestId);
var hello = new ClientHello { ProtoVersion = 2, ClientName = "Managed test" };
hello.Features.Add(ProtocolFeatures.AdaptivePacketLoss);
Send(new() { RequestId = 1, ClientHello = hello });
Envelope response = await ReadUntilAsync(e => e.ServerHello is not null);
Assert.Equal(1UL, response.RequestId);
Assert.Contains(ProtocolFeatures.AdaptivePacketLoss, response.ServerHello.Features);
Send(new() { RequestId = 2, AuthRequest = new() { Guest = new() { Nickname = nickname } } });
AuthResult auth = (await ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
Authentication = auth;
@@ -87,7 +87,7 @@ public class WindowsManagedClientTests
using var client = new Client("Admin", "test", tofuStorePath: Path.Combine(fixture.Directory, "admin.pins"));
await Login(client, fixture, true);
ChannelInfo music = client.ListChannels().Single(c => c.Name == "Music Room");
var expected = new AudioConfigInfo(0, true, 48_000, 128_000, 20, 1, true, 15, true, 10, true);
var expected = new AudioConfigInfo(0, true, 48_000, 128_000, 20, 1, true, 15, true, 10, true, VcPacketLossMode.AutoBalanced);
var edit = new ChannelEditInfo(music.Id, music.ParentId, music.Name, music.Topic,
music.PasswordProtected, null, music.MaxUsers, music.SortOrder, expected);