diff --git a/PROGRESS.md b/PROGRESS.md index 24954a3..38e2441 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -25,13 +25,15 @@ verified on an iPhone 16 Pro Max with a two-channel AVAudioEngine input and dist samples; the managed Apple binding requires native use of its otherwise-unmapped stereo polar pattern constant. -SQLite schema v3 persists channel DRED settings and migrates existing v1/v2 databases with DRED -disabled until explicitly enabled. +SQLite schema v4 persists DRED and the channel packet-loss mode. Manual loss remains the default; +automatic Fast/Balanced/Stable modes measure each sender's authenticated UDP uplink at the server, +cap the applied Opus hint at 30%, and feed it back over TLS. ## Release gates - Run real multi-person calls on Windows, macOS, and physical iOS hardware, including adaptive - 20/40/60 ms buffering, duration-aware DRED/FEC, and mismatched input/output endpoints. + 20/40/60 ms buffering, duration-aware DRED/FEC, automatic packet-loss feedback, and mismatched + input/output endpoints. - Complete NVDA and VoiceOver navigation/announcement passes. - Exercise iOS background/lock, interruption, Bluetooth, route-change, ReplayKit, and iOS 27 ScreenCaptureKit paths on devices. Complete extended mono/stereo/voice-chat switching while diff --git a/clients/apple/VoiceCat.Mac/AdministrationWindowController.cs b/clients/apple/VoiceCat.Mac/AdministrationWindowController.cs index 50ffa59..699d134 100644 --- a/clients/apple/VoiceCat.Mac/AdministrationWindowController.cs +++ b/clients/apple/VoiceCat.Mac/AdministrationWindowController.cs @@ -71,8 +71,8 @@ internal sealed class AdministrationWindowController : NSWindowController private async void ServerMute(object? sender, EventArgs args) { User? user = User(); if (user is not null) Show(await client.SetServerMuteAsync(user.Id, !user.ServerMuted, user.ServerDeafened)); } private async void ServerDeafen(object? sender, EventArgs args) { User? user = User(); if (user is not null) Show(await client.SetServerMuteAsync(user.Id, user.ServerMuted, !user.ServerDeafened)); } private void Tune(object? sender, EventArgs args) { User? user = User(); if (user is not null) PerUserTuning.Run(client, user); } - private async void CreateChannel(object? sender, EventArgs args) { ChannelEdit? edit = ChannelEditor.Run(null, client.Channels); if (edit is not null) { Show(await client.CreateChannelAsync(edit.Channel, edit.Password)); Refresh(); } } - private async void EditChannel(object? sender, EventArgs args) { Channel? channel = Channel(); if (channel is null) return; ChannelEdit? edit = ChannelEditor.Run(channel, client.Channels); if (edit is not null) { Show(await client.EditChannelAsync(edit.Channel, edit.Password)); Refresh(); } } + private async void CreateChannel(object? sender, EventArgs args) { ChannelEdit? edit = ChannelEditor.Run(null, client.Channels, client.SupportsAdaptivePacketLoss); if (edit is not null) { Show(await client.CreateChannelAsync(edit.Channel, edit.Password)); Refresh(); } } + private async void EditChannel(object? sender, EventArgs args) { Channel? channel = Channel(); if (channel is null) return; ChannelEdit? edit = ChannelEditor.Run(channel, client.Channels, client.SupportsAdaptivePacketLoss); if (edit is not null) { Show(await client.EditChannelAsync(edit.Channel, edit.Password)); Refresh(); } } private async void DeleteChannel(object? sender, EventArgs args) { Channel? channel = Channel(); if (channel is null || channel.Id == 1) return; diff --git a/clients/apple/VoiceCat.Mac/ChannelEditor.cs b/clients/apple/VoiceCat.Mac/ChannelEditor.cs index c2eb07d..1fe94b1 100644 --- a/clients/apple/VoiceCat.Mac/ChannelEditor.cs +++ b/clients/apple/VoiceCat.Mac/ChannelEditor.cs @@ -7,7 +7,7 @@ namespace VoiceCat.Mac; internal static class ChannelEditor { - internal static ChannelEdit? Run(Channel? existing, IReadOnlyList channels) + internal static ChannelEdit? Run(Channel? existing, IReadOnlyList channels, bool supportsAdaptivePacketLoss) { var view = new NSView(new CGRect(0, 0, 520, 500)); var name = Field(existing?.Name ?? "", 360); var topic = Field(existing?.Topic ?? "", 330); @@ -25,13 +25,21 @@ internal static class ChannelEditor Add(view, "Maximum users (0=none)", maximum, 240); Add(view, "Sort order", order, 210); Add(view, "Password", password, 180); Add(view, "Mode", mode, 150); Add(view, "Sample rate", rate, 120); Add(view, "Frame ms", frame, 90); Add(view, "Application", application, 60); Add(view, "Bitrate", bitrate, 30); - var advanced = new NSView(new CGRect(0, 0, 520, 105)); - var fec = Check("In-band FEC", existing?.Audio?.Fec ?? true, 0, 75); var dtx = Check("DTX", existing?.Audio?.Dtx ?? true, 130, 75); - var dred = Check("Deep redundancy", existing?.Audio?.Dred ?? false, 230, 75); - var loss = Field((existing?.Audio?.ExpectedPacketLoss ?? 5).ToString(), 40); loss.Frame = new CGRect(155, 40, 100, 24); - var complexity = Field((existing?.Audio?.Complexity ?? 10).ToString(), 10); complexity.Frame = new CGRect(155, 10, 100, 24); - advanced.AddSubview(fec); advanced.AddSubview(dtx); advanced.AddSubview(dred); Add(advanced, "Packet loss %", loss, 40); Add(advanced, "Complexity 0–10", complexity, 10); - var container = new NSView(new CGRect(0, 0, 520, 620)); view.Frame = new CGRect(0, 110, 520, 500); container.AddSubview(view); container.AddSubview(advanced); + var advanced = new NSView(new CGRect(0, 0, 520, 175)); + var automaticLoss = Check("Automatic packet loss", existing?.Audio?.PacketLossMode != PacketLossMode.PacketLossManual, 155, 140); + automaticLoss.Frame = new CGRect(155, 140, 200, 24); + automaticLoss.Enabled = supportsAdaptivePacketLoss; + var lossSpeed = Picker(["Fast", "Balanced", "Stable"], 105); + lossSpeed.SelectItem(existing?.Audio?.PacketLossMode switch { PacketLossMode.PacketLossAutoFast => 0, PacketLossMode.PacketLossAutoStable => 2, _ => 1 }); + var loss = Field((existing?.Audio?.ExpectedPacketLoss ?? 5).ToString(), 70); loss.Frame = new CGRect(155, 70, 100, 24); + var complexity = Field((existing?.Audio?.Complexity ?? 10).ToString(), 40); complexity.Frame = new CGRect(155, 40, 100, 24); + var fec = Check("In-band FEC", existing?.Audio?.Fec ?? true, 0, 10); var dtx = Check("DTX", existing?.Audio?.Dtx ?? true, 130, 10); + var dred = Check("Deep redundancy", existing?.Audio?.Dred ?? false, 230, 10); + void UpdateLossControls() { loss.Enabled = !On(automaticLoss); lossSpeed.Enabled = supportsAdaptivePacketLoss && On(automaticLoss); } + automaticLoss.Activated += (_, _) => UpdateLossControls(); UpdateLossControls(); + advanced.AddSubview(automaticLoss); Add(advanced, "Adaptation speed", lossSpeed, 105); Add(advanced, "Packet loss %", loss, 70); Add(advanced, "Complexity 0–10", complexity, 40); + advanced.AddSubview(fec); advanced.AddSubview(dtx); advanced.AddSubview(dred); + var container = new NSView(new CGRect(0, 0, 520, 690)); view.Frame = new CGRect(0, 180, 520, 500); container.AddSubview(view); container.AddSubview(advanced); var alert = new NSAlert { MessageText = existing is null ? "Create channel" : "Edit channel", AccessoryView = container }; alert.AddButton(existing is null ? "Create" : "Save"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return null; if (string.IsNullOrWhiteSpace(name.StringValue) || !uint.TryParse(maximum.StringValue, out uint max) || !int.TryParse(order.StringValue, out int sort) || @@ -45,7 +53,9 @@ internal static class ChannelEditor Type = type.IndexOfSelectedItem == 1 ? ChannelType.ChannelTemporary : ChannelType.ChannelPermanent, Audio = new AudioConfig { Codec = 0, Mode = mode.IndexOfSelectedItem == 1 ? ChannelMode.ModeStereo : ChannelMode.ModeMono, SampleRate = sampleRate, FrameMs = frameMs, Application = (OpusApplication)(int)application.IndexOfSelectedItem, BitrateBps = bitrateValue, - Fec = On(fec), Dtx = On(dtx), Dred = On(dred), ExpectedPacketLoss = packetLoss, Complexity = complexityValue } + Fec = On(fec), Dtx = On(dtx), Dred = On(dred), ExpectedPacketLoss = packetLoss, Complexity = complexityValue, + PacketLossMode = On(automaticLoss) ? lossSpeed.IndexOfSelectedItem switch + { 0 => PacketLossMode.PacketLossAutoFast, 2 => PacketLossMode.PacketLossAutoStable, _ => PacketLossMode.PacketLossAutoBalanced } : PacketLossMode.PacketLossManual } }, password.StringValue); } diff --git a/clients/apple/VoiceCat.iOS/AppModel.cs b/clients/apple/VoiceCat.iOS/AppModel.cs index d434316..8b3842a 100644 --- a/clients/apple/VoiceCat.iOS/AppModel.cs +++ b/clients/apple/VoiceCat.iOS/AppModel.cs @@ -43,6 +43,7 @@ internal sealed class AppModel internal IosSettings Settings => settings; internal VoiceCatClient? Client => client; internal bool IsConnected => client?.State == ClientConnectionState.Connected; + internal bool SupportsAdaptivePacketLoss => client?.SupportsAdaptivePacketLoss == true; internal bool IsConnecting { get; private set; } internal bool VoiceJoined => microphoneStream != 0; internal bool ScreenSharing => broadcast?.IsActive == true; diff --git a/clients/apple/VoiceCat.iOS/ChannelEditorController.cs b/clients/apple/VoiceCat.iOS/ChannelEditorController.cs index 8d88dbf..422366d 100644 --- a/clients/apple/VoiceCat.iOS/ChannelEditorController.cs +++ b/clients/apple/VoiceCat.iOS/ChannelEditorController.cs @@ -10,17 +10,21 @@ internal sealed class ChannelEditorController : UIViewController private readonly UITextField name = UiHelpers.Field("Channel name"), topic = UiHelpers.Field("Topic (optional)"), maximum = UiHelpers.Field("Maximum users, 0 is unlimited"), order = UiHelpers.Field("Sort order"), password = UiHelpers.Field("Password; blank preserves existing", true), bitrate = UiHelpers.Field("Bitrate in bits per second"), loss = UiHelpers.Field("Expected packet loss percent"), complexity = UiHelpers.Field("Complexity 0 through 10"); private readonly UISegmentedControl type = new(["Permanent", "Temporary"]), mode = new(["Mono", "Stereo"]), application = new(["VoIP", "Audio", "Low delay"]); private readonly UIButton parent = UIButton.FromType(UIButtonType.System), sampleRate = UIButton.FromType(UIButtonType.System), frame = UIButton.FromType(UIButtonType.System); - private readonly UISwitch fec = new(), dtx = new(), dred = new(); private uint parentId; + private readonly UIButton lossSpeed = UIButton.FromType(UIButtonType.System); + private readonly UISwitch automaticLoss = new(), fec = new(), dtx = new(), dred = new(); private uint parentId; internal ChannelEditorController(AppModel model, Channel? existing) { this.model = model; this.existing = existing; Title = existing is null ? "New Channel" : "Edit Channel"; } public override void ViewDidLoad() { base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemGroupedBackground; UIScrollView scroll = new() { TranslatesAutoresizingMaskIntoConstraints = false }; UIStackView stack = new() { Axis = UILayoutConstraintAxis.Vertical, Spacing = 12, TranslatesAutoresizingMaskIntoConstraints = false }; View.AddSubview(scroll); scroll.AddSubview(stack); NSLayoutConstraint.ActivateConstraints([scroll.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), scroll.BottomAnchor.ConstraintEqualTo(View.BottomAnchor), scroll.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor), scroll.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor), stack.TopAnchor.ConstraintEqualTo(scroll.ContentLayoutGuide.TopAnchor, 16), stack.BottomAnchor.ConstraintEqualTo(scroll.ContentLayoutGuide.BottomAnchor, -24), stack.LeadingAnchor.ConstraintEqualTo(scroll.FrameLayoutGuide.LeadingAnchor, 20), stack.TrailingAnchor.ConstraintEqualTo(scroll.FrameLayoutGuide.TrailingAnchor, -20)]); - foreach (UIView row in new UIView[] { name, topic, PickerRow("Parent channel", parent), PickerRow("Channel type", type), maximum, order, password, PickerRow("Channel mode", mode), PickerRow("Sample rate", sampleRate), PickerRow("Frame duration", frame), PickerRow("Opus application", application), bitrate, loss, complexity, SwitchRow("Forward error correction", fec), SwitchRow("Discontinuous transmission", dtx), SwitchRow("Deep redundancy", dred) }) stack.AddArrangedSubview(row); + foreach (UIView row in new UIView[] { name, topic, PickerRow("Parent channel", parent), PickerRow("Channel type", type), maximum, order, password, PickerRow("Channel mode", mode), PickerRow("Sample rate", sampleRate), PickerRow("Frame duration", frame), PickerRow("Opus application", application), bitrate, SwitchRow("Automatic packet loss", automaticLoss), PickerRow("Adaptation speed", lossSpeed), loss, complexity, SwitchRow("Forward error correction", fec), SwitchRow("Discontinuous transmission", dtx), SwitchRow("Deep redundancy", dred) }) stack.AddArrangedSubview(row); maximum.KeyboardType = order.KeyboardType = bitrate.KeyboardType = loss.KeyboardType = complexity.KeyboardType = UIKeyboardType.NumberPad; parent.Menu = UIMenu.Create(model.Channels.Where(channel => channel.Id != existing?.Id).OrderBy(channel => channel.Name).Select(channel => UIAction.Create(channel.Name, null, null, _ => { parentId = channel.Id; parent.SetTitle(channel.Name, UIControlState.Normal); })).Prepend(UIAction.Create("Root", null, null, _ => { parentId = 0; parent.SetTitle("Root", UIControlState.Normal); })).ToArray()); parent.ShowsMenuAsPrimaryAction = true; sampleRate.Menu = Choice(sampleRate, ["48000"]); sampleRate.ShowsMenuAsPrimaryAction = true; frame.Menu = Choice(frame, ["5", "10", "20", "40", "60"]); frame.ShowsMenuAsPrimaryAction = true; + lossSpeed.Menu = Choice(lossSpeed, ["Fast", "Balanced", "Stable"]); lossSpeed.ShowsMenuAsPrimaryAction = true; + automaticLoss.Enabled = model.SupportsAdaptivePacketLoss; + automaticLoss.ValueChanged += (_, _) => UpdateLossControls(); Load(); NavigationItem.RightBarButtonItem = new("Save", UIBarButtonItemStyle.Done, async (_, _) => await Save()); } private void Load() @@ -30,6 +34,9 @@ internal sealed class ChannelEditorController : UIViewController parent.SetTitle(model.Channels.FirstOrDefault(value => value.Id == parentId)?.Name ?? "Root", UIControlState.Normal); type.SelectedSegment = channel.Type == ChannelType.ChannelTemporary ? 1 : 0; mode.SelectedSegment = channel.Audio?.Mode == ChannelMode.ModeStereo ? 1 : 0; sampleRate.SetTitle((channel.Audio?.SampleRate ?? 48000).ToString(), UIControlState.Normal); frame.SetTitle((channel.Audio?.FrameMs ?? 20).ToString(), UIControlState.Normal); application.SelectedSegment = (nint)(channel.Audio?.Application ?? OpusApplication.OpusVoip); bitrate.Text = (channel.Audio?.BitrateBps ?? 64000).ToString(); loss.Text = (channel.Audio?.ExpectedPacketLoss ?? 5).ToString(); complexity.Text = (channel.Audio?.Complexity ?? 10).ToString(); fec.On = channel.Audio?.Fec ?? true; dtx.On = channel.Audio?.Dtx ?? false; dred.On = channel.Audio?.Dred ?? false; + automaticLoss.On = channel.Audio?.PacketLossMode != PacketLossMode.PacketLossManual; + lossSpeed.SetTitle(channel.Audio?.PacketLossMode switch { PacketLossMode.PacketLossAutoFast => "Fast", PacketLossMode.PacketLossAutoStable => "Stable", _ => "Balanced" }, UIControlState.Normal); + UpdateLossControls(); } private async Task Save() { @@ -37,11 +44,13 @@ internal sealed class ChannelEditorController : UIViewController { if (string.IsNullOrWhiteSpace(name.Text) || !uint.TryParse(maximum.Text, out uint max) || !int.TryParse(order.Text, out int sort) || !uint.TryParse(sampleRate.Title(UIControlState.Normal), out uint rate) || !uint.TryParse(frame.Title(UIControlState.Normal), out uint frameMs) || !uint.TryParse(bitrate.Text, out uint bits) || !uint.TryParse(loss.Text, out uint packetLoss) || !uint.TryParse(complexity.Text, out uint cpu) || packetLoss > 100 || cpu > 10) throw new ArgumentException("Enter valid channel and Opus settings."); var channel = new Channel { Id = existing?.Id ?? 0, ParentId = parentId, Name = name.Text.Trim(), Topic = topic.Text?.Trim() ?? "", MaxUsers = max, Order = sort, Type = type.SelectedSegment == 1 ? ChannelType.ChannelTemporary : ChannelType.ChannelPermanent, - Audio = new AudioConfig { Codec = 0, Mode = mode.SelectedSegment == 1 ? ChannelMode.ModeStereo : ChannelMode.ModeMono, SampleRate = rate, FrameMs = frameMs, Application = (OpusApplication)(int)application.SelectedSegment, BitrateBps = bits, ExpectedPacketLoss = packetLoss, Complexity = cpu, Fec = fec.On, Dtx = dtx.On, Dred = dred.On } }; + Audio = new AudioConfig { Codec = 0, Mode = mode.SelectedSegment == 1 ? ChannelMode.ModeStereo : ChannelMode.ModeMono, SampleRate = rate, FrameMs = frameMs, Application = (OpusApplication)(int)application.SelectedSegment, BitrateBps = bits, ExpectedPacketLoss = packetLoss, Complexity = cpu, Fec = fec.On, Dtx = dtx.On, Dred = dred.On, + PacketLossMode = automaticLoss.On ? lossSpeed.Title(UIControlState.Normal) switch { "Fast" => PacketLossMode.PacketLossAutoFast, "Stable" => PacketLossMode.PacketLossAutoStable, _ => PacketLossMode.PacketLossAutoBalanced } : PacketLossMode.PacketLossManual } }; GenericResult result = await model.RunAdminAsync(client => existing is null ? client.CreateChannelAsync(channel, password.Text ?? "") : client.EditChannelAsync(channel, password.Text ?? "")); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } } + private void UpdateLossControls() { loss.Enabled = !automaticLoss.On; lossSpeed.Enabled = model.SupportsAdaptivePacketLoss && automaticLoss.On; } private static UIView PickerRow(string label, UIView control) { var row = new UIStackView { Axis = UILayoutConstraintAxis.Horizontal, Distribution = UIStackViewDistribution.FillEqually, Spacing = 8 }; var text = new UILabel { Text = label }; control.AccessibilityLabel = label; row.AddArrangedSubview(text); row.AddArrangedSubview(control); return row; } private static UIView SwitchRow(string label, UISwitch toggle) { var row = new UIStackView { Axis = UILayoutConstraintAxis.Horizontal, Distribution = UIStackViewDistribution.EqualSpacing }; row.AddArrangedSubview(new UILabel { Text = label }); toggle.AccessibilityLabel = label; row.AddArrangedSubview(toggle); return row; } private static UIMenu Choice(UIButton button, string[] values) => UIMenu.Create(values.Select(value => UIAction.Create(value, null, null, _ => button.SetTitle(value, UIControlState.Normal))).ToArray()); diff --git a/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs b/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs index f80d965..74acfad 100644 --- a/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs @@ -26,6 +26,8 @@ public sealed class ChannelEditDialog : Form private NumericUpDown _numFrameMs = null!; private ComboBox _cboApplication = null!; private CheckBox _chkFec = null!; + private CheckBox _chkAutomaticLoss = null!; + private ComboBox _cboLossSpeed = null!; private NumericUpDown _numExpectedLoss = null!; private CheckBox _chkDtx = null!; private CheckBox _chkDred = null!; @@ -33,11 +35,14 @@ public sealed class ChannelEditDialog : Form public ChannelEditInfo? Result { get; private set; } - public ChannelEditDialog(IEnumerable channels, ChannelEditInfo? existing = null) + private readonly bool _supportsAdaptivePacketLoss; + + public ChannelEditDialog(IEnumerable channels, ChannelEditInfo? existing = null, bool supportsAdaptivePacketLoss = true) { _isCreate = existing is null; _editingId = existing?.Id ?? 0; _channels = channels.Where(c => c.Id != _editingId).ToList(); + _supportsAdaptivePacketLoss = supportsAdaptivePacketLoss; Text = _isCreate ? "Create channel" : "Edit channel"; FormBorderStyle = FormBorderStyle.FixedDialog; @@ -269,6 +274,31 @@ public sealed class ChannelEditDialog : Form page.Controls.Add(_cboApplication); y += 34; + _chkAutomaticLoss = new CheckBox + { + Text = "&Automatic packet loss", + Location = new Point(inputX, y), + AutoSize = true, + Checked = audio.PacketLossMode != VcPacketLossMode.Manual, + Enabled = _supportsAdaptivePacketLoss, + TabIndex = 15, + }; + page.Controls.Add(_chkAutomaticLoss); + y += 30; + + AddLabel(page, "Adaptation &speed:", 12, y, labelWidth); + _cboLossSpeed = new ComboBox + { + Location = new Point(inputX, y - 2), + Size = new Size(160, 23), + DropDownStyle = ComboBoxStyle.DropDownList, + TabIndex = 16, + }; + _cboLossSpeed.Items.AddRange(["Fast", "Balanced", "Stable"]); + _cboLossSpeed.SelectedIndex = audio.PacketLossMode == VcPacketLossMode.Manual ? 1 : (int)audio.PacketLossMode - 1; + page.Controls.Add(_cboLossSpeed); + y += 34; + AddLabel(page, "Expected packet loss (%):", 12, y, labelWidth); _numExpectedLoss = new NumericUpDown { @@ -277,9 +307,16 @@ public sealed class ChannelEditDialog : Form Minimum = 0, Maximum = 100, Value = audio.ExpectedPacketLoss, - TabIndex = 15, + TabIndex = 17, }; page.Controls.Add(_numExpectedLoss); + void UpdateLossControls() + { + _numExpectedLoss.Enabled = !_chkAutomaticLoss.Checked; + _cboLossSpeed.Enabled = _supportsAdaptivePacketLoss && _chkAutomaticLoss.Checked; + } + _chkAutomaticLoss.CheckedChanged += (_, _) => UpdateLossControls(); + UpdateLossControls(); y += 34; AddLabel(page, "Com&plexity (0–10):", 12, y, labelWidth); @@ -290,7 +327,7 @@ public sealed class ChannelEditDialog : Form Minimum = 0, Maximum = 10, Value = audio.Complexity, - TabIndex = 16, + TabIndex = 18, }; page.Controls.Add(_numComplexity); y += 34; @@ -301,7 +338,7 @@ public sealed class ChannelEditDialog : Form Location = new Point(inputX, y), AutoSize = true, Checked = audio.Fec, - TabIndex = 17, + TabIndex = 19, }; page.Controls.Add(_chkFec); y += 28; @@ -312,7 +349,7 @@ public sealed class ChannelEditDialog : Form Location = new Point(inputX, y), AutoSize = true, Checked = audio.Dtx, - TabIndex = 18, + TabIndex = 20, }; page.Controls.Add(_chkDtx); y += 28; @@ -323,7 +360,7 @@ public sealed class ChannelEditDialog : Form Location = new Point(inputX, y), AutoSize = true, Checked = audio.Dred, - TabIndex = 19, + TabIndex = 21, }; page.Controls.Add(_chkDred); } @@ -375,7 +412,8 @@ public sealed class ChannelEditDialog : Form ExpectedPacketLoss: (uint)_numExpectedLoss.Value, Dtx: _chkDtx.Checked, Complexity: (uint)_numComplexity.Value, - Dred: _chkDred.Checked); + Dred: _chkDred.Checked, + PacketLossMode: _chkAutomaticLoss.Checked ? (VcPacketLossMode)(_cboLossSpeed.SelectedIndex + 1) : VcPacketLossMode.Manual); Result = new ChannelEditInfo( Id: _editingId, diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs index fe4d3fb..162d169 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs @@ -1132,7 +1132,7 @@ public partial class MainForm : Form private void CreateChannel() { - using var dlg = new ChannelEditDialog(_channels); + using var dlg = new ChannelEditDialog(_channels, supportsAdaptivePacketLoss: _client.SupportsAdaptivePacketLoss); if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return; _client.CreateChannel(dlg.Result); } @@ -1148,7 +1148,7 @@ public partial class MainForm : Form channel.PasswordProtected, null, channel.MaxUsers, channel.SortOrder, channel.Audio); - using var dlg = new ChannelEditDialog(_channels, editInfo); + using var dlg = new ChannelEditDialog(_channels, editInfo, _client.SupportsAdaptivePacketLoss); if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return; _client.EditChannel(dlg.Result); } diff --git a/clients/windows/VoiceCat.Windows/Administration.cs b/clients/windows/VoiceCat.Windows/Administration.cs index 7078ec3..daa34b4 100644 --- a/clients/windows/VoiceCat.Windows/Administration.cs +++ b/clients/windows/VoiceCat.Windows/Administration.cs @@ -27,6 +27,7 @@ public sealed partial class VoiceCatClient Id = info.Id, ParentId = info.ParentId, Name = info.Name, Topic = info.Topic, MaxUsers = info.MaxUsers, Order = info.SortOrder, Audio = new() { Codec = info.Audio.Codec, Mode = info.Audio.Stereo ? ChannelMode.ModeStereo : ChannelMode.ModeMono, SampleRate = info.Audio.SampleRate, BitrateBps = info.Audio.BitrateBps, FrameMs = info.Audio.FrameMs, Application = (OpusApplication)info.Audio.Application, Complexity = info.Audio.Complexity, - Fec = info.Audio.Fec, ExpectedPacketLoss = info.Audio.ExpectedPacketLoss, Dtx = info.Audio.Dtx, Dred = info.Audio.Dred } + Fec = info.Audio.Fec, ExpectedPacketLoss = info.Audio.ExpectedPacketLoss, Dtx = info.Audio.Dtx, Dred = info.Audio.Dred, + PacketLossMode = (PacketLossMode)info.Audio.PacketLossMode } }; } diff --git a/clients/windows/VoiceCat.Windows/Enums.cs b/clients/windows/VoiceCat.Windows/Enums.cs index f9745de..1647057 100644 --- a/clients/windows/VoiceCat.Windows/Enums.cs +++ b/clients/windows/VoiceCat.Windows/Enums.cs @@ -1,6 +1,8 @@ // Windows-facing state and command values used by the WinForms application. namespace VoiceCat.Windows; +public enum VcPacketLossMode { Manual, AutoFast, AutoBalanced, AutoStable } + public enum VcResult { Ok = 0, diff --git a/clients/windows/VoiceCat.Windows/Models.cs b/clients/windows/VoiceCat.Windows/Models.cs index 87c1142..35aa26d 100644 --- a/clients/windows/VoiceCat.Windows/Models.cs +++ b/clients/windows/VoiceCat.Windows/Models.cs @@ -73,4 +73,5 @@ public sealed record AudioConfigInfo( uint ExpectedPacketLoss, bool Dtx, uint Complexity, - bool Dred); + bool Dred, + VcPacketLossMode PacketLossMode = VcPacketLossMode.Manual); diff --git a/clients/windows/VoiceCat.Windows/VoiceCatClient.cs b/clients/windows/VoiceCat.Windows/VoiceCatClient.cs index e9c9b2b..5ff82d0 100644 --- a/clients/windows/VoiceCat.Windows/VoiceCatClient.cs +++ b/clients/windows/VoiceCat.Windows/VoiceCatClient.cs @@ -87,6 +87,7 @@ public sealed partial class VoiceCatClient : IDisposable StopDevices(); core.DisconnectAsync().GetAwaiter().GetResult(); return VcResult.Ok; } public string GetServerIdentityDisplay() => core.ServerHello is { } hello ? Convert.ToHexString(hello.ServerIdentityFingerprint.Span) : ""; + public bool SupportsAdaptivePacketLoss => core.SupportsAdaptivePacketLoss; public void PumpEvents() { @@ -182,7 +183,7 @@ public sealed partial class VoiceCatClient : IDisposable public List ListDevices(VcDeviceKind kind) => backend?.Enumerate(kind == VcDeviceKind.Input).Select(d => new DeviceInfo(d.Id, d.Name, d.IsDefault)).ToList() ?? []; public static string VersionString => "VoiceCat managed core 0.1.0 (protocol v2)"; public static string ResultString(VcResult result) => result.ToString(); - private static AudioConfigInfo Audio(AudioConfig a) => new(a.Codec, a.Mode == ChannelMode.ModeStereo, a.SampleRate, a.BitrateBps, a.FrameMs, (uint)a.Application, a.Fec, a.ExpectedPacketLoss, a.Dtx, a.Complexity, a.Dred); + private static AudioConfigInfo Audio(AudioConfig a) => new(a.Codec, a.Mode == ChannelMode.ModeStereo, a.SampleRate, a.BitrateBps, a.FrameMs, (uint)a.Application, a.Fec, a.ExpectedPacketLoss, a.Dtx, a.Complexity, a.Dred, (VcPacketLossMode)a.PacketLossMode); private void StopDevices() { diff --git a/docs/api-dotnet.md b/docs/api-dotnet.md index 95e6585..7df83ab 100644 --- a/docs/api-dotnet.md +++ b/docs/api-dotnet.md @@ -228,8 +228,17 @@ moves members to Lobby (even if full), clearing their streams. Edits stop existi so clients must negotiate the updated audio configuration. Channel names/topics/passwords are limited to 128/4096/1024 UTF-8 bytes. Audio requires Opus, 48 kHz, mono/stereo, 500–512000 bps, integral 5/10/20/40/60 ms frames and valid application/loss/complexity. -Database v3 persists DRED with the rest of the channel audio configuration. Opening a v1 or v2 -database adds the DRED column with a disabled default before accepting channel updates. +Database v4 persists DRED and `AudioConfig.packet_loss_mode` with the rest of the channel audio +configuration. Opening older databases adds missing columns with disabled/manual defaults. The +mode's zero value is manual for wire and database compatibility. Automatic Fast/Balanced/Stable +modes use 3/10/30-second sender-uplink +windows respectively. The server derives loss only from authenticated media counters, requires at +least 20 expected packets, caps the applied Opus hint at 30%, and sends changed values to capable +clients with `PacketLossUpdate`. The managed audio worker applies the mutable encoder control; +device callbacks remain free of networking, allocation, and synchronization. The stored manual +percentage is retained as the bootstrap value while an automatic window fills. +Support is advertised as `adaptive-packet-loss` in both hello feature lists. Older senders keep +using the bootstrap value, and edits from older administrators preserve an existing automatic mode. Session permissions gate kick/ban/move/mute and account operations. Only administrators can grant permissions; account-administration permission cannot grant administrator status. diff --git a/proto/voicecat.proto b/proto/voicecat.proto index d525ec3..593722a 100644 --- a/proto/voicecat.proto +++ b/proto/voicecat.proto @@ -47,6 +47,7 @@ message Envelope { SubscribeVoiceRequest subscribe_voice = 45; UnsubscribeVoiceRequest unsubscribe_voice = 46; VoiceSubscriptionResult voice_subscription_result = 47; + PacketLossUpdate packet_loss_update = 48; // Text (50-59) TextMessage text_message = 50; @@ -77,6 +78,12 @@ enum ChannelMode { MODE_MONO = 0; MODE_STEREO = 1; } enum StreamKind { STREAM_MIC = 0; STREAM_SCREEN_AUDIO = 1; STREAM_AUX_DEVICE = 2; } enum TextScope { TEXT_CHANNEL = 0; TEXT_PRIVATE = 1; TEXT_SERVER = 2; } enum OpusApplication { OPUS_VOIP = 0; OPUS_AUDIO = 1; OPUS_LOWDELAY = 2; } +enum PacketLossMode { + PACKET_LOSS_MANUAL = 0; + PACKET_LOSS_AUTO_FAST = 1; + PACKET_LOSS_AUTO_BALANCED = 2; + PACKET_LOSS_AUTO_STABLE = 3; +} // Common types message AudioConfig { @@ -91,6 +98,7 @@ message AudioConfig { bool dtx = 9; uint32 complexity = 10; // 0..10 bool dred = 11; // Deep REDundancy (Opus 1.6) + PacketLossMode packet_loss_mode = 12; } message StreamInfo { @@ -223,6 +231,11 @@ message UdpBinding { bytes udp_token = 1; bool ack = 2; } message SubscribeVoiceRequest {} message UnsubscribeVoiceRequest {} message VoiceSubscriptionResult { bool ok = 1; string error = 2; bool subscribed = 3; } +message PacketLossUpdate { + uint32 channel_id = 1; + uint32 measured_percent = 2; + uint32 applied_percent = 3; +} // Text message TextMessage { diff --git a/src/VoiceCat.Audio/AudioEngine.cs b/src/VoiceCat.Audio/AudioEngine.cs index 70a1fcc..41fb525 100644 --- a/src/VoiceCat.Audio/AudioEngine.cs +++ b/src/VoiceCat.Audio/AudioEngine.cs @@ -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)); diff --git a/src/VoiceCat.Audio/LocalStream.cs b/src/VoiceCat.Audio/LocalStream.cs index d7146fc..835ecc9 100644 --- a/src/VoiceCat.Audio/LocalStream.cs +++ b/src/VoiceCat.Audio/LocalStream.cs @@ -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 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) { diff --git a/src/VoiceCat.Codec/OpusEncoder.cs b/src/VoiceCat.Codec/OpusEncoder.cs index afe8a29..484ae52 100644 --- a/src/VoiceCat.Codec/OpusEncoder.cs +++ b/src/VoiceCat.Codec/OpusEncoder.cs @@ -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 pcm, Span packet) { ObjectDisposedException.ThrowIf(handle.IsClosed, this); diff --git a/src/VoiceCat.Core/VoiceCatClient.cs b/src/VoiceCat.Core/VoiceCatClient.cs index e13317a..3c8b5a0 100644 --- a/src/VoiceCat.Core/VoiceCatClient.cs +++ b/src/VoiceCat.Core/VoiceCatClient.cs @@ -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? 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 Channels { get { lock (stateGate) return channels.Values.Select(c => c.Clone()).ToArray(); } } public IReadOnlyList 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); diff --git a/src/VoiceCat.Protocol/ProtocolFeatures.cs b/src/VoiceCat.Protocol/ProtocolFeatures.cs new file mode 100644 index 0000000..5f37a13 --- /dev/null +++ b/src/VoiceCat.Protocol/ProtocolFeatures.cs @@ -0,0 +1,6 @@ +namespace VoiceCat.Protocol; + +public static class ProtocolFeatures +{ + public const string AdaptivePacketLoss = "adaptive-packet-loss"; +} diff --git a/src/VoiceCat.Server/ChannelManagement.cs b/src/VoiceCat.Server/ChannelManagement.cs index d0ad253..7d95912 100644 --- a/src/VoiceCat.Server/ChannelManagement.cs +++ b/src/VoiceCat.Server/ChannelManagement.cs @@ -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."); diff --git a/src/VoiceCat.Server/Data/AccountStore.cs b/src/VoiceCat.Server/Data/AccountStore.cs index 80627a5..f592fdc 100644 --- a/src/VoiceCat.Server/Data/AccountStore.cs +++ b/src/VoiceCat.Server/Data/AccountStore.cs @@ -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) } }); } diff --git a/src/VoiceCat.Server/Data/ChannelStore.cs b/src/VoiceCat.Server/Data/ChannelStore.cs index ed8b507..0f4b37a 100644 --- a/src/VoiceCat.Server/Data/ChannelStore.cs +++ b/src/VoiceCat.Server/Data/ChannelStore.cs @@ -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); diff --git a/src/VoiceCat.Server/Data/schema.sql b/src/VoiceCat.Server/Data/schema.sql index aa220c1..3032ec8 100644 --- a/src/VoiceCat.Server/Data/schema.sql +++ b/src/VoiceCat.Server/Data/schema.sql @@ -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 ( diff --git a/src/VoiceCat.Server/Transport/MediaFanout.cs b/src/VoiceCat.Server/Transport/MediaFanout.cs index f43a8a3..3d2f4c4 100644 --- a/src/VoiceCat.Server/Transport/MediaFanout.cs +++ b/src/VoiceCat.Server/Transport/MediaFanout.cs @@ -15,13 +15,15 @@ internal sealed class MediaFanout : IDisposable private int length; private int index; - public bool TryStart(ReadOnlySpan packet, MediaRoute sender, MediaRoute[] recipients) + public bool TryStart(ReadOnlySpan 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; diff --git a/src/VoiceCat.Server/Transport/MediaRelay.cs b/src/VoiceCat.Server/Transport/MediaRelay.cs index c9b5d4f..c76430d 100644 --- a/src/VoiceCat.Server/Transport/MediaRelay.cs +++ b/src/VoiceCat.Server/Transport/MediaRelay.cs @@ -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? 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 packet, out SocketAddress? endpoint)) await SendAsync(packet, endpoint!).ConfigureAwait(false); } diff --git a/src/VoiceCat.Server/Transport/PacketLossTracker.cs b/src/VoiceCat.Server/Transport/PacketLossTracker.cs new file mode 100644 index 0000000..189ebcb --- /dev/null +++ b/src/VoiceCat.Server/Transport/PacketLossTracker.cs @@ -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); +} diff --git a/src/VoiceCat.Server/VoiceServer.cs b/src/VoiceCat.Server/VoiceServer.cs index 307e87e..da9e4a2 100644 --- a/src/VoiceCat.Server/VoiceServer.cs +++ b/src/VoiceCat.Server/VoiceServer.cs @@ -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 Features { get; } = new(StringComparer.Ordinal); public User? User { get; set; } public Permissions Permissions { get; set; } = new(); public MediaPeer? Media { get; set; } diff --git a/tests/VoiceCat.Tests/AccountStoreTests.cs b/tests/VoiceCat.Tests/AccountStoreTests.cs index c7bca04..d66fc92 100644 --- a/tests/VoiceCat.Tests/AccountStoreTests.cs +++ b/tests/VoiceCat.Tests/AccountStoreTests.cs @@ -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); } } diff --git a/tests/VoiceCat.Tests/AudioEngineTests.cs b/tests/VoiceCat.Tests/AudioEngineTests.cs index e20d635..ed48676 100644 --- a/tests/VoiceCat.Tests/AudioEngineTests.cs +++ b/tests/VoiceCat.Tests/AudioEngineTests.cs @@ -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) diff --git a/tests/VoiceCat.Tests/ChannelManagementTests.cs b/tests/VoiceCat.Tests/ChannelManagementTests.cs index 074bf65..3ca0f21 100644 --- a/tests/VoiceCat.Tests/ChannelManagementTests.cs +++ b/tests/VoiceCat.Tests/ChannelManagementTests.cs @@ -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); + } } diff --git a/tests/VoiceCat.Tests/CodecTests.cs b/tests/VoiceCat.Tests/CodecTests.cs index 295cf04..38108f5 100644 --- a/tests/VoiceCat.Tests/CodecTests.cs +++ b/tests/VoiceCat.Tests/CodecTests.cs @@ -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(() => encoder.SetExpectedPacketLossPercent(101)); + } + public static IEnumerable Formats() { foreach (int rate in new[] { 8000, 12000, 16000, 24000, 48000 }) diff --git a/tests/VoiceCat.Tests/MediaFanoutTests.cs b/tests/VoiceCat.Tests/MediaFanoutTests.cs index 07a282a..5efa558 100644 --- a/tests/VoiceCat.Tests/MediaFanoutTests.cs +++ b/tests/VoiceCat.Tests/MediaFanoutTests.cs @@ -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."); diff --git a/tests/VoiceCat.Tests/MediaRelayTests.cs b/tests/VoiceCat.Tests/MediaRelayTests.cs index f6ca1f7..724b49c 100644 --- a/tests/VoiceCat.Tests/MediaRelayTests.cs +++ b/tests/VoiceCat.Tests/MediaRelayTests.cs @@ -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 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); + } } diff --git a/tests/VoiceCat.Tests/PacketLossTrackerTests.cs b/tests/VoiceCat.Tests/PacketLossTrackerTests.cs new file mode 100644 index 0000000..51d7167 --- /dev/null +++ b/tests/VoiceCat.Tests/PacketLossTrackerTests.cs @@ -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(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(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(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; + } +} diff --git a/tests/VoiceCat.Tests/ServerTests.cs b/tests/VoiceCat.Tests/ServerTests.cs index 4e83ac2..6897b87 100644 --- a/tests/VoiceCat.Tests/ServerTests.cs +++ b/tests/VoiceCat.Tests/ServerTests.cs @@ -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 TakeMediaCryptoAsync() => connection.TakeMediaCryptoAsync(Timeout.Token); public async Task ReadUntilAsync(Func 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 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; diff --git a/tests/VoiceCat.Tests/WindowsManagedClientTests.cs b/tests/VoiceCat.Tests/WindowsManagedClientTests.cs index 26cd7bb..56f3958 100644 --- a/tests/VoiceCat.Tests/WindowsManagedClientTests.cs +++ b/tests/VoiceCat.Tests/WindowsManagedClientTests.cs @@ -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);