Persist channel DRED settings
This commit is contained in:
+4
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user