diff --git a/PROGRESS.md b/PROGRESS.md index faf3517..24954a3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,6 +1,6 @@ # VoiceCat status -Updated: 2026-09-20 +Updated: 2026-09-22 ## 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 pattern constant. +SQLite schema v3 persists channel DRED settings and migrates existing v1/v2 databases with DRED +disabled until explicitly enabled. + ## Release gates - Run real multi-person calls on Windows, macOS, and physical iOS hardware, including adaptive diff --git a/docs/api-dotnet.md b/docs/api-dotnet.md index 9ee83a8..95e6585 100644 --- a/docs/api-dotnet.md +++ b/docs/api-dotnet.md @@ -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 are limited to 128/4096/1024 UTF-8 bytes. Audio requires Opus, 48 kHz, mono/stereo, 500–512000 bps, integral 5/10/20/40/60 ms frames and valid application/loss/complexity. -Database 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 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 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. Account creation/authentication uses parameterized SQL; two password workers bound per-store Argon2 work. Failed authentication leaves `last_login` unchanged. Dispose diff --git a/src/VoiceCat.Server/ChannelManagement.cs b/src/VoiceCat.Server/ChannelManagement.cs index 010ba38..d0ad253 100644 --- a/src/VoiceCat.Server/ChannelManagement.cs +++ b/src/VoiceCat.Server/ChannelManagement.cs @@ -65,9 +65,9 @@ public sealed partial class VoiceServer Encoding.UTF8.GetByteCount(channel.Topic) > 4096 || Encoding.UTF8.GetByteCount(password) > 1024 || !Enum.IsDefined(channel.Type) || channel.MaxUsers > int.MaxValue || a is null || a.Codec != 0 || !Enum.IsDefined(a.Mode) || !Enum.IsDefined(a.Application) || a.SampleRate != 48000 || a.BitrateBps is < 500 or > 512000 || a.FrameMs is not (5 or 10 or 20 or 40 or 60) || - a.Complexity > 10 || a.ExpectedPacketLoss > 100 || a.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)) - 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."); uint parent = channel.ParentId; var visited = new HashSet(); diff --git a/src/VoiceCat.Server/Data/AccountStore.cs b/src/VoiceCat.Server/Data/AccountStore.cs index 5134d6d..80627a5 100644 --- a/src/VoiceCat.Server/Data/AccountStore.cs +++ b/src/VoiceCat.Server/Data/AccountStore.cs @@ -27,13 +27,21 @@ public sealed partial class AccountStore : IDisposable version.Transaction = transaction; version.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'"; 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."); using var resource = typeof(AccountStore).Assembly.GetManifestResourceStream("VoiceCat.Server.Data.schema.sql")!; using var reader = new StreamReader(resource); using var migrate = connection.CreateCommand(); 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(); transaction.Commit(); } @@ -121,7 +129,7 @@ public sealed partial class AccountStore : IDisposable command.CommandText = """ SELECT id,parent_id,name,topic,password_hash,max_users,type,sort_order, audio_codec,audio_mode,audio_sample_rate,audio_bitrate_bps,audio_frame_ms, - audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity + audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity,audio_dred FROM channels ORDER BY sort_order,id """; 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)), FrameMs = checked((uint)reader.GetInt64(12)), Application = (Voicecat.V1.OpusApplication)reader.GetInt32(13), Fec = reader.GetInt32(14) != 0, ExpectedPacketLoss = checked((uint)reader.GetInt64(15)), - Dtx = reader.GetInt32(16) != 0, Complexity = checked((uint)reader.GetInt64(17)) + Dtx = reader.GetInt32(16) != 0, Complexity = checked((uint)reader.GetInt64(17)), Dred = reader.GetInt32(18) != 0 } }); } diff --git a/src/VoiceCat.Server/Data/ChannelStore.cs b/src/VoiceCat.Server/Data/ChannelStore.cs index 52db1db..ed8b507 100644 --- a/src/VoiceCat.Server/Data/ChannelStore.cs +++ b/src/VoiceCat.Server/Data/ChannelStore.cs @@ -43,9 +43,9 @@ public sealed partial class AccountStore byte[] salt = RandomNumberGenerator.GetBytes(16); hash = Convert.ToHexString(salt).ToLowerInvariant() + ":" + Convert.ToHexString(ChannelDigest(password, salt)).ToLowerInvariant(); } - string[] columns = ["parent_id", "name", "topic", "max_users", "type", "sort_order", "audio_codec", "audio_mode", "audio_sample_rate", "audio_bitrate_bps", "audio_frame_ms", "audio_application", "audio_fec", "audio_expected_packet_loss", "audio_dtx", "audio_complexity"]; + 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; - 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]); command.Parameters.AddWithValue("$hash", hash); command.Parameters.AddWithValue("$id", channel.Id); diff --git a/src/VoiceCat.Server/Data/schema.sql b/src/VoiceCat.Server/Data/schema.sql index f5a0113..aa220c1 100644 --- a/src/VoiceCat.Server/Data/schema.sql +++ b/src/VoiceCat.Server/Data/schema.sql @@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS channels ( audio_expected_packet_loss INTEGER NOT NULL DEFAULT 10, audio_dtx INTEGER NOT NULL DEFAULT 1, audio_complexity INTEGER NOT NULL DEFAULT 5, + audio_dred INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS bans ( diff --git a/tests/VoiceCat.Tests/AccountStoreTests.cs b/tests/VoiceCat.Tests/AccountStoreTests.cs index d51fba8..c7bca04 100644 --- a/tests/VoiceCat.Tests/AccountStoreTests.cs +++ b/tests/VoiceCat.Tests/AccountStoreTests.cs @@ -54,4 +54,33 @@ public sealed class AccountStoreTests } 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); } + } } diff --git a/tests/VoiceCat.Tests/ChannelManagementTests.cs b/tests/VoiceCat.Tests/ChannelManagementTests.cs index fd7dcc3..074bf65 100644 --- a/tests/VoiceCat.Tests/ChannelManagementTests.cs +++ b/tests/VoiceCat.Tests/ChannelManagementTests.cs @@ -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 = 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); + } } diff --git a/tests/VoiceCat.Tests/WindowsManagedClientTests.cs b/tests/VoiceCat.Tests/WindowsManagedClientTests.cs index d4247fa..26cd7bb 100644 --- a/tests/VoiceCat.Tests/WindowsManagedClientTests.cs +++ b/tests/VoiceCat.Tests/WindowsManagedClientTests.cs @@ -78,6 +78,25 @@ public class WindowsManagedClientTests 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] public async Task WindowsScreenAudioPreservesDistinctStereoChannels() {