Persist channel DRED settings
This commit is contained in:
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
# VoiceCat status
|
# VoiceCat status
|
||||||
|
|
||||||
Updated: 2026-09-20
|
Updated: 2026-09-22
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
@@ -25,6 +25,9 @@ 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
|
||||||
|
disabled until explicitly enabled.
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
+3
-2
@@ -228,7 +228,8 @@ 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 v2 has no DRED column; CRUD rejects DRED rather than silently losing it on restart.
|
Database v3 persists DRED with the rest of the channel audio configuration. Opening a v1 or v2
|
||||||
|
database adds the DRED column with a disabled default before accepting channel updates.
|
||||||
|
|
||||||
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.
|
||||||
@@ -283,7 +284,7 @@ The reaper sends a fatal disconnect, removes presence/routing and broadcasts one
|
|||||||
event. Valid UDP activity keeps a TCP-idle client alive. Shutdown cancels and awaits
|
event. Valid UDP activity keeps a TCP-idle client alive. Shutdown cancels and awaits
|
||||||
the accept, reaper, control and media loops before disposing credentials/storage.
|
the accept, reaper, control and media loops before disposing credentials/storage.
|
||||||
|
|
||||||
`AccountStore(path)` uses schema version 2 and accepts version 1 migration,
|
`AccountStore(path)` uses schema version 3 and accepts version 1 and 2 migrations,
|
||||||
and rejects unknown revisions. Opening an existing channel table does not reseed it.
|
and rejects unknown revisions. Opening an existing channel table does not reseed it.
|
||||||
Account creation/authentication uses parameterized SQL; two password workers bound
|
Account creation/authentication uses parameterized SQL; two password workers bound
|
||||||
per-store Argon2 work. Failed authentication leaves `last_login` unchanged. Dispose
|
per-store Argon2 work. Failed authentication leaves `last_login` unchanged. Dispose
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ 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.Dred ||
|
a.Complexity > 10 || a.ExpectedPacketLoss > 100 ||
|
||||||
!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 (database v2 cannot persist DRED).");
|
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.");
|
||||||
uint parent = channel.ParentId;
|
uint parent = channel.ParentId;
|
||||||
var visited = new HashSet<uint>();
|
var visited = new HashSet<uint>();
|
||||||
|
|||||||
@@ -27,13 +27,21 @@ public sealed partial class AccountStore : IDisposable
|
|||||||
version.Transaction = transaction;
|
version.Transaction = transaction;
|
||||||
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();
|
||||||
if (stored is not null && (!int.TryParse((string)stored, NumberStyles.None, CultureInfo.InvariantCulture, out int revision) || revision is < 1 or > 2))
|
int revision = 0;
|
||||||
|
if (stored is not null && (!int.TryParse((string)stored, NumberStyles.None, CultureInfo.InvariantCulture, out revision) || revision is < 1 or > 3))
|
||||||
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);
|
||||||
using var migrate = connection.CreateCommand();
|
using var migrate = connection.CreateCommand();
|
||||||
migrate.Transaction = transaction;
|
migrate.Transaction = transaction;
|
||||||
migrate.CommandText = reader.ReadToEnd() + "INSERT INTO server_meta (key,value) VALUES ('schema_version','2') ON CONFLICT(key) DO UPDATE SET value='2';";
|
migrate.CommandText = reader.ReadToEnd();
|
||||||
|
migrate.ExecuteNonQuery();
|
||||||
|
if (stored is not null && revision < 3)
|
||||||
|
{
|
||||||
|
migrate.CommandText = "ALTER TABLE channels ADD COLUMN audio_dred INTEGER NOT NULL DEFAULT 0;";
|
||||||
|
migrate.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
migrate.CommandText = "INSERT INTO server_meta (key,value) VALUES ('schema_version','3') ON CONFLICT(key) DO UPDATE SET value='3';";
|
||||||
migrate.ExecuteNonQuery();
|
migrate.ExecuteNonQuery();
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
@@ -121,7 +129,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_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity,audio_dred
|
||||||
FROM channels ORDER BY sort_order,id
|
FROM channels ORDER BY sort_order,id
|
||||||
""";
|
""";
|
||||||
using var reader = command.ExecuteReader();
|
using var reader = command.ExecuteReader();
|
||||||
@@ -139,7 +147,7 @@ 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))
|
Dtx = reader.GetInt32(16) != 0, Complexity = checked((uint)reader.GetInt64(17)), Dred = reader.GetInt32(18) != 0
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"];
|
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"];
|
||||||
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];
|
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];
|
||||||
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);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
audio_expected_packet_loss INTEGER NOT NULL DEFAULT 10,
|
audio_expected_packet_loss INTEGER NOT NULL DEFAULT 10,
|
||||||
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,
|
||||||
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 (
|
||||||
|
|||||||
@@ -54,4 +54,33 @@ public sealed class AccountStoreTests
|
|||||||
}
|
}
|
||||||
finally { Directory.Delete(directory, true); }
|
finally { Directory.Delete(directory, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VersionTwoDatabaseMigratesDredAsDisabled()
|
||||||
|
{
|
||||||
|
string directory = Path.Combine(Path.GetTempPath(), "voicecat-v2-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
string path = Path.Combine(directory, "voicecat.db");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var store = new AccountStore(path)) store.LoadChannels();
|
||||||
|
using (var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString()))
|
||||||
|
{
|
||||||
|
connection.Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "ALTER TABLE channels DROP COLUMN audio_dred; UPDATE server_meta SET value='2' WHERE key='schema_version';";
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var migrated = new AccountStore(path))
|
||||||
|
Assert.All(migrated.LoadChannels(), channel => Assert.False(channel.Audio.Dred));
|
||||||
|
|
||||||
|
using var verify = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
|
||||||
|
verify.Open();
|
||||||
|
using var query = verify.CreateCommand();
|
||||||
|
query.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
|
||||||
|
Assert.Equal("3", query.ExecuteScalar());
|
||||||
|
}
|
||||||
|
finally { Directory.Delete(directory, true); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,4 +99,33 @@ public class ChannelManagementTests
|
|||||||
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = child.Id } }));
|
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = child.Id } }));
|
||||||
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } }));
|
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DredAudioSettingsRoundTripThroughEditAndDatabaseRestart()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var admin = await AdminAsync(fixture);
|
||||||
|
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||||
|
{
|
||||||
|
Channel music = store.LoadChannels().Single(c => c.Name == "Music Room");
|
||||||
|
music.Audio.SampleRate = 48_000;
|
||||||
|
music.Audio.BitrateBps = 128_000;
|
||||||
|
music.Audio.ExpectedPacketLoss = 15;
|
||||||
|
music.Audio.Dtx = true;
|
||||||
|
music.Audio.Fec = true;
|
||||||
|
music.Audio.Dred = true;
|
||||||
|
music.Audio.Complexity = 10;
|
||||||
|
Assert.True(await ResultAsync(admin, new() { EditChannel = new() { Channel = music } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var reopened = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||||
|
AudioConfig audio = reopened.LoadChannels().Single(c => c.Name == "Music Room").Audio;
|
||||||
|
Assert.Equal(48_000U, audio.SampleRate);
|
||||||
|
Assert.Equal(128_000U, audio.BitrateBps);
|
||||||
|
Assert.Equal(15U, audio.ExpectedPacketLoss);
|
||||||
|
Assert.True(audio.Dtx);
|
||||||
|
Assert.True(audio.Fec);
|
||||||
|
Assert.True(audio.Dred);
|
||||||
|
Assert.Equal(10U, audio.Complexity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,25 @@ public class WindowsManagedClientTests
|
|||||||
Assert.Empty(alice.ManagedClient.LocalStreams);
|
Assert.Empty(alice.ManagedClient.LocalStreams);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WindowsChannelEditorSettingsRoundTripFromServer()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
using (var accounts = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||||
|
await accounts.CreateAccountAsync("Admin", "secret", true);
|
||||||
|
using var client = new Client("Admin", "test", tofuStorePath: Path.Combine(fixture.Directory, "admin.pins"));
|
||||||
|
await Login(client, fixture, true);
|
||||||
|
ChannelInfo music = client.ListChannels().Single(c => c.Name == "Music Room");
|
||||||
|
var expected = new AudioConfigInfo(0, true, 48_000, 128_000, 20, 1, true, 15, true, 10, true);
|
||||||
|
var edit = new ChannelEditInfo(music.Id, music.ParentId, music.Name, music.Topic,
|
||||||
|
music.PasswordProtected, null, music.MaxUsers, music.SortOrder, expected);
|
||||||
|
|
||||||
|
Assert.Equal(VcResult.Ok, client.EditChannel(edit));
|
||||||
|
await Until(client, () => client.ListChannels().Single(c => c.Id == music.Id).Audio.Dred);
|
||||||
|
|
||||||
|
Assert.Equal(expected, client.ListChannels().Single(c => c.Id == music.Id).Audio);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WindowsScreenAudioPreservesDistinctStereoChannels()
|
public async Task WindowsScreenAudioPreservesDistinctStereoChannels()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user