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