Persist channel DRED settings
Build and test / test (macos-latest) (push) Waiting to run
Build and test / test (ubuntu-24.04) (push) Waiting to run
Build and test / test (windows-latest) (push) Waiting to run
Build and test / apple-client (push) Waiting to run

This commit is contained in:
2026-09-22 14:11:36 +02:00
parent 8471eac363
commit 7c4708c3de
9 changed files with 101 additions and 11 deletions
+4 -1
View File
@@ -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
+3 -2
View File
@@ -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,
500512000 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
+2 -2
View File
@@ -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<uint>();
+12 -4
View File
@@ -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
}
});
}
+2 -2
View File
@@ -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);
+1
View File
@@ -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 (
+29
View File
@@ -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); }
}
}
@@ -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);
}
}
@@ -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()
{