Add adaptive packet loss handling
This commit is contained in:
+5
-3
@@ -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
|
samples; the managed Apple binding requires native use of its otherwise-unmapped stereo polar
|
||||||
pattern constant.
|
pattern constant.
|
||||||
|
|
||||||
SQLite schema v3 persists channel DRED settings and migrates existing v1/v2 databases with DRED
|
SQLite schema v4 persists DRED and the channel packet-loss mode. Manual loss remains the default;
|
||||||
disabled until explicitly enabled.
|
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
|
## Release gates
|
||||||
|
|
||||||
- Run real multi-person calls on Windows, macOS, and physical iOS hardware, including adaptive
|
- 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.
|
- Complete NVDA and VoiceOver navigation/announcement passes.
|
||||||
- Exercise iOS background/lock, interruption, Bluetooth, route-change, ReplayKit, and iOS 27
|
- Exercise iOS background/lock, interruption, Bluetooth, route-change, ReplayKit, and iOS 27
|
||||||
ScreenCaptureKit paths on devices. Complete extended mono/stereo/voice-chat switching while
|
ScreenCaptureKit paths on devices. Complete extended mono/stereo/voice-chat switching while
|
||||||
|
|||||||
@@ -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 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 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 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 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); if (edit is not null) { Show(await client.EditChannelAsync(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)
|
private async void DeleteChannel(object? sender, EventArgs args)
|
||||||
{
|
{
|
||||||
Channel? channel = Channel(); if (channel is null || channel.Id == 1) return;
|
Channel? channel = Channel(); if (channel is null || channel.Id == 1) return;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace VoiceCat.Mac;
|
|||||||
|
|
||||||
internal static class ChannelEditor
|
internal static class ChannelEditor
|
||||||
{
|
{
|
||||||
internal static ChannelEdit? Run(Channel? existing, IReadOnlyList<Channel> channels)
|
internal static ChannelEdit? Run(Channel? existing, IReadOnlyList<Channel> channels, bool supportsAdaptivePacketLoss)
|
||||||
{
|
{
|
||||||
var view = new NSView(new CGRect(0, 0, 520, 500));
|
var view = new NSView(new CGRect(0, 0, 520, 500));
|
||||||
var name = Field(existing?.Name ?? "", 360); var topic = Field(existing?.Topic ?? "", 330);
|
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, "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);
|
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 advanced = new NSView(new CGRect(0, 0, 520, 175));
|
||||||
var fec = Check("In-band FEC", existing?.Audio?.Fec ?? true, 0, 75); var dtx = Check("DTX", existing?.Audio?.Dtx ?? true, 130, 75);
|
var automaticLoss = Check("Automatic packet loss", existing?.Audio?.PacketLossMode != PacketLossMode.PacketLossManual, 155, 140);
|
||||||
var dred = Check("Deep redundancy", existing?.Audio?.Dred ?? false, 230, 75);
|
automaticLoss.Frame = new CGRect(155, 140, 200, 24);
|
||||||
var loss = Field((existing?.Audio?.ExpectedPacketLoss ?? 5).ToString(), 40); loss.Frame = new CGRect(155, 40, 100, 24);
|
automaticLoss.Enabled = supportsAdaptivePacketLoss;
|
||||||
var complexity = Field((existing?.Audio?.Complexity ?? 10).ToString(), 10); complexity.Frame = new CGRect(155, 10, 100, 24);
|
var lossSpeed = Picker(["Fast", "Balanced", "Stable"], 105);
|
||||||
advanced.AddSubview(fec); advanced.AddSubview(dtx); advanced.AddSubview(dred); Add(advanced, "Packet loss %", loss, 40); Add(advanced, "Complexity 0–10", complexity, 10);
|
lossSpeed.SelectItem(existing?.Audio?.PacketLossMode switch { PacketLossMode.PacketLossAutoFast => 0, PacketLossMode.PacketLossAutoStable => 2, _ => 1 });
|
||||||
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 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 };
|
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;
|
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) ||
|
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,
|
Type = type.IndexOfSelectedItem == 1 ? ChannelType.ChannelTemporary : ChannelType.ChannelPermanent,
|
||||||
Audio = new AudioConfig { Codec = 0, Mode = mode.IndexOfSelectedItem == 1 ? ChannelMode.ModeStereo : ChannelMode.ModeMono,
|
Audio = new AudioConfig { Codec = 0, Mode = mode.IndexOfSelectedItem == 1 ? ChannelMode.ModeStereo : ChannelMode.ModeMono,
|
||||||
SampleRate = sampleRate, FrameMs = frameMs, Application = (OpusApplication)(int)application.IndexOfSelectedItem, BitrateBps = bitrateValue,
|
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);
|
}, password.StringValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ internal sealed class AppModel
|
|||||||
internal IosSettings Settings => settings;
|
internal IosSettings Settings => settings;
|
||||||
internal VoiceCatClient? Client => client;
|
internal VoiceCatClient? Client => client;
|
||||||
internal bool IsConnected => client?.State == ClientConnectionState.Connected;
|
internal bool IsConnected => client?.State == ClientConnectionState.Connected;
|
||||||
|
internal bool SupportsAdaptivePacketLoss => client?.SupportsAdaptivePacketLoss == true;
|
||||||
internal bool IsConnecting { get; private set; }
|
internal bool IsConnecting { get; private set; }
|
||||||
internal bool VoiceJoined => microphoneStream != 0;
|
internal bool VoiceJoined => microphoneStream != 0;
|
||||||
internal bool ScreenSharing => broadcast?.IsActive == true;
|
internal bool ScreenSharing => broadcast?.IsActive == true;
|
||||||
|
|||||||
@@ -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 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 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 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"; }
|
internal ChannelEditorController(AppModel model, Channel? existing) { this.model = model; this.existing = existing; Title = existing is null ? "New Channel" : "Edit Channel"; }
|
||||||
public override void ViewDidLoad()
|
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 };
|
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)]);
|
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;
|
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;
|
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;
|
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());
|
Load(); NavigationItem.RightBarButtonItem = new("Save", UIBarButtonItemStyle.Done, async (_, _) => await Save());
|
||||||
}
|
}
|
||||||
private void Load()
|
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;
|
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);
|
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;
|
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()
|
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.");
|
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,
|
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);
|
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); }
|
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 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 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());
|
private static UIMenu Choice(UIButton button, string[] values) => UIMenu.Create(values.Select(value => UIAction.Create(value, null, null, _ => button.SetTitle(value, UIControlState.Normal))).ToArray());
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public sealed class ChannelEditDialog : Form
|
|||||||
private NumericUpDown _numFrameMs = null!;
|
private NumericUpDown _numFrameMs = null!;
|
||||||
private ComboBox _cboApplication = null!;
|
private ComboBox _cboApplication = null!;
|
||||||
private CheckBox _chkFec = null!;
|
private CheckBox _chkFec = null!;
|
||||||
|
private CheckBox _chkAutomaticLoss = null!;
|
||||||
|
private ComboBox _cboLossSpeed = null!;
|
||||||
private NumericUpDown _numExpectedLoss = null!;
|
private NumericUpDown _numExpectedLoss = null!;
|
||||||
private CheckBox _chkDtx = null!;
|
private CheckBox _chkDtx = null!;
|
||||||
private CheckBox _chkDred = null!;
|
private CheckBox _chkDred = null!;
|
||||||
@@ -33,11 +35,14 @@ public sealed class ChannelEditDialog : Form
|
|||||||
|
|
||||||
public ChannelEditInfo? Result { get; private set; }
|
public ChannelEditInfo? Result { get; private set; }
|
||||||
|
|
||||||
public ChannelEditDialog(IEnumerable<ChannelInfo> channels, ChannelEditInfo? existing = null)
|
private readonly bool _supportsAdaptivePacketLoss;
|
||||||
|
|
||||||
|
public ChannelEditDialog(IEnumerable<ChannelInfo> channels, ChannelEditInfo? existing = null, bool supportsAdaptivePacketLoss = true)
|
||||||
{
|
{
|
||||||
_isCreate = existing is null;
|
_isCreate = existing is null;
|
||||||
_editingId = existing?.Id ?? 0;
|
_editingId = existing?.Id ?? 0;
|
||||||
_channels = channels.Where(c => c.Id != _editingId).ToList();
|
_channels = channels.Where(c => c.Id != _editingId).ToList();
|
||||||
|
_supportsAdaptivePacketLoss = supportsAdaptivePacketLoss;
|
||||||
|
|
||||||
Text = _isCreate ? "Create channel" : "Edit channel";
|
Text = _isCreate ? "Create channel" : "Edit channel";
|
||||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
@@ -269,6 +274,31 @@ public sealed class ChannelEditDialog : Form
|
|||||||
page.Controls.Add(_cboApplication);
|
page.Controls.Add(_cboApplication);
|
||||||
y += 34;
|
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);
|
AddLabel(page, "Expected packet loss (%):", 12, y, labelWidth);
|
||||||
_numExpectedLoss = new NumericUpDown
|
_numExpectedLoss = new NumericUpDown
|
||||||
{
|
{
|
||||||
@@ -277,9 +307,16 @@ public sealed class ChannelEditDialog : Form
|
|||||||
Minimum = 0,
|
Minimum = 0,
|
||||||
Maximum = 100,
|
Maximum = 100,
|
||||||
Value = audio.ExpectedPacketLoss,
|
Value = audio.ExpectedPacketLoss,
|
||||||
TabIndex = 15,
|
TabIndex = 17,
|
||||||
};
|
};
|
||||||
page.Controls.Add(_numExpectedLoss);
|
page.Controls.Add(_numExpectedLoss);
|
||||||
|
void UpdateLossControls()
|
||||||
|
{
|
||||||
|
_numExpectedLoss.Enabled = !_chkAutomaticLoss.Checked;
|
||||||
|
_cboLossSpeed.Enabled = _supportsAdaptivePacketLoss && _chkAutomaticLoss.Checked;
|
||||||
|
}
|
||||||
|
_chkAutomaticLoss.CheckedChanged += (_, _) => UpdateLossControls();
|
||||||
|
UpdateLossControls();
|
||||||
y += 34;
|
y += 34;
|
||||||
|
|
||||||
AddLabel(page, "Com&plexity (0–10):", 12, y, labelWidth);
|
AddLabel(page, "Com&plexity (0–10):", 12, y, labelWidth);
|
||||||
@@ -290,7 +327,7 @@ public sealed class ChannelEditDialog : Form
|
|||||||
Minimum = 0,
|
Minimum = 0,
|
||||||
Maximum = 10,
|
Maximum = 10,
|
||||||
Value = audio.Complexity,
|
Value = audio.Complexity,
|
||||||
TabIndex = 16,
|
TabIndex = 18,
|
||||||
};
|
};
|
||||||
page.Controls.Add(_numComplexity);
|
page.Controls.Add(_numComplexity);
|
||||||
y += 34;
|
y += 34;
|
||||||
@@ -301,7 +338,7 @@ public sealed class ChannelEditDialog : Form
|
|||||||
Location = new Point(inputX, y),
|
Location = new Point(inputX, y),
|
||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
Checked = audio.Fec,
|
Checked = audio.Fec,
|
||||||
TabIndex = 17,
|
TabIndex = 19,
|
||||||
};
|
};
|
||||||
page.Controls.Add(_chkFec);
|
page.Controls.Add(_chkFec);
|
||||||
y += 28;
|
y += 28;
|
||||||
@@ -312,7 +349,7 @@ public sealed class ChannelEditDialog : Form
|
|||||||
Location = new Point(inputX, y),
|
Location = new Point(inputX, y),
|
||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
Checked = audio.Dtx,
|
Checked = audio.Dtx,
|
||||||
TabIndex = 18,
|
TabIndex = 20,
|
||||||
};
|
};
|
||||||
page.Controls.Add(_chkDtx);
|
page.Controls.Add(_chkDtx);
|
||||||
y += 28;
|
y += 28;
|
||||||
@@ -323,7 +360,7 @@ public sealed class ChannelEditDialog : Form
|
|||||||
Location = new Point(inputX, y),
|
Location = new Point(inputX, y),
|
||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
Checked = audio.Dred,
|
Checked = audio.Dred,
|
||||||
TabIndex = 19,
|
TabIndex = 21,
|
||||||
};
|
};
|
||||||
page.Controls.Add(_chkDred);
|
page.Controls.Add(_chkDred);
|
||||||
}
|
}
|
||||||
@@ -375,7 +412,8 @@ public sealed class ChannelEditDialog : Form
|
|||||||
ExpectedPacketLoss: (uint)_numExpectedLoss.Value,
|
ExpectedPacketLoss: (uint)_numExpectedLoss.Value,
|
||||||
Dtx: _chkDtx.Checked,
|
Dtx: _chkDtx.Checked,
|
||||||
Complexity: (uint)_numComplexity.Value,
|
Complexity: (uint)_numComplexity.Value,
|
||||||
Dred: _chkDred.Checked);
|
Dred: _chkDred.Checked,
|
||||||
|
PacketLossMode: _chkAutomaticLoss.Checked ? (VcPacketLossMode)(_cboLossSpeed.SelectedIndex + 1) : VcPacketLossMode.Manual);
|
||||||
|
|
||||||
Result = new ChannelEditInfo(
|
Result = new ChannelEditInfo(
|
||||||
Id: _editingId,
|
Id: _editingId,
|
||||||
|
|||||||
@@ -1132,7 +1132,7 @@ public partial class MainForm : Form
|
|||||||
|
|
||||||
private void CreateChannel()
|
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;
|
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
|
||||||
_client.CreateChannel(dlg.Result);
|
_client.CreateChannel(dlg.Result);
|
||||||
}
|
}
|
||||||
@@ -1148,7 +1148,7 @@ public partial class MainForm : Form
|
|||||||
channel.PasswordProtected, null, channel.MaxUsers, channel.SortOrder,
|
channel.PasswordProtected, null, channel.MaxUsers, channel.SortOrder,
|
||||||
channel.Audio);
|
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;
|
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
|
||||||
_client.EditChannel(dlg.Result);
|
_client.EditChannel(dlg.Result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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,
|
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,
|
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 }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Windows-facing state and command values used by the WinForms application.
|
// Windows-facing state and command values used by the WinForms application.
|
||||||
namespace VoiceCat.Windows;
|
namespace VoiceCat.Windows;
|
||||||
|
|
||||||
|
public enum VcPacketLossMode { Manual, AutoFast, AutoBalanced, AutoStable }
|
||||||
|
|
||||||
public enum VcResult
|
public enum VcResult
|
||||||
{
|
{
|
||||||
Ok = 0,
|
Ok = 0,
|
||||||
|
|||||||
@@ -73,4 +73,5 @@ public sealed record AudioConfigInfo(
|
|||||||
uint ExpectedPacketLoss,
|
uint ExpectedPacketLoss,
|
||||||
bool Dtx,
|
bool Dtx,
|
||||||
uint Complexity,
|
uint Complexity,
|
||||||
bool Dred);
|
bool Dred,
|
||||||
|
VcPacketLossMode PacketLossMode = VcPacketLossMode.Manual);
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ public sealed partial class VoiceCatClient : IDisposable
|
|||||||
StopDevices(); core.DisconnectAsync().GetAwaiter().GetResult(); return VcResult.Ok;
|
StopDevices(); core.DisconnectAsync().GetAwaiter().GetResult(); return VcResult.Ok;
|
||||||
}
|
}
|
||||||
public string GetServerIdentityDisplay() => core.ServerHello is { } hello ? Convert.ToHexString(hello.ServerIdentityFingerprint.Span) : "";
|
public string GetServerIdentityDisplay() => core.ServerHello is { } hello ? Convert.ToHexString(hello.ServerIdentityFingerprint.Span) : "";
|
||||||
|
public bool SupportsAdaptivePacketLoss => core.SupportsAdaptivePacketLoss;
|
||||||
|
|
||||||
public void PumpEvents()
|
public void PumpEvents()
|
||||||
{
|
{
|
||||||
@@ -182,7 +183,7 @@ public sealed partial class VoiceCatClient : IDisposable
|
|||||||
public List<DeviceInfo> ListDevices(VcDeviceKind kind) => backend?.Enumerate(kind == VcDeviceKind.Input).Select(d => new DeviceInfo(d.Id, d.Name, d.IsDefault)).ToList() ?? [];
|
public List<DeviceInfo> 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 VersionString => "VoiceCat managed core 0.1.0 (protocol v2)";
|
||||||
public static string ResultString(VcResult result) => result.ToString();
|
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()
|
private void StopDevices()
|
||||||
{
|
{
|
||||||
|
|||||||
+11
-2
@@ -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
|
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,
|
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.
|
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 v4 persists DRED and `AudioConfig.packet_loss_mode` with the rest of the channel audio
|
||||||
database adds the DRED column with a disabled default before accepting channel updates.
|
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
|
Session permissions gate kick/ban/move/mute and account operations. Only administrators
|
||||||
can grant permissions; account-administration permission cannot grant administrator status.
|
can grant permissions; account-administration permission cannot grant administrator status.
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ message Envelope {
|
|||||||
SubscribeVoiceRequest subscribe_voice = 45;
|
SubscribeVoiceRequest subscribe_voice = 45;
|
||||||
UnsubscribeVoiceRequest unsubscribe_voice = 46;
|
UnsubscribeVoiceRequest unsubscribe_voice = 46;
|
||||||
VoiceSubscriptionResult voice_subscription_result = 47;
|
VoiceSubscriptionResult voice_subscription_result = 47;
|
||||||
|
PacketLossUpdate packet_loss_update = 48;
|
||||||
|
|
||||||
// Text (50-59)
|
// Text (50-59)
|
||||||
TextMessage text_message = 50;
|
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 StreamKind { STREAM_MIC = 0; STREAM_SCREEN_AUDIO = 1; STREAM_AUX_DEVICE = 2; }
|
||||||
enum TextScope { TEXT_CHANNEL = 0; TEXT_PRIVATE = 1; TEXT_SERVER = 2; }
|
enum TextScope { TEXT_CHANNEL = 0; TEXT_PRIVATE = 1; TEXT_SERVER = 2; }
|
||||||
enum OpusApplication { OPUS_VOIP = 0; OPUS_AUDIO = 1; OPUS_LOWDELAY = 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
|
// Common types
|
||||||
message AudioConfig {
|
message AudioConfig {
|
||||||
@@ -91,6 +98,7 @@ message AudioConfig {
|
|||||||
bool dtx = 9;
|
bool dtx = 9;
|
||||||
uint32 complexity = 10; // 0..10
|
uint32 complexity = 10; // 0..10
|
||||||
bool dred = 11; // Deep REDundancy (Opus 1.6)
|
bool dred = 11; // Deep REDundancy (Opus 1.6)
|
||||||
|
PacketLossMode packet_loss_mode = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
message StreamInfo {
|
message StreamInfo {
|
||||||
@@ -223,6 +231,11 @@ message UdpBinding { bytes udp_token = 1; bool ack = 2; }
|
|||||||
message SubscribeVoiceRequest {}
|
message SubscribeVoiceRequest {}
|
||||||
message UnsubscribeVoiceRequest {}
|
message UnsubscribeVoiceRequest {}
|
||||||
message VoiceSubscriptionResult { bool ok = 1; string error = 2; bool subscribed = 3; }
|
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
|
// Text
|
||||||
message TextMessage {
|
message TextMessage {
|
||||||
|
|||||||
@@ -112,6 +112,19 @@ public sealed class AudioEngine : IDisposable
|
|||||||
if (stream.Info.StreamId == streamId) return stream.Diagnostics;
|
if (stream.Info.StreamId == streamId) return stream.Diagnostics;
|
||||||
return default;
|
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)
|
public void SetLocalGain(uint streamId, float gain)
|
||||||
{
|
{
|
||||||
if (!float.IsFinite(gain) || gain < 0 || gain > 4) throw new ArgumentOutOfRangeException(nameof(gain));
|
if (!float.IsFinite(gain) || gain < 0 || gain > 4) throw new ArgumentOutOfRangeException(nameof(gain));
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ internal sealed class LocalStream : IDisposable
|
|||||||
private int starvedSamples;
|
private int starvedSamples;
|
||||||
private long cycles, starvedCycles, encodedPackets, rejectedPackets;
|
private long cycles, starvedCycles, encodedPackets, rejectedPackets;
|
||||||
private uint timestamp;
|
private uint timestamp;
|
||||||
|
private int desiredPacketLoss;
|
||||||
private bool wasTransmitting, marker;
|
private bool wasTransmitting, marker;
|
||||||
internal LocalAudioDiagnostics Diagnostics => new(Volatile.Read(ref cycles), Volatile.Read(ref starvedCycles),
|
internal LocalAudioDiagnostics Diagnostics => new(Volatile.Read(ref cycles), Volatile.Read(ref starvedCycles),
|
||||||
Volatile.Read(ref encodedPackets), Volatile.Read(ref rejectedPackets), Input.CountFrames);
|
Volatile.Read(ref encodedPackets), Volatile.Read(ref rejectedPackets), Input.CountFrames);
|
||||||
|
internal int AppliedExpectedPacketLoss => encoder.ExpectedPacketLossPercent;
|
||||||
|
internal int DesiredExpectedPacketLoss { set => Volatile.Write(ref desiredPacketLoss, value); }
|
||||||
|
|
||||||
internal bool Feed(ReadOnlySpan<short> pcm, int channels)
|
internal bool Feed(ReadOnlySpan<short> pcm, int channels)
|
||||||
{
|
{
|
||||||
@@ -64,6 +67,7 @@ internal sealed class LocalStream : IDisposable
|
|||||||
DiscontinuousTransmission = stream.Audio.Dtx, DeepRedundancy = stream.Audio.Dred,
|
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 }
|
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 { left = new(); } catch { encoder.Dispose(); throw; }
|
||||||
try { right = new(); } catch { left.Dispose(); 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)
|
internal void Process(AudioEngine engine, EncodedVoiceSender sender)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref cycles);
|
Interlocked.Increment(ref cycles);
|
||||||
|
int desired = Volatile.Read(ref desiredPacketLoss);
|
||||||
|
if (desired != encoder.ExpectedPacketLossPercent) encoder.SetExpectedPacketLossPercent(desired);
|
||||||
var input = capture.AsSpan(0, 960 * CaptureChannels);
|
var input = capture.AsSpan(0, 960 * CaptureChannels);
|
||||||
if (Input.Read(input) != input.Length)
|
if (Input.Read(input) != input.Length)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public sealed class OpusEncoder : IDisposable
|
|||||||
private readonly OpusEncoderHandle handle;
|
private readonly OpusEncoderHandle handle;
|
||||||
public OpusOptions Options { get; }
|
public OpusOptions Options { get; }
|
||||||
public bool SupportsDeepRedundancy { get; }
|
public bool SupportsDeepRedundancy { get; }
|
||||||
|
public int ExpectedPacketLossPercent { get; private set; }
|
||||||
public static string Version => Marshal.PtrToStringUTF8(NativeMethods.Version())!;
|
public static string Version => Marshal.PtrToStringUTF8(NativeMethods.Version())!;
|
||||||
|
|
||||||
public OpusEncoder(OpusOptions? options = null)
|
public OpusEncoder(OpusOptions? options = null)
|
||||||
@@ -24,6 +25,7 @@ public sealed class OpusEncoder : IDisposable
|
|||||||
Set(4012, Options.ForwardErrorCorrection ? 1 : 0);
|
Set(4012, Options.ForwardErrorCorrection ? 1 : 0);
|
||||||
Set(4016, Options.DiscontinuousTransmission ? 1 : 0);
|
Set(4016, Options.DiscontinuousTransmission ? 1 : 0);
|
||||||
Set(4014, Options.ExpectedPacketLossPercent);
|
Set(4014, Options.ExpectedPacketLossPercent);
|
||||||
|
ExpectedPacketLossPercent = Options.ExpectedPacketLossPercent;
|
||||||
int support = NativeMethods.EncoderGetDred(handle, out _);
|
int support = NativeMethods.EncoderGetDred(handle, out _);
|
||||||
if (support != -5) OpusException.Check(support);
|
if (support != -5) OpusException.Check(support);
|
||||||
SupportsDeepRedundancy = support == 0 && Options.SampleRate >= 16000;
|
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));
|
private void Set(int request, int value) => OpusException.Check(NativeMethods.EncoderSet(handle, request, value));
|
||||||
|
|
||||||
|
public void SetExpectedPacketLossPercent(int value)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
|
||||||
|
if (value is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(value));
|
||||||
|
if (value == ExpectedPacketLossPercent) return;
|
||||||
|
Set(4014, value);
|
||||||
|
ExpectedPacketLossPercent = value;
|
||||||
|
}
|
||||||
|
|
||||||
public unsafe int Encode(ReadOnlySpan<short> pcm, Span<byte> packet)
|
public unsafe int Encode(ReadOnlySpan<short> pcm, Span<byte> packet)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
|
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
|
|||||||
private long nextRequest;
|
private long nextRequest;
|
||||||
private AuthResult? authentication;
|
private AuthResult? authentication;
|
||||||
private ServerHello? hello;
|
private ServerHello? hello;
|
||||||
|
private uint adaptiveLossChannel;
|
||||||
|
private int adaptiveLossPercent = -1;
|
||||||
private ClientConnectionState state;
|
private ClientConnectionState state;
|
||||||
|
|
||||||
public event Action<ClientConnectionState>? ConnectionStateChanged;
|
public event Action<ClientConnectionState>? ConnectionStateChanged;
|
||||||
@@ -47,6 +49,7 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
|
|||||||
public Task Completion => reader;
|
public Task Completion => reader;
|
||||||
public AuthResult? Authentication { get { lock (stateGate) return authentication?.Clone(); } }
|
public AuthResult? Authentication { get { lock (stateGate) return authentication?.Clone(); } }
|
||||||
public ServerHello? ServerHello { get { lock (stateGate) return hello?.Clone(); } }
|
public ServerHello? ServerHello { get { lock (stateGate) return hello?.Clone(); } }
|
||||||
|
public bool SupportsAdaptivePacketLoss { get { lock (stateGate) return hello?.Features.Contains(ProtocolFeatures.AdaptivePacketLoss) == true; } }
|
||||||
public IReadOnlyList<Channel> Channels { get { lock (stateGate) return channels.Values.Select(c => c.Clone()).ToArray(); } }
|
public IReadOnlyList<Channel> Channels { get { lock (stateGate) return channels.Values.Select(c => c.Clone()).ToArray(); } }
|
||||||
public IReadOnlyList<User> Users { get { lock (stateGate) return users.Values.Select(u => u.Clone()).ToArray(); } }
|
public IReadOnlyList<User> Users { get { lock (stateGate) return users.Values.Select(u => u.Clone()).ToArray(); } }
|
||||||
public bool TryReadEvent(out Envelope? envelope) => events.Reader.TryRead(out envelope);
|
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);
|
pins.Pin(host, port, certificatePin);
|
||||||
}
|
}
|
||||||
reader = ReadAsync(control, connectionLifetime.Token);
|
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.");
|
if (response.ServerHello?.ProtoVersion != 2) throw new IOException("Unsupported server protocol.");
|
||||||
lock (stateGate) hello = response.ServerHello.Clone();
|
lock (stateGate) hello = response.ServerHello.Clone();
|
||||||
keepalive = KeepaliveAsync(connectionLifetime.Token);
|
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 };
|
var info = new StreamInfo { StreamId = response.StreamId, Ssrc = response.Ssrc, Kind = kind, Audio = response.EffectiveAudio.Clone(), Label = label };
|
||||||
try
|
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();
|
return info.Clone();
|
||||||
}
|
}
|
||||||
catch { if (State == ClientConnectionState.Connected) Send(new() { StreamStop = new() { StreamId = info.StreamId } }); throw; }
|
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 is not null)
|
||||||
{
|
{
|
||||||
if (message.ChannelEvent.Kind == ChannelEvent.Types.Kind.Deleted) channels.Remove(message.ChannelEvent.DeletedId);
|
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 is not null)
|
||||||
{
|
{
|
||||||
if (message.UserEvent.Kind == UserEvent.Types.Kind.Left) users.Remove(message.UserEvent.LeftId);
|
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();
|
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))
|
if (authentication is not null && (message.ServerState is not null || message.UserEvent is not null))
|
||||||
{
|
{
|
||||||
User self = users.GetValueOrDefault(authentication.Self.Id, authentication.Self);
|
User self = users.GetValueOrDefault(authentication.Self.Id, authentication.Self);
|
||||||
@@ -273,7 +303,7 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
|
|||||||
media = null;
|
media = null;
|
||||||
mediaCrypto?.Dispose(); mediaCrypto = null;
|
mediaCrypto?.Dispose(); mediaCrypto = null;
|
||||||
connectionLifetime?.Dispose(); connectionLifetime = 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)
|
lock (stateGate)
|
||||||
{
|
{
|
||||||
foreach (var id in localStreams.Keys) Audio.RemoveLocalStream(id);
|
foreach (var id in localStreams.Keys) Audio.RemoveLocalStream(id);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace VoiceCat.Protocol;
|
||||||
|
|
||||||
|
public static class ProtocolFeatures
|
||||||
|
{
|
||||||
|
public const string AdaptivePacketLoss = "adaptive-packet-loss";
|
||||||
|
}
|
||||||
@@ -12,6 +12,13 @@ public sealed partial class VoiceServer
|
|||||||
{
|
{
|
||||||
bool create = request.CreateChannel is not null;
|
bool create = request.CreateChannel is not null;
|
||||||
Channel? input = create ? request.CreateChannel!.Channel : request.EditChannel?.Channel;
|
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;
|
bool permitted = actor.Permissions.IsAdmin || create && actor.Permissions.CanCreateTempChannel && input?.Type == ChannelType.ChannelTemporary;
|
||||||
if (!permitted) { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
|
if (!permitted) { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
|
||||||
try
|
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) ||
|
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) ||
|
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.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))
|
!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.");
|
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.");
|
if (channel.Id == 1 && !create && (password.Length != 0 || channel.ParentId != 0)) throw new ArgumentException("Lobby must remain an unprotected root channel.");
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public sealed partial class AccountStore : IDisposable
|
|||||||
version.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
|
version.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
|
||||||
object? stored = version.ExecuteScalar();
|
object? stored = version.ExecuteScalar();
|
||||||
int revision = 0;
|
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.");
|
throw new InvalidDataException("Unsupported server database schema version.");
|
||||||
using var resource = typeof(AccountStore).Assembly.GetManifestResourceStream("VoiceCat.Server.Data.schema.sql")!;
|
using var resource = typeof(AccountStore).Assembly.GetManifestResourceStream("VoiceCat.Server.Data.schema.sql")!;
|
||||||
using var reader = new StreamReader(resource);
|
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.CommandText = "ALTER TABLE channels ADD COLUMN audio_dred INTEGER NOT NULL DEFAULT 0;";
|
||||||
migrate.ExecuteNonQuery();
|
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();
|
migrate.ExecuteNonQuery();
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
@@ -129,7 +134,7 @@ public sealed partial class AccountStore : IDisposable
|
|||||||
command.CommandText = """
|
command.CommandText = """
|
||||||
SELECT id,parent_id,name,topic,password_hash,max_users,type,sort_order,
|
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_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
|
FROM channels ORDER BY sort_order,id
|
||||||
""";
|
""";
|
||||||
using var reader = command.ExecuteReader();
|
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)),
|
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),
|
FrameMs = checked((uint)reader.GetInt64(12)), Application = (Voicecat.V1.OpusApplication)reader.GetInt32(13),
|
||||||
Fec = reader.GetInt32(14) != 0, ExpectedPacketLoss = checked((uint)reader.GetInt64(15)),
|
Fec = reader.GetInt32(14) != 0, ExpectedPacketLoss = checked((uint)reader.GetInt64(15)),
|
||||||
Dtx = reader.GetInt32(16) != 0, Complexity = checked((uint)reader.GetInt64(17)), Dred = reader.GetInt32(18) != 0
|
Dtx = reader.GetInt32(16) != 0, Complexity = checked((uint)reader.GetInt64(17)), Dred = reader.GetInt32(18) != 0,
|
||||||
|
PacketLossMode = (Voicecat.V1.PacketLossMode)reader.GetInt32(19)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ public sealed partial class AccountStore
|
|||||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||||
hash = Convert.ToHexString(salt).ToLowerInvariant() + ":" + Convert.ToHexString(ChannelDigest(password, salt)).ToLowerInvariant();
|
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;
|
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]);
|
for (int i = 0; i < columns.Length; i++) command.Parameters.AddWithValue("$" + columns[i], values[i]);
|
||||||
command.Parameters.AddWithValue("$hash", hash);
|
command.Parameters.AddWithValue("$hash", hash);
|
||||||
command.Parameters.AddWithValue("$id", channel.Id);
|
command.Parameters.AddWithValue("$id", channel.Id);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
audio_dtx INTEGER NOT NULL DEFAULT 1,
|
audio_dtx INTEGER NOT NULL DEFAULT 1,
|
||||||
audio_complexity INTEGER NOT NULL DEFAULT 5,
|
audio_complexity INTEGER NOT NULL DEFAULT 5,
|
||||||
audio_dred INTEGER NOT NULL DEFAULT 0,
|
audio_dred INTEGER NOT NULL DEFAULT 0,
|
||||||
|
audio_packet_loss_mode INTEGER NOT NULL DEFAULT 0,
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS bans (
|
CREATE TABLE IF NOT EXISTS bans (
|
||||||
|
|||||||
@@ -15,13 +15,15 @@ internal sealed class MediaFanout : IDisposable
|
|||||||
private int length;
|
private int length;
|
||||||
private int index;
|
private int index;
|
||||||
|
|
||||||
public bool TryStart(ReadOnlySpan<byte> packet, MediaRoute sender, MediaRoute[] recipients)
|
public bool TryStart(ReadOnlySpan<byte> packet, MediaRoute sender, MediaRoute[] recipients, out PacketLossSample? loss)
|
||||||
{
|
{
|
||||||
|
loss = null;
|
||||||
source = null;
|
source = null;
|
||||||
if (!VoiceFrameHeader.TryRead(packet, out var candidate) || candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
|
if (!VoiceFrameHeader.TryRead(packet, out var candidate) || candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
|
||||||
!sender.Subscribed || sender.Muted || !sender.Sources.Contains(candidate.Ssrc) ||
|
!sender.Subscribed || sender.Muted || !sender.Sources.Contains(candidate.Ssrc) ||
|
||||||
packet.Length <= VoiceFrameHeader.Size + 16 || packet.Length > output.Length) return false;
|
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;
|
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;
|
source = sender;
|
||||||
routes = recipients;
|
routes = recipients;
|
||||||
index = 0;
|
index = 0;
|
||||||
|
|||||||
@@ -5,20 +5,23 @@ using System.Net.Sockets;
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using VoiceCat.Protocol;
|
using VoiceCat.Protocol;
|
||||||
|
using PacketLossMode = Voicecat.V1.PacketLossMode;
|
||||||
|
|
||||||
namespace VoiceCat.Server.Transport;
|
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 byte[] Token { get; } = token;
|
||||||
public MediaSessionCrypto Crypto { get; } = crypto;
|
public MediaSessionCrypto Crypto { get; } = crypto;
|
||||||
public SessionActivity Activity { get; } = activity ?? new(TimeProvider.System);
|
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.
|
// Only the UDP loop reads or changes the endpoint and binding state.
|
||||||
public SocketAddress? Endpoint { get; set; }
|
public SocketAddress? Endpoint { get; set; }
|
||||||
public void Dispose() { Crypto.Dispose(); CryptographicOperations.ZeroMemory(Token); }
|
public void Dispose() { Crypto.Dispose(); CryptographicOperations.ZeroMemory(Token); }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record MediaRoute(MediaPeer Peer, uint ChannelId, bool Subscribed, bool Muted, bool Deafened, uint[] Sources);
|
internal sealed record MediaRoute(MediaPeer Peer, uint ChannelId, bool Subscribed, bool Muted, bool Deafened, uint[] Sources,
|
||||||
|
PacketLossMode LossMode = PacketLossMode.PacketLossManual, Action<PacketLossSample>? LossUpdated = null);
|
||||||
|
|
||||||
internal sealed class MediaRelay : IAsyncDisposable
|
internal sealed class MediaRelay : IAsyncDisposable
|
||||||
{
|
{
|
||||||
@@ -111,8 +114,9 @@ internal sealed class MediaRelay : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
continue;
|
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();
|
source.Peer.Activity.Touch();
|
||||||
|
if (loss is { } sample) source.LossUpdated?.Invoke(sample);
|
||||||
while (fanout.TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint))
|
while (fanout.TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint))
|
||||||
await SendAsync(packet, endpoint!).ConfigureAwait(false);
|
await SendAsync(packet, endpoint!).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using Voicecat.V1;
|
||||||
|
|
||||||
|
namespace VoiceCat.Server.Transport;
|
||||||
|
|
||||||
|
internal readonly record struct PacketLossSample(uint MeasuredPercent, uint AppliedPercent);
|
||||||
|
|
||||||
|
internal sealed class PacketLossTracker(TimeProvider clock)
|
||||||
|
{
|
||||||
|
// Cumulative expected/received deltas follow the RTCP receiver-report model. Fixed
|
||||||
|
// checkpoints avoid allocating on the UDP owner while still permitting late packets to
|
||||||
|
// correct a later interval.
|
||||||
|
private readonly Checkpoint[] checkpoints = new Checkpoint[34];
|
||||||
|
private int next, count;
|
||||||
|
private bool initialized;
|
||||||
|
private ulong firstSequence, highestSequence, received;
|
||||||
|
private long lastCheckpoint;
|
||||||
|
private uint channelId;
|
||||||
|
private PacketLossMode mode;
|
||||||
|
private int lastApplied = -1;
|
||||||
|
|
||||||
|
internal PacketLossSample? Observe(ulong sequence, uint channel, PacketLossMode currentMode)
|
||||||
|
{
|
||||||
|
if (currentMode == PacketLossMode.PacketLossManual)
|
||||||
|
{
|
||||||
|
Reset(channel, currentMode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (channel != channelId || currentMode != mode) Reset(channel, currentMode);
|
||||||
|
|
||||||
|
long now = clock.GetTimestamp();
|
||||||
|
if (!initialized)
|
||||||
|
{
|
||||||
|
initialized = true;
|
||||||
|
firstSequence = highestSequence = sequence;
|
||||||
|
received = 1;
|
||||||
|
lastCheckpoint = now;
|
||||||
|
AddCheckpoint(now, 1, 1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sequence > highestSequence) highestSequence = sequence;
|
||||||
|
received++;
|
||||||
|
if (clock.GetElapsedTime(lastCheckpoint, now) < TimeSpan.FromSeconds(1)) return null;
|
||||||
|
lastCheckpoint = now;
|
||||||
|
ulong expected = highestSequence - firstSequence + 1;
|
||||||
|
AddCheckpoint(now, expected, received);
|
||||||
|
|
||||||
|
int seconds = currentMode switch
|
||||||
|
{
|
||||||
|
PacketLossMode.PacketLossAutoFast => 3,
|
||||||
|
PacketLossMode.PacketLossAutoBalanced => 10,
|
||||||
|
PacketLossMode.PacketLossAutoStable => 30,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(currentMode))
|
||||||
|
};
|
||||||
|
Checkpoint? baseline = null;
|
||||||
|
for (int i = 1; i < count; i++)
|
||||||
|
{
|
||||||
|
Checkpoint candidate = checkpoints[(next - 1 - i + checkpoints.Length) % checkpoints.Length];
|
||||||
|
if (clock.GetElapsedTime(candidate.Timestamp, now) >= TimeSpan.FromSeconds(seconds))
|
||||||
|
{ baseline = candidate; break; }
|
||||||
|
}
|
||||||
|
if (baseline is null) return null;
|
||||||
|
ulong expectedDelta = expected - baseline.Value.Expected;
|
||||||
|
ulong receivedDelta = received - baseline.Value.Received;
|
||||||
|
if (expectedDelta < 20) return null;
|
||||||
|
ulong lost = expectedDelta > receivedDelta ? expectedDelta - receivedDelta : 0;
|
||||||
|
uint measured = checked((uint)Math.Round(lost * 100.0 / expectedDelta, MidpointRounding.AwayFromZero));
|
||||||
|
uint applied = Math.Min(measured, 30);
|
||||||
|
if (applied == lastApplied) return null;
|
||||||
|
lastApplied = checked((int)applied);
|
||||||
|
return new(measured, applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddCheckpoint(long timestamp, ulong expected, ulong receivedCount)
|
||||||
|
{
|
||||||
|
checkpoints[next] = new(timestamp, expected, receivedCount);
|
||||||
|
next = (next + 1) % checkpoints.Length;
|
||||||
|
if (count < checkpoints.Length) count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reset(uint channel, PacketLossMode currentMode)
|
||||||
|
{
|
||||||
|
channelId = channel;
|
||||||
|
mode = currentMode;
|
||||||
|
next = count = 0;
|
||||||
|
initialized = false;
|
||||||
|
firstSequence = highestSequence = received = 0;
|
||||||
|
lastCheckpoint = 0;
|
||||||
|
lastApplied = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct Checkpoint(long Timestamp, ulong Expected, ulong Received);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using System.Security.Cryptography;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using VoiceCat.Crypto;
|
using VoiceCat.Crypto;
|
||||||
|
using VoiceCat.Protocol;
|
||||||
using VoiceCat.Server.Data;
|
using VoiceCat.Server.Data;
|
||||||
using VoiceCat.Server.Transport;
|
using VoiceCat.Server.Transport;
|
||||||
using Voicecat.V1;
|
using Voicecat.V1;
|
||||||
@@ -128,8 +129,10 @@ public sealed partial class VoiceServer : IAsyncDisposable
|
|||||||
Reject(session, "Unsupported protocol version or banned address.");
|
Reject(session, "Unsupported protocol version or banned address.");
|
||||||
break;
|
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)) };
|
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");
|
if (allowGuests) hello.AuthMethods.Add("guest");
|
||||||
hello.AuthMethods.Add("password");
|
hello.AuthMethods.Add("password");
|
||||||
session.Connection.TrySend(new() { RequestId = envelope.RequestId, ServerHello = hello });
|
session.Connection.TrySend(new() { RequestId = envelope.RequestId, ServerHello = hello });
|
||||||
@@ -315,9 +318,17 @@ public sealed partial class VoiceServer : IAsyncDisposable
|
|||||||
|
|
||||||
private void PublishMedia()
|
private void PublishMedia()
|
||||||
{
|
{
|
||||||
media.Publish(sessions.Values.Where(peer => peer.User is not null && !peer.Closing).Select(peer => new MediaRoute(
|
media.Publish(sessions.Values.Where(peer => peer.User is not null && !peer.Closing).Select(peer =>
|
||||||
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());
|
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() } });
|
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 bool Closing { get; set; }
|
||||||
public string DepartureReason { get; set; } = "";
|
public string DepartureReason { get; set; } = "";
|
||||||
public bool HelloReceived { get; set; }
|
public bool HelloReceived { get; set; }
|
||||||
|
public HashSet<string> Features { get; } = new(StringComparer.Ordinal);
|
||||||
public User? User { get; set; }
|
public User? User { get; set; }
|
||||||
public Permissions Permissions { get; set; } = new();
|
public Permissions Permissions { get; set; } = new();
|
||||||
public MediaPeer? Media { get; set; }
|
public MediaPeer? Media { get; set; }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using VoiceCat.Server.Data;
|
using VoiceCat.Server.Data;
|
||||||
|
using Voicecat.V1;
|
||||||
|
|
||||||
namespace VoiceCat.Tests;
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ public sealed class AccountStoreTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VersionTwoDatabaseMigratesDredAsDisabled()
|
public void VersionTwoDatabaseMigratesDredAndAdaptiveLossAsDisabled()
|
||||||
{
|
{
|
||||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-v2-" + Guid.NewGuid().ToString("N"));
|
string directory = Path.Combine(Path.GetTempPath(), "voicecat-v2-" + Guid.NewGuid().ToString("N"));
|
||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
@@ -68,18 +69,22 @@ public sealed class AccountStoreTests
|
|||||||
{
|
{
|
||||||
connection.Open();
|
connection.Open();
|
||||||
using var command = connection.CreateCommand();
|
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();
|
command.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
using (var migrated = new AccountStore(path))
|
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());
|
using var verify = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
|
||||||
verify.Open();
|
verify.Open();
|
||||||
using var query = verify.CreateCommand();
|
using var query = verify.CreateCommand();
|
||||||
query.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
|
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); }
|
finally { Directory.Delete(directory, true); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,26 @@ public class AudioEngineTests
|
|||||||
Assert.True((sent[^1].Flags & VoiceFrameFlags.Marker) != 0);
|
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]
|
[Theory]
|
||||||
[InlineData(5)] [InlineData(10)] [InlineData(20)] [InlineData(40)] [InlineData(60)]
|
[InlineData(5)] [InlineData(10)] [InlineData(20)] [InlineData(40)] [InlineData(60)]
|
||||||
public void RecoveryLookaheadTracksChannelFrameDuration(int frameMilliseconds)
|
public void RecoveryLookaheadTracksChannelFrameDuration(int frameMilliseconds)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using VoiceCat.Server.Data;
|
using VoiceCat.Server.Data;
|
||||||
|
using VoiceCat.Protocol;
|
||||||
using Voicecat.V1;
|
using Voicecat.V1;
|
||||||
using static VoiceCat.Tests.ServerTests;
|
using static VoiceCat.Tests.ServerTests;
|
||||||
|
|
||||||
@@ -11,7 +12,8 @@ public class ChannelManagementTests
|
|||||||
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||||
await store.CreateAccountAsync("Admin", "secret", true);
|
await store.CreateAccountAsync("Admin", "secret", true);
|
||||||
Client client = await fixture.ConnectAsync();
|
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);
|
await client.ReadUntilAsync(e => e.ServerHello is not null);
|
||||||
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } });
|
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } });
|
||||||
Assert.True((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
|
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.Dtx = true;
|
||||||
music.Audio.Fec = true;
|
music.Audio.Fec = true;
|
||||||
music.Audio.Dred = true;
|
music.Audio.Dred = true;
|
||||||
|
music.Audio.PacketLossMode = PacketLossMode.PacketLossAutoStable;
|
||||||
music.Audio.Complexity = 10;
|
music.Audio.Complexity = 10;
|
||||||
Assert.True(await ResultAsync(admin, new() { EditChannel = new() { Channel = music } }));
|
Assert.True(await ResultAsync(admin, new() { EditChannel = new() { Channel = music } }));
|
||||||
}
|
}
|
||||||
@@ -126,6 +129,36 @@ public class ChannelManagementTests
|
|||||||
Assert.True(audio.Dtx);
|
Assert.True(audio.Dtx);
|
||||||
Assert.True(audio.Fec);
|
Assert.True(audio.Fec);
|
||||||
Assert.True(audio.Dred);
|
Assert.True(audio.Dred);
|
||||||
|
Assert.Equal(PacketLossMode.PacketLossAutoStable, audio.PacketLossMode);
|
||||||
Assert.Equal(10U, audio.Complexity);
|
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
|
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()
|
public static IEnumerable<object[]> Formats()
|
||||||
{
|
{
|
||||||
foreach (int rate in new[] { 8000, 12000, 16000, 24000, 48000 })
|
foreach (int rate in new[] { 8000, 12000, 16000, 24000, 48000 })
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ public sealed class MediaFanoutTests(ITestOutputHelper output)
|
|||||||
void Cycle()
|
void Cycle()
|
||||||
{
|
{
|
||||||
sender.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), payload, packet);
|
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;
|
int recipients = 0;
|
||||||
while (fanout.TryNext(out var next, out _)) { last = next; recipients++; }
|
while (fanout.TryNext(out var next, out _)) { last = next; recipients++; }
|
||||||
if (recipients != 50) throw new InvalidOperationException("Incorrect fanout.");
|
if (recipients != 50) throw new InvalidOperationException("Incorrect fanout.");
|
||||||
|
|||||||
@@ -10,6 +10,34 @@ namespace VoiceCat.Tests;
|
|||||||
|
|
||||||
public sealed class MediaRelayTests
|
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]
|
[Fact]
|
||||||
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
|
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
|
||||||
{
|
{
|
||||||
@@ -162,6 +190,10 @@ public sealed class MediaRelayTests
|
|||||||
{
|
{
|
||||||
Client client = await fixture.ConnectAsync();
|
Client client = await fixture.ConnectAsync();
|
||||||
await client.LoginAsync(nickname);
|
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());
|
var peer = new VoicePeer(client, fixture.Server.MediaEndPoint, await client.TakeMediaCryptoAsync());
|
||||||
client.Send(new() { UdpBinding = new() { UdpToken = client.Authentication!.UdpToken } });
|
client.Send(new() { UdpBinding = new() { UdpToken = client.Authentication!.UdpToken } });
|
||||||
Assert.True((await client.ReadUntilAsync(e => e.UdpBinding is not null)).UdpBinding.Ack);
|
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(); }
|
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.Crypto;
|
||||||
using VoiceCat.Server;
|
using VoiceCat.Server;
|
||||||
using VoiceCat.Server.Transport;
|
using VoiceCat.Server.Transport;
|
||||||
|
using VoiceCat.Protocol;
|
||||||
using Voicecat.V1;
|
using Voicecat.V1;
|
||||||
|
|
||||||
namespace VoiceCat.Tests;
|
namespace VoiceCat.Tests;
|
||||||
@@ -135,13 +136,21 @@ public sealed class ServerTests
|
|||||||
public Task<MediaSessionCrypto> TakeMediaCryptoAsync() => connection.TakeMediaCryptoAsync(Timeout.Token);
|
public Task<MediaSessionCrypto> TakeMediaCryptoAsync() => connection.TakeMediaCryptoAsync(Timeout.Token);
|
||||||
public async Task<Envelope> ReadUntilAsync(Func<Envelope, bool> predicate)
|
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.");
|
throw new IOException("Connection ended before the expected message.");
|
||||||
}
|
}
|
||||||
public async Task<User> LoginAsync(string nickname)
|
public async Task<User> LoginAsync(string nickname)
|
||||||
{
|
{
|
||||||
Send(new() { RequestId = 1, ClientHello = new() { ProtoVersion = 2, ClientName = "Managed test" } });
|
var hello = new ClientHello { ProtoVersion = 2, ClientName = "Managed test" };
|
||||||
Assert.Equal(1UL, (await ReadUntilAsync(e => e.ServerHello is not null)).RequestId);
|
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 } } });
|
Send(new() { RequestId = 2, AuthRequest = new() { Guest = new() { Nickname = nickname } } });
|
||||||
AuthResult auth = (await ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
|
AuthResult auth = (await ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
|
||||||
Authentication = auth;
|
Authentication = auth;
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ public class WindowsManagedClientTests
|
|||||||
using var client = new Client("Admin", "test", tofuStorePath: Path.Combine(fixture.Directory, "admin.pins"));
|
using var client = new Client("Admin", "test", tofuStorePath: Path.Combine(fixture.Directory, "admin.pins"));
|
||||||
await Login(client, fixture, true);
|
await Login(client, fixture, true);
|
||||||
ChannelInfo music = client.ListChannels().Single(c => c.Name == "Music Room");
|
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,
|
var edit = new ChannelEditInfo(music.Id, music.ParentId, music.Name, music.Topic,
|
||||||
music.PasswordProtected, null, music.MaxUsers, music.SortOrder, expected);
|
music.PasswordProtected, null, music.MaxUsers, music.SortOrder, expected);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user