Retire legacy implementations and flatten managed layout
This commit is contained in:
@@ -1,247 +0,0 @@
|
||||
# Tests use plain asserts + exit codes (no framework dep needed).
|
||||
# Behavior tests — not just "it compiles" — are how milestones are judged (AGENTS.md).
|
||||
|
||||
add_executable(test_smoke test_smoke.cpp)
|
||||
target_link_libraries(test_smoke PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_smoke PRIVATE cxx_std_20)
|
||||
add_test(NAME smoke COMMAND test_smoke)
|
||||
|
||||
# Needs core/src on the include path to reach internal headers (protocol/, session/, etc.).
|
||||
add_executable(test_frame_codec test_frame_codec.cpp)
|
||||
target_link_libraries(test_frame_codec PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_frame_codec PRIVATE cxx_std_20)
|
||||
target_include_directories(test_frame_codec PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
add_test(NAME frame_codec COMMAND test_frame_codec)
|
||||
|
||||
set(VC_TEST_INTERNAL_INCLUDES
|
||||
${CMAKE_SOURCE_DIR}/core/src
|
||||
${CMAKE_SOURCE_DIR}/server/src
|
||||
${CMAKE_BINARY_DIR}/core/generated) # protobuf-generated headers
|
||||
|
||||
add_executable(test_envelope test_envelope.cpp)
|
||||
target_link_libraries(test_envelope PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_envelope PRIVATE cxx_std_20)
|
||||
target_include_directories(test_envelope PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME envelope COMMAND test_envelope)
|
||||
|
||||
add_executable(test_tls_loopback test_tls_loopback.cpp)
|
||||
target_link_libraries(test_tls_loopback PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_tls_loopback PRIVATE cxx_std_20)
|
||||
target_include_directories(test_tls_loopback PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME tls_loopback COMMAND test_tls_loopback)
|
||||
|
||||
# Links voicecat::server (which pulls in voicecat::voicecat + all deps transitively).
|
||||
add_executable(test_m1_integration test_m1_integration.cpp)
|
||||
target_link_libraries(test_m1_integration PRIVATE voicecat::server)
|
||||
target_compile_features(test_m1_integration PRIVATE cxx_std_20)
|
||||
target_include_directories(test_m1_integration PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME m1_integration COMMAND test_m1_integration)
|
||||
set_tests_properties(m1_integration PROPERTIES TIMEOUT 60)
|
||||
|
||||
# ── M2 unit tests ──────────────────────────────────────────────────────────
|
||||
|
||||
add_executable(test_voice_frame test_voice_frame.cpp)
|
||||
target_link_libraries(test_voice_frame PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_voice_frame PRIVATE cxx_std_20)
|
||||
target_include_directories(test_voice_frame PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME voice_frame COMMAND test_voice_frame)
|
||||
|
||||
add_executable(test_media_aead test_media_aead.cpp)
|
||||
target_link_libraries(test_media_aead PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_media_aead PRIVATE cxx_std_20)
|
||||
target_include_directories(test_media_aead PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME media_aead COMMAND test_media_aead)
|
||||
|
||||
add_executable(test_opus_codec test_opus_codec.cpp)
|
||||
target_link_libraries(test_opus_codec PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_opus_codec PRIVATE cxx_std_20)
|
||||
target_include_directories(test_opus_codec PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME opus_codec COMMAND test_opus_codec)
|
||||
|
||||
# PLC cap: after ~2s of pure PLC (no real packets), the mixer emits silence instead of
|
||||
# comfort noise — bounds the eternal-hiss failure mode (defense-in-depth for the
|
||||
# disconnect/LEFT fix). White-box AudioEngine test, no server needed.
|
||||
add_executable(test_plc_cap test_plc_cap.cpp)
|
||||
target_link_libraries(test_plc_cap PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_plc_cap PRIVATE cxx_std_20)
|
||||
target_include_directories(test_plc_cap PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME plc_cap COMMAND test_plc_cap)
|
||||
|
||||
# Jitter-buffer bounded depth: across many talkspurt/silence cycles with a compressed sender
|
||||
# timeline + reordered stragglers, playout latency must stay bounded (no backward drift /
|
||||
# ratchet). White-box AudioEngine test, no server needed.
|
||||
add_executable(test_jitter_depth test_jitter_depth.cpp)
|
||||
target_link_libraries(test_jitter_depth PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_jitter_depth PRIVATE cxx_std_20)
|
||||
target_include_directories(test_jitter_depth PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME jitter_depth COMMAND test_jitter_depth)
|
||||
|
||||
# External playback (iOS VPIO): the mixer-timer thread drives decode+mix with NO hardware
|
||||
# device and delivers the final mix to the mixed-output sink. White-box AudioEngine test.
|
||||
add_executable(test_external_playback test_external_playback.cpp)
|
||||
target_link_libraries(test_external_playback PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_external_playback PRIVATE cxx_std_20)
|
||||
target_include_directories(test_external_playback PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME external_playback COMMAND test_external_playback)
|
||||
|
||||
# M2 exit criterion: two headless clients relay encrypted Opus frames via the SFU.
|
||||
add_executable(test_m2_voice test_m2_voice.cpp)
|
||||
target_link_libraries(test_m2_voice PRIVATE voicecat::server)
|
||||
target_compile_features(test_m2_voice PRIVATE cxx_std_20)
|
||||
target_include_directories(test_m2_voice PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME m2_voice COMMAND test_m2_voice)
|
||||
set_tests_properties(m2_voice PROPERTIES TIMEOUT 120)
|
||||
|
||||
# Same exit criterion, but through the real C ABI (vc_client), not raw sockets —
|
||||
# proves stream_start/stop/UDP-binding in core/src/core/client.cpp actually work.
|
||||
add_executable(test_voice_client_abi test_voice_client_abi.cpp)
|
||||
target_link_libraries(test_voice_client_abi PRIVATE voicecat::server)
|
||||
target_compile_features(test_voice_client_abi PRIVATE cxx_std_20)
|
||||
target_include_directories(test_voice_client_abi PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME voice_client_abi COMMAND test_voice_client_abi)
|
||||
set_tests_properties(voice_client_abi PROPERTIES TIMEOUT 60)
|
||||
|
||||
# M3 exit criterion: multi-stream (mic + desktop audio), independent per-stream
|
||||
# gain/mute/NS, per-channel Opus configurability, talk indicators -- all through the
|
||||
# real C ABI (vc_client), not raw sockets.
|
||||
add_executable(test_m3_multistream test_m3_multistream.cpp)
|
||||
target_link_libraries(test_m3_multistream PRIVATE voicecat::server)
|
||||
target_compile_features(test_m3_multistream PRIVATE cxx_std_20)
|
||||
target_include_directories(test_m3_multistream PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME m3_multistream COMMAND test_m3_multistream)
|
||||
set_tests_properties(m3_multistream PROPERTIES TIMEOUT 60)
|
||||
|
||||
# Post-M3 follow-up: device enumeration, VAD/PTT input gate, true stereo playback mixing —
|
||||
# the items PROGRESS.md's M3 section explicitly carried forward as out of scope.
|
||||
add_executable(test_vad_ptt_devices test_vad_ptt_devices.cpp)
|
||||
target_link_libraries(test_vad_ptt_devices PRIVATE voicecat::server)
|
||||
target_compile_features(test_vad_ptt_devices PRIVATE cxx_std_20)
|
||||
target_include_directories(test_vad_ptt_devices PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME vad_ptt_devices COMMAND test_vad_ptt_devices)
|
||||
set_tests_properties(vad_ptt_devices PROPERTIES TIMEOUT 60)
|
||||
|
||||
# M4: channel/user/stream snapshot getters (vc_list_channels/vc_list_users/
|
||||
# vc_list_user_streams) — through the real C ABI against a real in-process server.
|
||||
add_executable(test_channel_user_list_abi test_channel_user_list_abi.cpp)
|
||||
target_link_libraries(test_channel_user_list_abi PRIVATE voicecat::server)
|
||||
target_compile_features(test_channel_user_list_abi PRIVATE cxx_std_20)
|
||||
target_include_directories(test_channel_user_list_abi PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME channel_user_list_abi COMMAND test_channel_user_list_abi)
|
||||
set_tests_properties(channel_user_list_abi PROPERTIES TIMEOUT 60)
|
||||
|
||||
# M4: TOFU server-identity gate (VC_EVENT_SERVER_IDENTITY / vc_confirm_server_identity /
|
||||
# vc_get_server_identity_display) — real TLS handshakes against real in-process servers.
|
||||
add_executable(test_tofu_flow test_tofu_flow.cpp)
|
||||
target_link_libraries(test_tofu_flow PRIVATE voicecat::server)
|
||||
target_compile_features(test_tofu_flow PRIVATE cxx_std_20)
|
||||
target_include_directories(test_tofu_flow PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME tofu_flow COMMAND test_tofu_flow)
|
||||
set_tests_properties(tofu_flow PROPERTIES TIMEOUT 90)
|
||||
|
||||
# M5 Phase 1: permission enforcement + SetPermissionRequest + channel create.
|
||||
add_executable(test_m5_permissions test_m5_permissions.cpp)
|
||||
target_link_libraries(test_m5_permissions PRIVATE voicecat::server)
|
||||
target_compile_features(test_m5_permissions PRIVATE cxx_std_20)
|
||||
target_include_directories(test_m5_permissions PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME m5_permissions COMMAND test_m5_permissions)
|
||||
set_tests_properties(m5_permissions PROPERTIES TIMEOUT 60)
|
||||
|
||||
# M5 Phase 2: kick/ban/move/server-mute.
|
||||
add_executable(test_m5_kick_ban_move_mute test_m5_kick_ban_move_mute.cpp)
|
||||
target_link_libraries(test_m5_kick_ban_move_mute PRIVATE voicecat::server)
|
||||
target_compile_features(test_m5_kick_ban_move_mute PRIVATE cxx_std_20)
|
||||
target_include_directories(test_m5_kick_ban_move_mute PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME m5_kick_ban_move_mute COMMAND test_m5_kick_ban_move_mute)
|
||||
set_tests_properties(m5_kick_ban_move_mute PROPERTIES TIMEOUT 90)
|
||||
|
||||
# M5 Phase 3: in-app admin account management.
|
||||
add_executable(test_m5_admin_accounts test_m5_admin_accounts.cpp)
|
||||
target_link_libraries(test_m5_admin_accounts PRIVATE voicecat::server)
|
||||
target_compile_features(test_m5_admin_accounts PRIVATE cxx_std_20)
|
||||
target_include_directories(test_m5_admin_accounts PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME m5_admin_accounts COMMAND test_m5_admin_accounts)
|
||||
set_tests_properties(m5_admin_accounts PROPERTIES TIMEOUT 90)
|
||||
|
||||
# M5 Phase 4: channel CRUD & password enforcement.
|
||||
add_executable(test_m5_channel_crud test_m5_channel_crud.cpp)
|
||||
target_link_libraries(test_m5_channel_crud PRIVATE voicecat::server)
|
||||
target_compile_features(test_m5_channel_crud PRIVATE cxx_std_20)
|
||||
target_include_directories(test_m5_channel_crud PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME m5_channel_crud COMMAND test_m5_channel_crud)
|
||||
set_tests_properties(m5_channel_crud PROPERTIES TIMEOUT 90)
|
||||
|
||||
# Disconnect/timeout: server broadcasts UserEvent::LEFT on TCP drop (no more ghost
|
||||
# users or eternal PLC hiss on peers). Also covers the keepalive/reaper paths added
|
||||
# alongside the LEFT-broadcast fix.
|
||||
add_executable(test_disconnect_left test_disconnect_left.cpp)
|
||||
target_link_libraries(test_disconnect_left PRIVATE voicecat::server)
|
||||
target_compile_features(test_disconnect_left PRIVATE cxx_std_20)
|
||||
target_include_directories(test_disconnect_left PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME disconnect_left COMMAND test_disconnect_left)
|
||||
set_tests_properties(disconnect_left PROPERTIES TIMEOUT 90)
|
||||
|
||||
# Reaper: half-open connections (no TCP EOF) are dropped after the configurable timeout,
|
||||
# peers get UserEvent::LEFT, the stale client gets disconnected. Uses a 2s timeout for
|
||||
# fast test turnaround (production default is 45s).
|
||||
add_executable(test_reaper_timeout test_reaper_timeout.cpp)
|
||||
target_link_libraries(test_reaper_timeout PRIVATE voicecat::server)
|
||||
target_compile_features(test_reaper_timeout PRIVATE cxx_std_20)
|
||||
target_include_directories(test_reaper_timeout PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME reaper_timeout COMMAND test_reaper_timeout)
|
||||
set_tests_properties(reaper_timeout PROPERTIES TIMEOUT 30)
|
||||
|
||||
# DRED toggle: per-channel dred flag round-trips through protocol; encoder initialises
|
||||
# with DRED; PCM injection through DRED-enabled encode path runs without crash.
|
||||
add_executable(test_dred_toggle test_dred_toggle.cpp)
|
||||
target_link_libraries(test_dred_toggle PRIVATE voicecat::server)
|
||||
target_compile_features(test_dred_toggle PRIVATE cxx_std_20)
|
||||
target_include_directories(test_dred_toggle PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME dred_toggle COMMAND test_dred_toggle)
|
||||
set_tests_properties(dred_toggle PROPERTIES TIMEOUT 60)
|
||||
|
||||
# External PCM feed/tap API:
|
||||
# test_feed_pcm_round_trip — vc_stream_feed_pcm (mono), verified via pcm_sink.
|
||||
# test_feed_pcm_stereo — vc_stream_feed_pcm (stereo, Music Room), L != R assertion.
|
||||
# test_pcm_sink — vc_set_pcm_sink metadata (user_id / stream_id / sr) + disable.
|
||||
add_executable(test_external_pcm test_external_pcm.cpp)
|
||||
target_link_libraries(test_external_pcm PRIVATE voicecat::server)
|
||||
target_compile_features(test_external_pcm PRIVATE cxx_std_20)
|
||||
target_include_directories(test_external_pcm PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME external_pcm COMMAND test_external_pcm)
|
||||
set_tests_properties(external_pcm PROPERTIES TIMEOUT 90)
|
||||
|
||||
# Non-20ms channel frame_ms: the fixed 48k/20ms engine clock is reframed to the channel's
|
||||
# Opus window before encoding. 40ms (accumulate) and 10ms (split) feed->sink round trips.
|
||||
add_executable(test_frame_ms_reframe test_frame_ms_reframe.cpp)
|
||||
target_link_libraries(test_frame_ms_reframe PRIVATE voicecat::server)
|
||||
target_compile_features(test_frame_ms_reframe PRIVATE cxx_std_20)
|
||||
target_include_directories(test_frame_ms_reframe PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME frame_ms_reframe COMMAND test_frame_ms_reframe)
|
||||
set_tests_properties(frame_ms_reframe PROPERTIES TIMEOUT 90)
|
||||
|
||||
# Per-channel sample_rate as an Opus bandwidth cap (OPUS_SET_MAX_BANDWIDTH): a 7 kHz tone is
|
||||
# attenuated on an 8 kHz (narrowband) channel vs a 48 kHz (full-band) channel.
|
||||
add_executable(test_channel_samplerate test_channel_samplerate.cpp)
|
||||
target_link_libraries(test_channel_samplerate PRIVATE voicecat::server)
|
||||
target_compile_features(test_channel_samplerate PRIVATE cxx_std_20)
|
||||
target_include_directories(test_channel_samplerate PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME channel_samplerate COMMAND test_channel_samplerate)
|
||||
set_tests_properties(channel_samplerate PROPERTIES TIMEOUT 90)
|
||||
|
||||
# Noise suppression: the RNNoise backend behind ApmProcessor actually denoises (docs/voice.md
|
||||
# §10-11). Links core only; reaches the internal audio/ headers for ApmProcessor::create().
|
||||
add_executable(test_noise_suppression test_noise_suppression.cpp)
|
||||
target_link_libraries(test_noise_suppression PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_noise_suppression PRIVATE cxx_std_20)
|
||||
target_include_directories(test_noise_suppression PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME noise_suppression COMMAND test_noise_suppression)
|
||||
|
||||
# Receive-side NR through the decode loop: a stereo VOICE stream is actually denoised
|
||||
# (regression for the stereo-mic bypass), and a screen-audio share is left untouched.
|
||||
# White-box AudioEngine test; paced realtime feed, so it needs a generous timeout.
|
||||
add_executable(test_recv_noise_reduction test_recv_noise_reduction.cpp)
|
||||
target_link_libraries(test_recv_noise_reduction PRIVATE voicecat::voicecat)
|
||||
target_compile_features(test_recv_noise_reduction PRIVATE cxx_std_20)
|
||||
target_include_directories(test_recv_noise_reduction PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||
add_test(NAME recv_noise_reduction COMMAND test_recv_noise_reduction)
|
||||
set_tests_properties(recv_noise_reduction PROPERTIES TIMEOUT 90)
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using VoiceCat.Server.Data;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class AccountStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnsupportedSchemaIsRejectedWithoutCreatingAccountTables()
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), "voicecat-future-" + Guid.NewGuid().ToString("N") + ".db");
|
||||
try
|
||||
{
|
||||
SQLitePCL.Batteries_V2.Init();
|
||||
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "CREATE TABLE server_meta (key TEXT PRIMARY KEY,value TEXT NOT NULL); INSERT INTO server_meta VALUES ('schema_version','99');";
|
||||
command.ExecuteNonQuery();
|
||||
Assert.Throws<InvalidDataException>(() => new AccountStore(path));
|
||||
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE name='accounts'";
|
||||
Assert.Equal(0L, command.ExecuteScalar());
|
||||
command.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
|
||||
Assert.Equal("99", command.ExecuteScalar());
|
||||
}
|
||||
finally { File.Delete(path); File.Delete(path + "-wal"); File.Delete(path + "-shm"); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountsSurviveRestartAndFailedAuthDoesNotChangeLastLogin()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-db-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "voicecat.db");
|
||||
try
|
||||
{
|
||||
Account account;
|
||||
using (var store = new AccountStore(path)) account = await store.CreateAccountAsync("admin'", "secret", true);
|
||||
using (var store = new AccountStore(path))
|
||||
{
|
||||
Assert.Null(await store.AuthenticateAsync("admin'", "wrong"));
|
||||
Assert.Null(await store.AuthenticateAsync("missing", "secret"));
|
||||
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT last_login FROM accounts WHERE id=$id";
|
||||
command.Parameters.AddWithValue("$id", account.Id);
|
||||
Assert.Equal(0L, command.ExecuteScalar());
|
||||
Account authenticated = Assert.IsType<Account>(await store.AuthenticateAsync("admin'", "secret"));
|
||||
Assert.Equal(account.Id, authenticated.Id);
|
||||
Assert.True(authenticated.IsAdmin);
|
||||
Assert.True(authenticated.LastLogin > 0);
|
||||
}
|
||||
}
|
||||
finally { Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using VoiceCat.Server.Data;
|
||||
using Voicecat.V1;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
using static VoiceCat.Tests.ChannelManagementTests;
|
||||
using static VoiceCat.Tests.MediaRelayTests;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class AdministrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AccountAdministrationIsPermissionGatedAndPersistsPasswordChanges()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var guest = await fixture.ConnectAsync();
|
||||
User user = await guest.LoginAsync("Guest");
|
||||
await using var admin = await AdminAsync(fixture);
|
||||
Assert.False(await ResultAsync(guest, new() { ListAccounts = new() }));
|
||||
Assert.False(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "secret" } }));
|
||||
Assert.False(await ResultAsync(guest, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { IsAdmin = true } } }));
|
||||
Assert.True(await ResultAsync(admin, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { CanAdminAccounts = true, CanCreateTempChannel = true } } }));
|
||||
Assert.True(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "first" } }));
|
||||
Assert.False(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "first" } }));
|
||||
Assert.False(await ResultAsync(guest, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { IsAdmin = true } } }));
|
||||
Assert.False(await ResultAsync(guest, new() { CreateChannel = new() { Channel = new() { Name = "Permanent" } } }));
|
||||
Assert.True(await ResultAsync(guest, new() { CreateChannel = new() { Channel = new() { Name = "Temporary", Type = ChannelType.ChannelTemporary,
|
||||
Audio = new() { SampleRate = 48000, BitrateBps = 24000, FrameMs = 20 } } } }));
|
||||
Assert.True(await ResultAsync(guest, new() { ResetPassword = new() { Username = "new", NewPassword = "second" } }));
|
||||
Assert.False(await ResultAsync(guest, new() { ResetPassword = new() { Username = "missing", NewPassword = "second" } }));
|
||||
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||
{
|
||||
Assert.Null(await store.AuthenticateAsync("new", "first"));
|
||||
Assert.NotNull(await store.AuthenticateAsync("new", "second"));
|
||||
}
|
||||
guest.Send(new() { RequestId = 50, ListAccounts = new() });
|
||||
Envelope list = await guest.ReadUntilAsync(e => e.ListAccountsResult is not null);
|
||||
Assert.Equal(50UL, list.RequestId);
|
||||
Assert.Equal(2, list.ListAccountsResult.Accounts.Count);
|
||||
var entry = list.ListAccountsResult.Accounts.Single(a => a.Username == "new");
|
||||
Assert.False(entry.IsAdmin);
|
||||
Assert.True(entry.CreatedAtUnixMs > 1_000_000_000_000);
|
||||
Assert.True(entry.LastLoginUnixMs > 1_000_000_000_000);
|
||||
Assert.True(await ResultAsync(guest, new() { DeleteAccount = new() { Username = "new" } }));
|
||||
Assert.False(await ResultAsync(guest, new() { DeleteAccount = new() { Username = "new" } }));
|
||||
using var reopened = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||
Assert.Null(await reopened.AuthenticateAsync("new", "second"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountBanPersistsByUsernameAndBlocksNewAuthentication()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var admin = await AdminAsync(fixture);
|
||||
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||
await store.CreateAccountAsync("Member", "password");
|
||||
await using var member = await fixture.ConnectAsync();
|
||||
member.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
|
||||
await member.ReadUntilAsync(e => e.ServerHello is not null);
|
||||
member.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = "password" } } });
|
||||
AuthResult auth = (await member.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
|
||||
Assert.True(auth.Ok);
|
||||
Assert.True(await ResultAsync(admin, new() { Ban = new() { UserId = auth.Self.Id, Reason = "account banned" } }));
|
||||
Assert.NotNull((await member.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect);
|
||||
Assert.True(store.IsBanned("username", "Member"));
|
||||
Assert.False(store.IsBanned("ip", "127.0.0.1"));
|
||||
await using var retry = await fixture.ConnectAsync();
|
||||
retry.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
|
||||
await retry.ReadUntilAsync(e => e.ServerHello is not null);
|
||||
retry.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = "password" } } });
|
||||
Assert.False((await retry.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
|
||||
Assert.False(await ResultAsync(admin, new() { Kick = new() { UserId = uint.MaxValue } }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServerMuteDeafenAndMoveImmediatelyChangeEncryptedMediaRouting()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
await using var admin = await AdminAsync(fixture);
|
||||
uint a = alice.Client.Authentication!.Self.Id, b = bob.Client.Authentication!.Self.Id;
|
||||
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
Assert.False(await ResultAsync(bob.Client, new() { ServerMute = new() { UserId = a, Muted = true } }));
|
||||
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = a, Muted = true } }));
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [1])); await bob.AssertNoVoiceAsync();
|
||||
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = a } }));
|
||||
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = b, Deafened = true } }));
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [2])); await bob.AssertNoVoiceAsync();
|
||||
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = b } }));
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [3])); Assert.Equal(new byte[] { 3 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||
Assert.True(await ResultAsync(admin, new() { MoveUser = new() { UserId = a, ChannelId = 2 } }));
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [4])); await bob.AssertNoVoiceAsync();
|
||||
Assert.True(await ResultAsync(admin, new() { MoveUser = new() { UserId = a, ChannelId = 1 } }));
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [5])); await bob.AssertNoVoiceAsync();
|
||||
var replacement = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
await alice.SendAsync(alice.Seal(replacement.Ssrc, [6])); Assert.Equal(new byte[] { 6 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task KickAndGuestBanDisconnectWithOneDepartureAndRetireMedia(bool ban)
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var observer = await VoicePeer.ConnectAsync(fixture, "Observer");
|
||||
await using var admin = await AdminAsync(fixture);
|
||||
uint id = alice.Client.Authentication!.Self.Id;
|
||||
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
Envelope request = ban ? new() { Ban = new() { UserId = id, Reason = "removed", ExpiresUnixMs = (ulong)DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeMilliseconds() } }
|
||||
: new() { Kick = new() { UserId = id, Reason = "removed" } };
|
||||
Assert.True(await ResultAsync(admin, request));
|
||||
Assert.Equal("removed", (await alice.Client.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Reason);
|
||||
var left = (await observer.Client.ReadUntilAsync(e => e.UserEvent?.LeftId == id)).UserEvent;
|
||||
Assert.Equal("removed", left.Reason);
|
||||
observer.Client.Send(new() { Ping = new() { Nonce = 99 } });
|
||||
while (true)
|
||||
{
|
||||
Envelope message = await observer.Client.ReadUntilAsync(_ => true);
|
||||
Assert.False(message.UserEvent?.LeftId == id);
|
||||
if (message.Pong?.Nonce == 99) break;
|
||||
}
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [1])); await observer.AssertNoVoiceAsync();
|
||||
await using var reconnect = await fixture.ConnectAsync();
|
||||
if (ban)
|
||||
{
|
||||
reconnect.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
|
||||
Assert.NotNull((await reconnect.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect);
|
||||
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||
Assert.True(store.IsBanned("ip", "127.0.0.1"));
|
||||
store.Ban("username", "expired", "", 1);
|
||||
Assert.False(store.IsBanned("username", "expired"));
|
||||
}
|
||||
else await reconnect.LoginAsync("Alice");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using VoiceCat.Audio;
|
||||
using VoiceCat.Codec;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class AudioEngineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(-1000)]
|
||||
[InlineData(1000)]
|
||||
public void AdaptivePcmBufferAbsorbsIndependentClockDrift(int partsPerMillion)
|
||||
{
|
||||
var buffer = new AdaptivePcmBuffer(1, 40); short[] input = new short[962], output = new short[960];
|
||||
input.AsSpan().Fill(1234); Assert.True(buffer.TryWrite(input.AsSpan(0, 960)));
|
||||
Assert.True(buffer.TryWrite(input.AsSpan(0, 960)));
|
||||
double produced = 0;
|
||||
for (int cycle = 0; cycle < 10_000; cycle++)
|
||||
{
|
||||
produced += 960 * (1 + partsPerMillion / 1_000_000.0);
|
||||
int frames = (int)produced; produced -= frames;
|
||||
Assert.True(buffer.TryWrite(input.AsSpan(0, frames)));
|
||||
Assert.Equal(output.Length, buffer.Read(output));
|
||||
}
|
||||
Assert.InRange(buffer.CountFrames, 480, 3840);
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int i = 0; i < 100; i++) { buffer.TryWrite(input.AsSpan(0, 960)); buffer.Read(output); }
|
||||
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneCaptureMissDoesNotRestartTalkspurtButSustainedStarvationDoes()
|
||||
{
|
||||
var sent = new List<(uint Timestamp, VoiceFrameFlags Flags)>();
|
||||
using var engine = new AudioEngine((_, timestamp, _, flags) => { sent.Add((timestamp, flags)); return true; }, false)
|
||||
{ InputMode = AudioInputMode.AlwaysOn, DeviceBufferMilliseconds = 20 };
|
||||
engine.AddLocalStream(Stream()); short[] tone = Tone();
|
||||
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
|
||||
engine.ProcessCycle();
|
||||
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
|
||||
Assert.Equal(2, sent.Count); Assert.True((sent[0].Flags & VoiceFrameFlags.Marker) != 0); Assert.Equal(VoiceFrameFlags.None, sent[1].Flags & VoiceFrameFlags.Marker);
|
||||
for (int i = 0; i < 10; i++) engine.ProcessCycle();
|
||||
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
|
||||
Assert.True((sent[^1].Flags & VoiceFrameFlags.Marker) != 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)] [InlineData(10)] [InlineData(20)] [InlineData(40)] [InlineData(60)]
|
||||
public void RecoveryLookaheadTracksChannelFrameDuration(int frameMilliseconds)
|
||||
{
|
||||
using var stream = new ReceiveStream(2, Stream(frameMilliseconds));
|
||||
Assert.Equal(frameMilliseconds * 48, stream.TargetDepthSamples);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JitterTargetAdaptsInSampleTimeAndRemainsCapped()
|
||||
{
|
||||
var clock = new ManualAudioClock(); StreamInfo info = Stream(); info.Audio.Fec = false;
|
||||
using var stream = new ReceiveStream(2, info, clock); using var encoder = new OpusEncoder(new() { Bitrate = 32000 });
|
||||
byte[] packet = new byte[1275]; int length = encoder.Encode(Tone(), packet);
|
||||
for (uint i = 0; i < 40; i++)
|
||||
{
|
||||
clock.Advance(i % 2 == 0 ? 5 : 35);
|
||||
stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, length));
|
||||
}
|
||||
int[] output = new int[1920]; stream.Mix(output, false, null);
|
||||
Assert.InRange(stream.TargetDepthSamples, 960, 5760);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DredUsesTimestampOffsetForConsecutiveMissingShortFrames()
|
||||
{
|
||||
using var probe = new OpusEncoder();
|
||||
if (!probe.SupportsDeepRedundancy) return;
|
||||
StreamInfo info = Stream(10, dred: true); using var stream = new ReceiveStream(2, info);
|
||||
using var encoder = new OpusEncoder(new() { FrameDurationMilliseconds = 10, DeepRedundancy = true, ExpectedPacketLossPercent = 30, Bitrate = 64000 });
|
||||
byte[] packet = new byte[1275]; short[] tone = new short[480]; int[] output = new int[1920];
|
||||
for (uint i = 0; i < 50; i++)
|
||||
{
|
||||
CodecTests.FillTone(tone, 480, 1, 48000, (int)i); int length = encoder.Encode(tone, packet);
|
||||
if (i is not (25 or 26)) stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 480), packet.AsSpan(0, length));
|
||||
if (i % 2 == 1) { output.AsSpan().Clear(); stream.Mix(output, false, null); }
|
||||
}
|
||||
Assert.True(stream.DredFrames >= 2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void LostFramesUseDredThenFecBeforeBoundedPlc(bool useDred)
|
||||
{
|
||||
using var receive = new ReceiveStream(2, Stream(dred: useDred));
|
||||
using var encoder = new OpusEncoder(new() { DeepRedundancy = useDred, ForwardErrorCorrection = true, ExpectedPacketLossPercent = 30, Complexity = 10, Bitrate = 64000 });
|
||||
byte[] packet = new byte[1275]; short[] tone = Tone(); int[] output = new int[1920];
|
||||
for (uint i = 0; i < 40; i++)
|
||||
{
|
||||
CodecTests.FillTone(tone, 960, 1, 48000, (int)i);
|
||||
int size = encoder.Encode(tone, packet);
|
||||
if (i != 25 && i != 30 && i != 35) receive.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, size));
|
||||
output.AsSpan().Clear(); receive.Mix(output, false, null);
|
||||
}
|
||||
if (useDred) Assert.True(receive.DredFrames > 0); else Assert.True(receive.FecFrames > 0);
|
||||
for (int i = 0; i < 20; i++) { output.AsSpan().Clear(); receive.Mix(output, false, null); }
|
||||
Assert.True(receive.ConcealedFrames > 0); Assert.All(output, sample => Assert.Equal(0, sample));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PcmRingDropsWholeFramesWhenFullAndPreservesOrderAcrossWraps()
|
||||
{
|
||||
var ring = new PcmRing(8); short[] output = new short[8];
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
Assert.True(ring.TryWrite([1, 2, 3, 4, 5, 6])); Assert.False(ring.TryWrite([7, 8, 9]));
|
||||
Assert.Equal(4, ring.Read(output.AsSpan(0, 4))); Assert.Equal(new short[] { 1, 2, 3, 4 }, output[..4]);
|
||||
Assert.True(ring.TryWrite([7, 8])); Assert.Equal(4, ring.Read(output)); Assert.Equal(new short[] { 5, 6, 7, 8 }, output[..4]); Assert.Equal(0, ring.Count);
|
||||
}
|
||||
}
|
||||
internal static StreamInfo Stream(int frame = 20, bool stereo = false, bool dred = false) => new()
|
||||
{
|
||||
StreamId = 1, Ssrc = 42, Kind = StreamKind.StreamMic,
|
||||
Audio = new() { SampleRate = 48000, BitrateBps = 32000, FrameMs = (uint)frame, Complexity = 5,
|
||||
Mode = stereo ? ChannelMode.ModeStereo : ChannelMode.ModeMono, Fec = true, ExpectedPacketLoss = 20, Dred = dred }
|
||||
};
|
||||
private static short[] Tone(int channels = 1)
|
||||
{
|
||||
var pcm = new short[960 * channels];
|
||||
for (int i = 0; i < 960; i++) for (int c = 0; c < channels; c++) pcm[i * channels + c] = (short)(Math.Sin(i * 2 * Math.PI * (c == 0 ? 440 : 660) / 48000) * 8000);
|
||||
return pcm;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5, false)] [InlineData(10, false)] [InlineData(20, false)] [InlineData(40, false)] [InlineData(60, false)] [InlineData(20, true)]
|
||||
public void ReframedEncodedPcmIsDecodedAndMixedForMonoAndStereo(int frame, bool stereo)
|
||||
{
|
||||
StreamInfo stream = Stream(frame, stereo);
|
||||
using var receive = new AudioEngine((_, _, _, _) => true, false);
|
||||
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { stream } }], 1, 1);
|
||||
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
|
||||
send.InputMode = AudioInputMode.AlwaysOn; send.AddLocalStream(stream, stereo ? 2 : 1);
|
||||
long energy = 0; int sinkChannels = 0;
|
||||
receive.MixedPcm += pcm => { foreach (short sample in pcm) energy += Math.Abs((int)sample); };
|
||||
receive.StreamPcm += (_, _, _, channels) => sinkChannels = channels;
|
||||
short[] tone = Tone(stereo ? 2 : 1);
|
||||
for (int i = 0; i < 30; i++) { Assert.True(send.FeedPcm(1, tone, stereo ? 2 : 1)); send.ProcessCycle(); receive.ProcessCycle(); }
|
||||
Assert.True(energy > 100000); Assert.Equal(stereo ? 2 : 1, sinkChannels);
|
||||
receive.SetRemotePlayback(2, 1, 1, true, false); energy = 0;
|
||||
for (int i = 0; i < 5; i++) { send.FeedPcm(1, tone, stereo ? 2 : 1); send.ProcessCycle(); receive.ProcessCycle(); }
|
||||
Assert.Equal(0, energy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioCyclesAllocateZeroBytesWithEncodeDecodeStereoNoiseReductionAndMixing()
|
||||
{
|
||||
StreamInfo stream = Stream(20, true);
|
||||
using var receive = new AudioEngine((_, _, _, _) => true, false);
|
||||
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { stream } }], 1, 1);
|
||||
receive.SetRemotePlayback(2, 1, 0.8f, false, true);
|
||||
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
|
||||
send.InputMode = AudioInputMode.AlwaysOn; send.InputNoiseReduction = true; send.AddLocalStream(stream, 2);
|
||||
short[] tone = Tone(2);
|
||||
for (int i = 0; i < 30; i++) { send.FeedPcm(1, tone, 2); send.ProcessCycle(); receive.ProcessCycle(); }
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int i = 0; i < 100; i++) { send.FeedPcm(1, tone, 2); send.ProcessCycle(); receive.ProcessCycle(); }
|
||||
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JitterBacklogIsBoundedAndPlcEventuallyBecomesSilence()
|
||||
{
|
||||
var info = Stream(); using var stream = new ReceiveStream(2, info); using var encoder = new OpusEncoder(new() { Bitrate = 32000 });
|
||||
byte[] packet = new byte[1275]; int length = encoder.Encode(Tone(), packet); int[] output = new int[1920];
|
||||
for (uint i = 0; i < 64; i++) stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, length));
|
||||
stream.Mix(output, false, null); Assert.InRange(stream.Depth, 0, 6);
|
||||
for (int i = 0; i < 20; i++) { output.AsSpan().Clear(); stream.Mix(output, false, null); }
|
||||
Assert.All(output, value => Assert.Equal(0, value)); Assert.InRange(stream.ConcealedFrames, 1, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PttResumeAndCaptureChannelChangesKeepTimestampProgressAndAudio()
|
||||
{
|
||||
var info = Stream(60, true); using var receive = new AudioEngine((_, _, _, _) => true, false);
|
||||
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { info } }], 1, 1);
|
||||
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
|
||||
send.InputMode = AudioInputMode.PushToTalk; send.PushToTalk = true; send.AddLocalStream(info);
|
||||
short[] mono = Tone(), stereo = Tone(2); long energy = 0;
|
||||
receive.MixedPcm += pcm => { foreach (short value in pcm) energy += Math.Abs((int)value); };
|
||||
for (int i = 0; i < 15; i++) { send.FeedPcm(1, mono, 1); send.ProcessCycle(); receive.ProcessCycle(); }
|
||||
send.PushToTalk = false;
|
||||
for (int i = 0; i < 16; i++) { send.FeedPcm(1, mono, 1); send.ProcessCycle(); receive.ProcessCycle(); }
|
||||
send.SetCaptureChannels(1, 2); send.PushToTalk = true; energy = 0;
|
||||
for (int i = 0; i < 15; i++) { send.FeedPcm(1, stereo, 2); send.ProcessCycle(); receive.ProcessCycle(); }
|
||||
Assert.True(energy > 100000);
|
||||
}
|
||||
|
||||
private sealed class ManualAudioClock : TimeProvider
|
||||
{
|
||||
private long milliseconds;
|
||||
public override long TimestampFrequency => 1000;
|
||||
public override long GetTimestamp() => milliseconds;
|
||||
internal void Advance(int value) => milliseconds += value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using VoiceCat.Server.Data;
|
||||
using Voicecat.V1;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class ChannelManagementTests
|
||||
{
|
||||
internal static async Task<Client> AdminAsync(ServerFixture fixture)
|
||||
{
|
||||
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||
await store.CreateAccountAsync("Admin", "secret", true);
|
||||
Client client = await fixture.ConnectAsync();
|
||||
client.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
|
||||
await client.ReadUntilAsync(e => e.ServerHello is not null);
|
||||
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } });
|
||||
Assert.True((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
|
||||
await client.ReadUntilAsync(e => e.ServerState is not null);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static Channel Room(string name = "Protected") => new()
|
||||
{
|
||||
Name = name, MaxUsers = 1,
|
||||
Audio = new() { SampleRate = 48000, BitrateBps = 24000, FrameMs = 20, Complexity = 5, Fec = true }
|
||||
};
|
||||
|
||||
internal static async Task<bool> ResultAsync(Client client, Envelope request)
|
||||
{
|
||||
request.RequestId = 42;
|
||||
client.Send(request);
|
||||
Envelope result = await client.ReadUntilAsync(e => e.GenericResult is not null);
|
||||
Assert.Equal(42UL, result.RequestId);
|
||||
return result.GenericResult.Ok;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProtectedChannelCrudEnforcesPasswordCapacityAndMovesMembersToLobby()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var guest = await fixture.ConnectAsync();
|
||||
User user = await guest.LoginAsync("Guest");
|
||||
await using var admin = await AdminAsync(fixture);
|
||||
Assert.False(await ResultAsync(guest, new() { CreateChannel = new() { Channel = Room() } }));
|
||||
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room(), Password = "pāssword" } }));
|
||||
Channel room = (await guest.ReadUntilAsync(e => e.ChannelEvent?.Kind == ChannelEvent.Types.Kind.Created)).ChannelEvent.Channel;
|
||||
Assert.True(room.PasswordProtected);
|
||||
foreach (string password in new[] { "", "wrong", "pāssword" })
|
||||
{
|
||||
guest.Send(new() { RequestId = 7, JoinChannel = new() { ChannelId = room.Id, Password = password } });
|
||||
Envelope result = await guest.ReadUntilAsync(e => e.JoinChannelResult is not null);
|
||||
Assert.Equal(7UL, result.RequestId);
|
||||
Assert.Equal(password == "pāssword", result.JoinChannelResult.Ok);
|
||||
}
|
||||
admin.Send(new() { JoinChannel = new() { ChannelId = room.Id, Password = "pāssword" } });
|
||||
Assert.False((await admin.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
|
||||
room.Name = "Renamed";
|
||||
Assert.False(await ResultAsync(guest, new() { EditChannel = new() { Channel = room } }));
|
||||
Assert.True(await ResultAsync(admin, new() { EditChannel = new() { Channel = room } }));
|
||||
Assert.Equal("Renamed", (await guest.ReadUntilAsync(e => e.ChannelEvent is not null)).ChannelEvent.Channel.Name);
|
||||
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||
{
|
||||
Assert.Equal("Renamed", store.LoadChannels().Single(c => c.Id == room.Id).Name);
|
||||
Assert.True(store.CheckChannelPassword(room.Id, "pāssword"));
|
||||
Assert.False(store.CheckChannelPassword(room.Id, "wrong"));
|
||||
}
|
||||
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = room.Id } }));
|
||||
Assert.Equal(room.Id, (await guest.ReadUntilAsync(e => e.ChannelEvent?.Kind == ChannelEvent.Types.Kind.Deleted)).ChannelEvent.DeletedId);
|
||||
guest.Send(new() { Subscribe = new() });
|
||||
var snapshot = (await guest.ReadUntilAsync(e => e.ServerState is not null)).ServerState;
|
||||
Assert.Equal(1U, snapshot.Users.Single(u => u.Id == user.Id).ChannelId);
|
||||
Assert.DoesNotContain(snapshot.Channels, c => c.Id == room.Id);
|
||||
using var reopened = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||
Assert.DoesNotContain(reopened.LoadChannels(), c => c.Id == room.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidChangesCannotCorruptChannelTreeOrLobby()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var admin = await AdminAsync(fixture);
|
||||
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room("Parent") } }));
|
||||
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||
Channel parent = store.LoadChannels().Single(c => c.Name == "Parent");
|
||||
Channel child = Room("Child"); child.ParentId = parent.Id;
|
||||
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = child } }));
|
||||
child = store.LoadChannels().Single(c => c.Name == "Child");
|
||||
parent.ParentId = child.Id;
|
||||
Assert.False(await ResultAsync(admin, new() { EditChannel = new() { Channel = parent } }));
|
||||
Assert.False(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } }));
|
||||
Assert.False(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = 1 } }));
|
||||
Channel lobby = store.LoadChannels().Single(c => c.Id == 1);
|
||||
Assert.False(await ResultAsync(admin, new() { EditChannel = new() { Channel = lobby, Password = "lockout" } }));
|
||||
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room("Child") } }));
|
||||
var invalid = Room("Invalid"); invalid.Audio.SampleRate = 123;
|
||||
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() { Channel = invalid } }));
|
||||
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() }));
|
||||
Assert.Equal(4, store.LoadChannels().Count);
|
||||
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = child.Id } }));
|
||||
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using VoiceCat.Codec;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class CodecTests
|
||||
{
|
||||
public static IEnumerable<object[]> Formats()
|
||||
{
|
||||
foreach (int rate in new[] { 8000, 12000, 16000, 24000, 48000 })
|
||||
foreach (int channels in new[] { 1, 2 })
|
||||
foreach (int duration in new[] { 10, 20, 40, 60 })
|
||||
yield return [rate, channels, duration];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Formats))]
|
||||
public void RoundTripAndLossConcealment(int sampleRate, int channels, int duration)
|
||||
{
|
||||
var options = new OpusOptions { SampleRate = sampleRate, Channels = channels, FrameDurationMilliseconds = duration, Bitrate = 64000 };
|
||||
using var encoder = new OpusEncoder(options);
|
||||
using var decoder = new OpusDecoder(sampleRate, channels);
|
||||
short[] input = new short[options.SamplesPerChannel * channels];
|
||||
short[] output = new short[input.Length];
|
||||
byte[] packet = new byte[4000];
|
||||
for (int frame = 0; frame < 12; frame++)
|
||||
{
|
||||
FillTone(input, options.SamplesPerChannel, channels, sampleRate, frame);
|
||||
int bytes = encoder.Encode(input, packet);
|
||||
Assert.InRange(bytes, 1, packet.Length);
|
||||
Assert.Equal(options.SamplesPerChannel, decoder.Decode(packet.AsSpan(0, bytes), output, options.SamplesPerChannel));
|
||||
}
|
||||
double rms = Rms(output);
|
||||
Assert.InRange(rms, 2000, 12000);
|
||||
Assert.Equal(options.SamplesPerChannel, decoder.Decode([], output, options.SamplesPerChannel));
|
||||
Assert.True(Rms(output) > 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsInvalidStorageAndOptionsBeforeNativeCalls()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new OpusEncoder(new() { Channels = 3 }));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new OpusEncoder(new() { FrameDurationMilliseconds = 30 }));
|
||||
using var encoder = new OpusEncoder();
|
||||
using var decoder = new OpusDecoder();
|
||||
Assert.Throws<ArgumentException>(() => encoder.Encode(new short[959], new byte[4000]));
|
||||
Assert.Throws<ArgumentException>(() => decoder.Decode([], new short[959], 960));
|
||||
encoder.Dispose();
|
||||
Assert.Throws<ObjectDisposedException>(() => encoder.Encode(new short[960], new byte[4000]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DredIsExplicitlySupportedOrRejected()
|
||||
{
|
||||
using var probe = new OpusEncoder();
|
||||
Assert.Contains("libopus", OpusEncoder.Version);
|
||||
if (!probe.SupportsDeepRedundancy)
|
||||
{
|
||||
Assert.Throws<NotSupportedException>(() => new OpusEncoder(new() { DeepRedundancy = true }));
|
||||
Assert.Throws<NotSupportedException>(() => new OpusDeepRedundancy());
|
||||
return;
|
||||
}
|
||||
VerifyDredRecovery(new() { DeepRedundancy = true, ExpectedPacketLossPercent = 20, Bitrate = 64000 });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Formats))]
|
||||
public void DredRecoversDroppedFrames(int sampleRate, int channels, int duration)
|
||||
{
|
||||
using var probe = new OpusEncoder();
|
||||
Assert.True(probe.SupportsDeepRedundancy, "Build native bindings with scripts/build-native.ps1 for DRED recovery tests.");
|
||||
if (sampleRate < 16000)
|
||||
Assert.Throws<NotSupportedException>(() => new OpusEncoder(new() { SampleRate = sampleRate, DeepRedundancy = true }));
|
||||
VerifyDredRecovery(new() { SampleRate = sampleRate, Channels = channels,
|
||||
FrameDurationMilliseconds = duration, DeepRedundancy = true, ExpectedPacketLossPercent = 20, Bitrate = 64000 });
|
||||
}
|
||||
|
||||
private static void VerifyDredRecovery(OpusOptions options)
|
||||
{
|
||||
// The pinned encoder cannot emit DRED at 8/12 kHz; packets can still be decoded at those rates.
|
||||
var encoderOptions = options with { SampleRate = Math.Max(16000, options.SampleRate) };
|
||||
using var encoder = new OpusEncoder(encoderOptions);
|
||||
using var decoder = new OpusDecoder(options.SampleRate, options.Channels);
|
||||
using var recovery = new OpusDeepRedundancy();
|
||||
short[] input = new short[encoderOptions.SamplesPerChannel * options.Channels];
|
||||
short[] output = new short[options.SamplesPerChannel * options.Channels];
|
||||
byte[] packet = new byte[4000];
|
||||
bool missing = false;
|
||||
int recovered = 0;
|
||||
for (int frame = 0; frame < 40; frame++)
|
||||
{
|
||||
FillTone(input, encoderOptions.SamplesPerChannel, options.Channels, encoderOptions.SampleRate, frame);
|
||||
int bytes = encoder.Encode(input, packet);
|
||||
if (missing)
|
||||
{
|
||||
Assert.True(recovery.TryRecover(decoder, packet.AsSpan(0, bytes), output, options.SamplesPerChannel));
|
||||
Assert.True(Rms(output) > 10);
|
||||
recovered++;
|
||||
missing = false;
|
||||
}
|
||||
if (frame > 20 && frame % 5 == 0)
|
||||
{
|
||||
missing = true;
|
||||
continue;
|
||||
}
|
||||
decoder.Decode(packet.AsSpan(0, bytes), output, options.SamplesPerChannel);
|
||||
}
|
||||
Assert.Equal(3, recovered);
|
||||
}
|
||||
|
||||
internal static void FillTone(Span<short> pcm, int samples, int channels, int rate, int frame)
|
||||
{
|
||||
for (int i = 0; i < samples; i++)
|
||||
for (int channel = 0; channel < channels; channel++)
|
||||
pcm[i * channels + channel] = (short)(8000 * Math.Sin(2 * Math.PI * (440 + 220 * channel) * (frame * samples + i) / rate));
|
||||
}
|
||||
|
||||
internal static double Rms(ReadOnlySpan<short> pcm)
|
||||
{
|
||||
double sum = 0;
|
||||
foreach (short value in pcm) sum += (double)value * value;
|
||||
return Math.Sqrt(sum / pcm.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using VoiceCat.Dsp;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class DspTests
|
||||
{
|
||||
[Fact]
|
||||
public void SuppressesNoiseAndPreservesUnsupportedSampleRates()
|
||||
{
|
||||
using var processor = new RnnoiseProcessor();
|
||||
short[] pcm = new short[960];
|
||||
uint random = 0x12345678;
|
||||
double inputEnergy = 0, outputEnergy = 0;
|
||||
for (int frame = 0; frame < 200; frame++)
|
||||
{
|
||||
FillNoise(pcm, ref random);
|
||||
if (frame >= 60) foreach (short value in pcm) inputEnergy += (double)value * value;
|
||||
processor.Process(pcm);
|
||||
if (frame >= 60) foreach (short value in pcm) outputEnergy += (double)value * value;
|
||||
}
|
||||
Assert.True(Math.Sqrt(outputEnergy / inputEnergy) < 0.2);
|
||||
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "rnnoise.json")));
|
||||
short[] expected = fixture.RootElement.GetProperty("samples").EnumerateArray().Select(value => value.GetInt16()).ToArray();
|
||||
Assert.Equal(pcm.Length, expected.Length);
|
||||
for (int i = 0; i < pcm.Length; i++) Assert.InRange(Math.Abs(pcm[i] - expected[i]), 0, 1);
|
||||
FillNoise(pcm, ref random);
|
||||
short[] original = (short[])pcm.Clone();
|
||||
processor.Process(pcm, 16000);
|
||||
Assert.Equal(original, pcm);
|
||||
Assert.Throws<ArgumentException>(() => processor.Process(new short[481]));
|
||||
processor.Dispose();
|
||||
Assert.Throws<ObjectDisposedException>(() => processor.Process(pcm));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VadStartsClosedAndUsesMonotonicHangTime()
|
||||
{
|
||||
var clock = new ManualTimeProvider();
|
||||
var processor = new EnergyVadProcessor(0.02f, TimeSpan.FromMilliseconds(300), clock);
|
||||
Assert.False(processor.Process(new short[480]));
|
||||
Assert.True(processor.Process(new short[] { 32767 }));
|
||||
clock.Advance(299);
|
||||
Assert.True(processor.Process([]));
|
||||
clock.Advance(1);
|
||||
Assert.False(processor.Process(new short[480]));
|
||||
processor.Threshold = 0.5f;
|
||||
Assert.False(processor.Process(new short[] { 1000 }));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => processor.Threshold = float.NaN);
|
||||
}
|
||||
|
||||
internal static void FillNoise(Span<short> pcm, ref uint random)
|
||||
{
|
||||
for (int i = 0; i < pcm.Length; i++)
|
||||
{
|
||||
random ^= random << 13;
|
||||
random ^= random >> 17;
|
||||
random ^= random << 5;
|
||||
pcm[i] = (short)((int)(random % 6001) - 3000);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private long timestamp;
|
||||
public override long TimestampFrequency => 1000;
|
||||
public override long GetTimestamp() => timestamp;
|
||||
public void Advance(int milliseconds) => timestamp += milliseconds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"hashes":[{"passwordBase64":"dm9pY2VjYXQgdGVzdA","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$Ki9tdSYqOtze3s3LAS6gv6I0buTIh2abdjWzY3GeLiE"},{"passwordBase64":"Y2Fmw6k","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$lEpmh4tmC0xaD5DhMboQo/3Hw7JqT3VThdqq0n1pImc"},{"passwordBase64":"YQBi","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$XZZGeWLqPMYfYmkPOuDe9dOMu0w7kVG9WS8/Dl6sVI0"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"samples":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"envelope": "00000020082a521c08011204746578741a0b746573742d636c69656e742205302e302e31",
|
||||
"media": [
|
||||
{"sequence": 0, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "", "packet": "01010000cafebabe0000000000000000000003c032faa61a66270f8b198f47e32e32ca84"},
|
||||
{"sequence": 1, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60616263", "packet": "01010000cafebabe0000000000000001000003c0695d7eda350fbe7d25787424bf19191d00e02d53daa4ea625d23af3335f38115f30cce2997de88a40961c10f8ace84e1f5cf7740bd5e62025c022a75532a11465f9322f9867fcf6a35396f86fdca1959d8512ae564c3f09eb1e8e224cd6bdef556a073c12aa45bdae5e77e1f2827b1f3e549f15c"},
|
||||
{"sequence": 65535, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe000000000000ffff000003c096bac906a2d141b97834d57095a62f947529d13f6a74a866"},
|
||||
{"sequence": 65536, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe0000000000010000000003c005ecf39e7f89b45accd35e9b5c9b45bde30713a28b8f3183"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Buffers;
|
||||
using System.IO.Pipelines;
|
||||
using Google.Protobuf;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class FramingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(65536)]
|
||||
[InlineData(ControlFraming.MaxPayloadLength)]
|
||||
public void PayloadRoundTrips(int size)
|
||||
{
|
||||
byte[] payload = Enumerable.Range(0, size).Select(i => (byte)i).ToArray();
|
||||
var output = new ArrayBufferWriter<byte>();
|
||||
ControlFraming.WriteFrame(output, payload);
|
||||
var input = new ReadOnlySequence<byte>(output.WrittenMemory);
|
||||
Assert.True(ControlFraming.TryReadFrame(ref input, out var actual));
|
||||
Assert.Equal(payload, actual.ToArray());
|
||||
Assert.True(input.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncompleteFramesDoNotConsumeInput()
|
||||
{
|
||||
byte[] frame = [0, 0, 0, 3, 1, 2, 3];
|
||||
for (int size = 0; size < frame.Length; size++)
|
||||
{
|
||||
var input = new ReadOnlySequence<byte>(frame.AsMemory(0, size));
|
||||
Assert.False(ControlFraming.TryReadFrame(ref input, out _));
|
||||
Assert.Equal(size, input.Length);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SegmentsAndBatchedFramesAreHandled()
|
||||
{
|
||||
byte[] bytes = [0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0];
|
||||
var first = new Segment(bytes.AsMemory(0, 1));
|
||||
var last = first;
|
||||
for (int i = 1; i < bytes.Length; i++) last = last.Append(bytes.AsMemory(i, 1));
|
||||
var input = new ReadOnlySequence<byte>(first, 0, last, last.Memory.Length);
|
||||
Assert.True(ControlFraming.TryReadFrame(ref input, out var payload));
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray());
|
||||
Assert.True(ControlFraming.TryReadFrame(ref input, out payload));
|
||||
Assert.True(payload.IsEmpty);
|
||||
Assert.True(input.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OversizedLengthsAreRejectedImmediately()
|
||||
{
|
||||
var input = new ReadOnlySequence<byte>(new byte[] { 1, 0, 0, 1 });
|
||||
Assert.Throws<InvalidDataException>(() => ControlFraming.TryReadFrame(ref input, out _));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => ControlFraming.WriteFrame(new ArrayBufferWriter<byte>(), new byte[ControlFraming.MaxPayloadLength + 1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnvelopesRoundTripThroughPipe()
|
||||
{
|
||||
var expected = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
|
||||
expected.ClientHello.Features.Add("text");
|
||||
var pipe = new Pipe();
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, expected);
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, new());
|
||||
await pipe.Writer.CompleteAsync();
|
||||
var actual = new List<Envelope>();
|
||||
await foreach (var envelope in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) actual.Add(envelope);
|
||||
Assert.Equal(new[] { expected, new Envelope() }, actual);
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new byte[] { 0 })]
|
||||
[InlineData(new byte[] { 0, 0, 0, 2, 1 })]
|
||||
public async Task TruncatedEndOfStreamIsRejected(byte[] bytes)
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
pipe.Writer.Write(bytes);
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () =>
|
||||
{
|
||||
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
|
||||
});
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidProtobufIsRejected()
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
ControlFraming.WriteFrame(pipe.Writer, new byte[] { 0xff });
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await Assert.ThrowsAsync<InvalidProtocolBufferException>(async () =>
|
||||
{
|
||||
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
|
||||
});
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadCanBeCanceled()
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
await using var enumerator = ControlFraming.ReadEnvelopesAsync(pipe.Reader, cancellation.Token).GetAsyncEnumerator();
|
||||
var pending = enumerator.MoveNextAsync().AsTask();
|
||||
cancellation.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending);
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownFieldsSurviveParsing()
|
||||
{
|
||||
byte[] bytes = [8, 42, 0xa0, 6, 7];
|
||||
Assert.Equal(bytes, Envelope.Parser.ParseFrom(bytes).ToByteArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FragmentedLargeEnvelopeMakesProgressUnderBackpressure()
|
||||
{
|
||||
var envelope = new Envelope { ClientHello = new() { ClientName = new string('a', 200000) } };
|
||||
var framed = new ArrayBufferWriter<byte>();
|
||||
ControlFraming.WriteEnvelope(framed, envelope);
|
||||
var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 32, resumeWriterThreshold: 16));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
async Task Produce()
|
||||
{
|
||||
for (int offset = 0; offset < framed.WrittenCount; offset += 7)
|
||||
await pipe.Writer.WriteAsync(framed.WrittenMemory.Slice(offset, Math.Min(7, framed.WrittenCount - offset)), timeout.Token);
|
||||
await pipe.Writer.CompleteAsync();
|
||||
}
|
||||
var producer = Produce();
|
||||
var actual = new List<Envelope>();
|
||||
await foreach (var item in ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token)) actual.Add(item);
|
||||
await producer;
|
||||
Assert.Equal(new[] { envelope }, actual);
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoppingEnumerationLeavesFollowingFramesAvailable()
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 1 });
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 2 });
|
||||
await pipe.Writer.FlushAsync();
|
||||
await using (var first = ControlFraming.ReadEnvelopesAsync(pipe.Reader).GetAsyncEnumerator())
|
||||
{
|
||||
Assert.True(await first.MoveNextAsync());
|
||||
Assert.Equal(1UL, first.Current.RequestId);
|
||||
}
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await using (var second = ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token).GetAsyncEnumerator())
|
||||
{
|
||||
Assert.True(await second.MoveNextAsync());
|
||||
Assert.Equal(2UL, second.Current.RequestId);
|
||||
}
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
private sealed class Segment : ReadOnlySequenceSegment<byte>
|
||||
{
|
||||
public Segment(ReadOnlyMemory<byte> memory) => Memory = memory;
|
||||
|
||||
public Segment Append(ReadOnlyMemory<byte> memory)
|
||||
{
|
||||
var segment = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length };
|
||||
Next = segment;
|
||||
return segment;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Buffers;
|
||||
using System.Text.Json;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class GoldenTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnvelopeMatchesCanonicalWireVector()
|
||||
{
|
||||
using var fixture = Load();
|
||||
var expected = Convert.FromHexString(fixture.RootElement.GetProperty("envelope").GetString()!);
|
||||
var envelope = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
|
||||
envelope.ClientHello.Features.Add("text");
|
||||
var output = new ArrayBufferWriter<byte>();
|
||||
ControlFraming.WriteEnvelope(output, envelope);
|
||||
Assert.Equal(expected, output.WrittenSpan.ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void MediaPacketsMatchCanonicalWireVectors(bool managed)
|
||||
{
|
||||
using var fixture = Load();
|
||||
foreach (var vector in fixture.RootElement.GetProperty("media").EnumerateArray())
|
||||
{
|
||||
byte[] key = Convert.FromHexString(vector.GetProperty("key").GetString()!);
|
||||
byte[] plaintext = Convert.FromHexString(vector.GetProperty("plaintext").GetString()!);
|
||||
byte[] expected = Convert.FromHexString(vector.GetProperty("packet").GetString()!);
|
||||
ulong sequence = vector.GetProperty("sequence").GetUInt64();
|
||||
using var sender = new MediaEncryptor(key, managed, sequence);
|
||||
using var receiver = new MediaDecryptor(key, managed);
|
||||
var header = new VoiceFrameHeader(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960);
|
||||
byte[] actual = new byte[expected.Length];
|
||||
sender.Encrypt(header, plaintext, actual);
|
||||
Assert.Equal(expected, actual);
|
||||
byte[] decoded = new byte[plaintext.Length];
|
||||
Assert.True(receiver.TryDecrypt(expected, decoded, out var parsed, out int written));
|
||||
Assert.Equal(sequence, parsed.Sequence);
|
||||
Assert.Equal(plaintext.Length, written);
|
||||
Assert.Equal(plaintext, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "wire.json")));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Formats.Asn1;
|
||||
using VoiceCat.Crypto;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class IdentityTests
|
||||
{
|
||||
[Fact]
|
||||
public void CredentialsSurviveRestartAndBindIdentityIntoCertificate()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-credentials-" + Guid.NewGuid());
|
||||
try
|
||||
{
|
||||
string identityFingerprint, certificateFingerprint;
|
||||
using (var credentials = ServerCredentials.LoadOrCreate(directory, "Server, with punctuation"))
|
||||
{
|
||||
identityFingerprint = credentials.Identity.Fingerprint;
|
||||
certificateFingerprint = credentials.CertificateFingerprint;
|
||||
using var tls = credentials.CreateTlsSession();
|
||||
Assert.False(tls.IsReady);
|
||||
byte[] identity = File.ReadAllBytes(Path.Combine(directory, "identity.key"));
|
||||
Assert.Equal(96, identity.Length);
|
||||
Assert.Equal(identity[..32], identity[64..]);
|
||||
using var certificate = X509Certificate2.CreateFromPem(File.ReadAllText(Path.Combine(directory, "server.crt")));
|
||||
var san = new AsnReader(certificate.Extensions["2.5.29.17"]!.RawData, AsnEncodingRules.DER).ReadSequence();
|
||||
Assert.Equal("urn:voicecat:identity:ed25519:" + Convert.ToHexString(credentials.Identity.PublicKey).ToLowerInvariant(),
|
||||
san.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6)));
|
||||
Assert.False(san.HasData);
|
||||
}
|
||||
using var restored = ServerCredentials.LoadOrCreate(directory, "ignored after creation");
|
||||
Assert.Equal(identityFingerprint, restored.Identity.Fingerprint);
|
||||
Assert.Equal(certificateFingerprint, restored.CertificateFingerprint);
|
||||
File.Delete(Path.Combine(directory, "server.key"));
|
||||
Assert.Throws<InvalidDataException>(() => ServerCredentials.LoadOrCreate(directory, "unchanged"));
|
||||
using var stillPresent = ServerIdentity.Load(Path.Combine(directory, "identity.key"));
|
||||
Assert.Equal(identityFingerprint, stillPresent.Fingerprint);
|
||||
}
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TofuRequiresExplicitPinAndPreservesFileFormat()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-pins-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "pins.txt");
|
||||
string fingerprint = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
try
|
||||
{
|
||||
var store = new TofuStore(path);
|
||||
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
|
||||
Assert.False(File.Exists(path));
|
||||
store.Pin("localhost", 9987, fingerprint);
|
||||
Assert.Equal($"localhost:9987 {fingerprint.ToLowerInvariant()}\n", File.ReadAllText(path));
|
||||
store = new(path);
|
||||
Assert.Equal(TofuStatus.Matched, store.Check("localhost", 9987, fingerprint));
|
||||
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, new string('0', 64)));
|
||||
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, fingerprint));
|
||||
store.Remove("localhost", 9987);
|
||||
Assert.Equal(TofuStatus.FirstConnect, new TofuStore(path).Check("localhost", 9987, fingerprint));
|
||||
File.WriteAllText(path, "localhost:9987 " + new string('g', 64));
|
||||
Assert.Throws<InvalidDataException>(() => new TofuStore(path));
|
||||
}
|
||||
finally { Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Diagnostics;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class ManagedCliTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1u)]
|
||||
[InlineData(2u)]
|
||||
public async Task TwoManagedCliProcessesExchangeTextAndDecodedVoice(uint channel)
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
string root = FindRoot();
|
||||
string cli = Path.Combine(root, "src", "VoiceCat.Cli", "bin", "Release", "net10.0", "VoiceCat.Cli.dll");
|
||||
Assert.True(File.Exists(cli), $"Managed CLI was not built at {cli}.");
|
||||
using Process alice = Start(cli, fixture, "Alice", "Alice says hello", "Bob says hello", channel);
|
||||
using Process bob = Start(cli, fixture, "Bob", "Bob says hello", "Alice says hello", channel);
|
||||
Task<string> aliceOut = alice.StandardOutput.ReadToEndAsync(); Task<string> aliceError = alice.StandardError.ReadToEndAsync();
|
||||
Task<string> bobOut = bob.StandardOutput.ReadToEndAsync(); Task<string> bobError = bob.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35));
|
||||
await Task.WhenAll(alice.WaitForExitAsync(timeout.Token), bob.WaitForExitAsync(timeout.Token));
|
||||
string aout = await aliceOut, bout = await bobOut;
|
||||
Assert.True(alice.ExitCode == 0, await aliceError + Environment.NewLine + aout);
|
||||
Assert.True(bob.ExitCode == 0, await bobError + Environment.NewLine + bout);
|
||||
Assert.Contains("Bob says hello", aout); Assert.Contains("Alice says hello", bout);
|
||||
Assert.Contains("\"type\":\"complete\"", aout); Assert.Contains("\"type\":\"complete\"", bout);
|
||||
Assert.DoesNotContain("\"voiceEnergy\":0", aout); Assert.DoesNotContain("\"voiceEnergy\":0", bout);
|
||||
}
|
||||
|
||||
private static Process Start(string cli, ServerFixture fixture, string name, string send, string expect, uint channel)
|
||||
{
|
||||
var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||
foreach (string argument in new[] { cli, "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nickname", name,
|
||||
"--pins", Path.Combine(fixture.Directory, name + ".cli.pins"), "--trust-first", "--channel", channel.ToString(), "--voice", "--expect-voice", "--send-text", send,
|
||||
"--expect-text", expect, "--start-delay-ms", "1500", "--timeout-seconds", "20" }) start.ArgumentList.Add(argument);
|
||||
return Process.Start(start) ?? throw new InvalidOperationException("Could not start managed CLI.");
|
||||
}
|
||||
private static string FindRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VoiceCat.slnx"))) directory = directory.Parent;
|
||||
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using VoiceCat.Core;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Server.Data;
|
||||
using Voicecat.V1;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
using static VoiceCat.Tests.MediaRelayTests;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class ManagedClientTests
|
||||
{
|
||||
private static VoiceCatClient NewClient(ServerFixture fixture, string name) => new(name, "test", Path.Combine(fixture.Directory, name + ".pins"));
|
||||
private static Task Connect(VoiceCatClient client, ServerFixture fixture) => client.ConnectAsync("127.0.0.1", (ushort)fixture.Server.EndPoint.Port, (_, _) => ValueTask.FromResult(true));
|
||||
private static async Task<Envelope> Event(VoiceCatClient client, Func<Envelope, bool> predicate)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await foreach (Envelope message in client.ReadEventsAsync(timeout.Token)) if (predicate(message)) return message;
|
||||
throw new IOException("Expected client event was not received.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManagedClientsAuthenticateChatAndCorrelateConcurrentRequests()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = NewClient(fixture, "Alice"); await using var bob = NewClient(fixture, "Bob");
|
||||
await Connect(alice, fixture); await Connect(bob, fixture);
|
||||
Assert.True((await alice.AuthenticateGuestAsync("Alice")).Ok); Assert.True((await bob.AuthenticateGuestAsync("Bob")).Ok);
|
||||
await Event(bob, e => e.ServerState is not null); await Event(alice, e => e.UserEvent?.User?.Nickname == "Bob");
|
||||
Assert.Equal(2, alice.Users.Count);
|
||||
var copy = alice.Users[0]; copy.Nickname = "Mutated"; Assert.DoesNotContain(alice.Users, u => u.Nickname == "Mutated");
|
||||
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, Body = "Managed conversation", ClientMsgId = "a1" } });
|
||||
Assert.Equal("Managed conversation", (await Event(bob, e => e.TextMessage is not null)).TextMessage.Body);
|
||||
var requests = Enumerable.Range(1, 20).Select(async i =>
|
||||
{
|
||||
Envelope response = await alice.RequestAsync(new() { Ping = new() { Nonce = (ulong)i } });
|
||||
Assert.Equal((ulong)i, response.Pong.Nonce); return response.RequestId;
|
||||
});
|
||||
Assert.Equal(20, (await Task.WhenAll(requests)).Distinct().Count());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => Connect(alice, fixture));
|
||||
Assert.Equal(ClientConnectionState.Connected, alice.State);
|
||||
await alice.DisconnectAsync();
|
||||
await Event(bob, e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left);
|
||||
await Connect(alice, fixture); Assert.True((await alice.AuthenticateGuestAsync("Returned")).Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TofuRequiresApprovalPinsAcceptedCertificateAndRejectsChanges()
|
||||
{
|
||||
await using var first = new ServerFixture(); await using var second = new ServerFixture();
|
||||
await using var client = NewClient(first, "Tofu");
|
||||
await Assert.ThrowsAsync<System.Security.Authentication.AuthenticationException>(() => client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port));
|
||||
await client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port, (challenge, _) =>
|
||||
{ Assert.Equal(TofuStatus.FirstConnect, challenge.Status); return ValueTask.FromResult(true); });
|
||||
await client.DisconnectAsync();
|
||||
await client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port); await client.DisconnectAsync();
|
||||
// Pin the other server's certificate to this endpoint, simulating a changed server certificate.
|
||||
using var credentials = ServerCredentials.LoadOrCreate(second.Directory, "VoiceCat Server");
|
||||
new TofuStore(Path.Combine(first.Directory, "Other.pins")).Pin("127.0.0.1", (ushort)first.Server.EndPoint.Port, credentials.CertificateFingerprint);
|
||||
await using var changed = new VoiceCatClient(tofuStorePath: Path.Combine(first.Directory, "Other.pins"));
|
||||
await Assert.ThrowsAsync<System.Security.Authentication.AuthenticationException>(() => changed.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port,
|
||||
(challenge, _) => { Assert.Equal(TofuStatus.Mismatch, challenge.Status); return ValueTask.FromResult(false); }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManagedClientSendsAndReceivesAuthenticatedEncodedVoice()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var managed = NewClient(fixture, "Managed"); await Connect(managed, fixture); await managed.AuthenticateGuestAsync("Managed");
|
||||
Assert.True((await managed.SubscribeVoiceAsync()).Ok);
|
||||
await using var peer = await VoicePeer.ConnectAsync(fixture, "Peer");
|
||||
var remote = await peer.AnnounceAsync(StreamKind.StreamMic);
|
||||
var local = (await managed.RequestAsync(new() { StreamAnnounce = new() { Kind = StreamKind.StreamMic } })).StreamAnnounceResult;
|
||||
Assert.True(local.Ok);
|
||||
var received = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
managed.VoiceReceived += (header, payload) => { Assert.Equal(remote.Ssrc, header.Ssrc); received.TrySetResult(payload.ToArray()); };
|
||||
await peer.SendAsync(peer.Seal(remote.Ssrc, [1, 2, 3]));
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, await received.Task.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.True(managed.TrySendEncodedVoice(local.Ssrc, 960, [4, 5, 6]));
|
||||
Assert.Equal(new byte[] { 4, 5, 6 }, (await peer.ReceiveVoiceAsync()).Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManagedAdministrationHelpersRoundTripTypedResults()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||
await store.CreateAccountAsync("Admin", "secret", true);
|
||||
await using var admin = NewClient(fixture, "ManagedAdmin"); await Connect(admin, fixture);
|
||||
Assert.True((await admin.AuthenticateUserAsync("Admin", "secret")).Ok);
|
||||
await Event(admin, envelope => envelope.ServerState is not null);
|
||||
Assert.True(admin.Permissions.IsAdmin);
|
||||
await using var member = NewClient(fixture, "ManagedMember"); await Connect(member, fixture);
|
||||
AuthResult memberAuth = await member.AuthenticateGuestAsync("ManagedMember"); Assert.True(memberAuth.Ok);
|
||||
await Event(member, envelope => envelope.ServerState is not null);
|
||||
await Event(admin, envelope => envelope.UserEvent?.User?.Id == memberAuth.Self.Id);
|
||||
|
||||
Assert.True((await admin.CreateAccountAsync("managed-ui", "first")).Ok);
|
||||
Assert.Contains(await admin.ListAccountsAsync(), account => account.Username == "managed-ui");
|
||||
Assert.True((await admin.ResetPasswordAsync("managed-ui", "second")).Ok);
|
||||
var room = new Voicecat.V1.Channel { Name = "Managed UI room", Audio = new()
|
||||
{ SampleRate = 48000, BitrateBps = 64000, FrameMs = 20, Complexity = 10, Fec = true } };
|
||||
Assert.True((await admin.CreateChannelAsync(room, "protected")).Ok);
|
||||
Envelope created = await Event(admin, envelope => envelope.ChannelEvent?.Channel?.Name == room.Name);
|
||||
Channel edited = created.ChannelEvent.Channel.Clone(); edited.Topic = "Edited from managed UI";
|
||||
Assert.True((await admin.EditChannelAsync(edited)).Ok);
|
||||
Assert.True((await admin.SetPermissionsAsync(memberAuth.Self.Id, new() { CanCreateTempChannel = true })).Ok);
|
||||
Assert.True((await admin.SetServerMuteAsync(memberAuth.Self.Id, true, true)).Ok);
|
||||
Assert.True((await admin.SetServerMuteAsync(memberAuth.Self.Id, false, false)).Ok);
|
||||
Assert.True((await admin.MoveUserAsync(memberAuth.Self.Id, created.ChannelEvent.Channel.Id)).Ok);
|
||||
Assert.True((await admin.KickUserAsync(memberAuth.Self.Id, "managed helper test")).Ok);
|
||||
Assert.True((await admin.DeleteChannelAsync(created.ChannelEvent.Channel.Id)).Ok);
|
||||
Assert.True((await admin.DeleteAccountAsync("managed-ui")).Ok);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using VoiceCat.Codec;
|
||||
using VoiceCat.Dsp;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class MediaAllocationTests
|
||||
{
|
||||
[Fact]
|
||||
public void SteadyStateCodecAndDspDoNotAllocateManagedMemory()
|
||||
{
|
||||
using var encoder = new OpusEncoder();
|
||||
using var decoder = new OpusDecoder();
|
||||
using var denoiser = new RnnoiseProcessor();
|
||||
var vad = new EnergyVadProcessor();
|
||||
short[] pcm = new short[960];
|
||||
short[] decoded = new short[960];
|
||||
byte[] packet = new byte[4000];
|
||||
CodecTests.FillTone(pcm, 960, 1, 48000, 0);
|
||||
for (int i = 0; i < 100; i++) Cycle(encoder, decoder, denoiser, vad, pcm, decoded, packet);
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int i = 0; i < 1000; i++) Cycle(encoder, decoder, denoiser, vad, pcm, decoded, packet);
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
Assert.Equal(0, allocated);
|
||||
}
|
||||
|
||||
private static void Cycle(OpusEncoder encoder, OpusDecoder decoder, RnnoiseProcessor denoiser,
|
||||
EnergyVadProcessor vad, short[] pcm, short[] decoded, byte[] packet)
|
||||
{
|
||||
int bytes = encoder.Encode(pcm, packet);
|
||||
decoder.Decode(packet.AsSpan(0, bytes), decoded, 960);
|
||||
denoiser.Process(decoded);
|
||||
vad.Process(decoded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
using VoiceCat.Server.Transport;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class MediaFanoutTests(ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task UdpRelayDeliversFiftyPacketsPerSecondToFiftySubscribers()
|
||||
{
|
||||
await using var relay = new MediaRelay(new(IPAddress.Loopback, 0));
|
||||
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||
Socket[] sockets = Enumerable.Range(0, 51).Select(_ => new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)).ToArray();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
try
|
||||
{
|
||||
foreach (Socket socket in sockets)
|
||||
{
|
||||
socket.ReceiveBufferSize = 1024 * 1024;
|
||||
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
}
|
||||
MediaRoute[] routes = sockets.Select(socket => new MediaRoute(
|
||||
new(new byte[16], new(new(key), new(key))) { Endpoint = ((IPEndPoint)socket.LocalEndPoint!).Serialize() },
|
||||
1, true, false, false, [42])).ToArray();
|
||||
relay.Publish(routes);
|
||||
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
|
||||
Task[] receivers = sockets.Skip(1).Select(async socket =>
|
||||
{
|
||||
using var decryptor = new MediaDecryptor(key);
|
||||
byte[] packet = new byte[4000];
|
||||
byte[] decoded = new byte[4000];
|
||||
for (ulong sequence = 0; sequence < 50; sequence++)
|
||||
{
|
||||
int length = await socket.ReceiveAsync(packet, SocketFlags.None, timeout.Token);
|
||||
Assert.True(decryptor.TryDecrypt(packet.AsSpan(0, length), decoded, out var header, out int bytes));
|
||||
Assert.Equal(sequence, header.Sequence);
|
||||
Assert.Equal(payload, decoded[..bytes]);
|
||||
}
|
||||
}).ToArray();
|
||||
using var encryptor = new MediaEncryptor(key);
|
||||
byte[] outgoing = new byte[VoiceFrameHeader.Size + payload.Length + 16];
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
for (uint index = 0; index < 50; index++)
|
||||
{
|
||||
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, index * 960), payload, outgoing);
|
||||
await sockets[0].SendToAsync(outgoing, SocketFlags.None, relay.EndPoint, timeout.Token);
|
||||
await Task.Delay(20, timeout.Token);
|
||||
}
|
||||
await Task.WhenAll(receivers);
|
||||
output.WriteLine($"Delivered all 2,500 recipient packets in {elapsed.Elapsed.TotalMilliseconds:F1} ms at a paced 50 pps input.");
|
||||
}
|
||||
finally { foreach (Socket socket in sockets) socket.Dispose(); }
|
||||
}
|
||||
|
||||
[PlatformCipherFact]
|
||||
public void FiftySubscriberFanoutAllocatesNoManagedMemoryAndPreservesPayload()
|
||||
{
|
||||
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||
MediaRoute[] routes = Enumerable.Range(0, 51).Select(i => new MediaRoute(
|
||||
new(new byte[16], new(new(key), new(key))) { Endpoint = new IPEndPoint(IPAddress.Loopback, 10000 + i).Serialize() },
|
||||
1, true, false, false, [42])).ToArray();
|
||||
using var sender = new MediaEncryptor(key);
|
||||
using var receiver = new MediaDecryptor(key);
|
||||
using var fanout = new MediaFanout();
|
||||
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
|
||||
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
|
||||
byte[] decoded = new byte[payload.Length];
|
||||
ReadOnlyMemory<byte> last = default;
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < 100; i++) Cycle();
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
for (int i = 0; i < 1000; i++) Cycle();
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(started);
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
Assert.Equal(0, allocated);
|
||||
Assert.True(receiver.TryDecrypt(last.Span, decoded, out var header, out int length));
|
||||
Assert.Equal(payload.Length, length);
|
||||
Assert.Equal(payload, decoded);
|
||||
Assert.Equal(42U, header.Ssrc);
|
||||
Assert.Equal(1099UL, header.Sequence);
|
||||
output.WriteLine($"50,000 recipient seals in {elapsed.TotalMilliseconds:F1} ms; {allocated} managed bytes. Transport scheduling is excluded.");
|
||||
}
|
||||
finally { foreach (MediaRoute route in routes) route.Peer.Dispose(); }
|
||||
|
||||
void Cycle()
|
||||
{
|
||||
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.");
|
||||
int recipients = 0;
|
||||
while (fanout.TryNext(out var next, out _)) { last = next; recipients++; }
|
||||
if (recipients != 50) throw new InvalidOperationException("Incorrect fanout.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PlatformCipherFactAttribute : FactAttribute
|
||||
{
|
||||
public PlatformCipherFactAttribute()
|
||||
{
|
||||
if (!ChaCha20Poly1305.IsSupported) Skip = "The allocation guarantee requires platform ChaCha20-Poly1305; fallback conformance is tested separately.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using VoiceCat.Transport;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using VoiceCat.Protocol;
|
||||
using VoiceCat.Server.Transport;
|
||||
using Voicecat.V1;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class MediaRelayTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
alice.Client.Send(new() { Disconnect = new() });
|
||||
await bob.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left && e.UserEvent.LeftId == alice.Client.Authentication!.Self.Id);
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [1]));
|
||||
await bob.AssertNoVoiceAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnounceRequiresSubscriptionAndUsesAuthoritativeMusicSettings()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var client = await fixture.ConnectAsync();
|
||||
await client.LoginAsync("Alice");
|
||||
client.Send(new() { StreamAnnounce = new() { Kind = StreamKind.StreamMic } });
|
||||
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
|
||||
client.Send(new() { SubscribeVoice = new() });
|
||||
await client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null);
|
||||
client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
|
||||
await client.ReadUntilAsync(e => e.JoinChannelResult is not null);
|
||||
client.Send(new() { RequestId = 21, StreamAnnounce = new() { Kind = StreamKind.StreamScreenAudio, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 64000 } } });
|
||||
var announced = await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null);
|
||||
Assert.Equal(21UL, announced.RequestId);
|
||||
Assert.True(announced.StreamAnnounceResult.Ok);
|
||||
Assert.Equal(64000U, announced.StreamAnnounceResult.EffectiveAudio.BitrateBps);
|
||||
Assert.Equal(48000U, announced.StreamAnnounceResult.EffectiveAudio.SampleRate);
|
||||
Assert.Equal(ChannelMode.ModeStereo, announced.StreamAnnounceResult.EffectiveAudio.Mode);
|
||||
client.Send(new() { StreamAnnounce = new() { Kind = (StreamKind)99 } });
|
||||
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EncryptedOpusIsResealedWithRecipientCountersAcrossMultipleStreamsAndSenders()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
await using var carol = await VoicePeer.ConnectAsync(fixture, "Carol");
|
||||
StreamAnnounceResult mic = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
StreamAnnounceResult screen = await alice.AnnounceAsync(StreamKind.StreamScreenAudio);
|
||||
StreamAnnounceResult other = await carol.AnnounceAsync(StreamKind.StreamMic);
|
||||
Assert.NotEqual(mic.StreamId, screen.StreamId);
|
||||
Assert.NotEqual(mic.Ssrc, screen.Ssrc);
|
||||
Assert.Equal(48000U, screen.EffectiveAudio.SampleRate);
|
||||
Assert.Equal(24000U, screen.EffectiveAudio.BitrateBps);
|
||||
using var encoder = new Codec.OpusEncoder(new());
|
||||
short[] samples = Enumerable.Range(0, 960).Select(i => (short)(8000 * Math.Sin(i * 0.1))).ToArray();
|
||||
byte[] payload = new byte[4000];
|
||||
int length = encoder.Encode(samples, payload);
|
||||
payload = payload[..length];
|
||||
foreach (var (sender, stream) in new[] { (alice, mic), (carol, other), (alice, screen) })
|
||||
{
|
||||
byte[] packet = sender.Seal(stream.Ssrc, payload, 960, VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent);
|
||||
await sender.SendAsync(packet);
|
||||
var received = await bob.ReceiveVoiceAsync();
|
||||
Assert.Equal(payload, received.Payload);
|
||||
Assert.Equal(stream.Ssrc, received.Header.Ssrc);
|
||||
Assert.Equal(960U, received.Header.Timestamp);
|
||||
Assert.Equal(VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent, received.Header.Flags);
|
||||
}
|
||||
Assert.Equal(2UL, bob.LastSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayForgeryAndSpoofedStreamsAreDroppedWithoutBreakingValidMedia()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
StreamAnnounceResult stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
byte[] packet = alice.Seal(stream.Ssrc, [1, 2, 3]);
|
||||
await alice.SendAsync(packet);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||
await alice.SendAsync(packet);
|
||||
byte[] forged = alice.Seal(stream.Ssrc, [4]);
|
||||
forged[^1] ^= 1;
|
||||
await alice.SendAsync(forged);
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc + 1000, [5]));
|
||||
await alice.SendAsync([1]);
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [6]));
|
||||
Assert.Equal(new byte[] { 6 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||
Assert.Equal(1UL, bob.LastSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscriptionChannelMovementAndStreamStopIsolateMedia()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
var mic = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
await bob.SubscribeAsync(false);
|
||||
await alice.SendAsync(alice.Seal(mic.Ssrc, [1]));
|
||||
await bob.AssertNoVoiceAsync();
|
||||
await bob.SubscribeAsync(true);
|
||||
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
|
||||
Assert.True((await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
|
||||
await alice.SendAsync(alice.Seal(mic.Ssrc, [2]));
|
||||
await bob.AssertNoVoiceAsync();
|
||||
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 1 } });
|
||||
await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null);
|
||||
alice.Client.Send(new() { StreamStop = new() { StreamId = mic.StreamId } });
|
||||
await alice.Client.ReadUntilAsync(e => e.UserEvent?.User?.Id == alice.Client.Authentication!.Self.Id && e.UserEvent.User.Streams.Count == 0);
|
||||
await alice.SendAsync(alice.Seal(mic.Ssrc, [3]));
|
||||
await bob.AssertNoVoiceAsync();
|
||||
var replacement = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
await alice.SendAsync(alice.Seal(replacement.Ssrc, [4]));
|
||||
Assert.Equal(new byte[] { 4 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BadTokensCannotBindAndExistingBindingCannotBeStolen()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
using var rogue = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
rogue.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
|
||||
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
|
||||
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
|
||||
alice.Client.Authentication!.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
|
||||
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
|
||||
byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||
await rogue.SendToAsync(keepalive, SocketFlags.None, fixture.Server.MediaEndPoint);
|
||||
using var timeout = new CancellationTokenSource(200);
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await rogue.ReceiveAsync(new byte[100], SocketFlags.None, timeout.Token));
|
||||
await alice.SendAsync(keepalive);
|
||||
Assert.Equal(keepalive, await alice.ReceivePacketAsync());
|
||||
}
|
||||
|
||||
internal sealed class VoicePeer : IAsyncDisposable
|
||||
{
|
||||
public Client Client { get; }
|
||||
private readonly Socket udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
private readonly IPEndPoint endpoint;
|
||||
private readonly MediaSessionCrypto crypto;
|
||||
public ulong LastSequence { get; private set; }
|
||||
private VoicePeer(Client client, IPEndPoint endpoint, MediaSessionCrypto crypto)
|
||||
{
|
||||
Client = client; this.endpoint = endpoint; this.crypto = crypto;
|
||||
udp.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
}
|
||||
public static async Task<VoicePeer> ConnectAsync(ServerFixture fixture, string nickname)
|
||||
{
|
||||
Client client = await fixture.ConnectAsync();
|
||||
await client.LoginAsync(nickname);
|
||||
var peer = new VoicePeer(client, fixture.Server.MediaEndPoint, await client.TakeMediaCryptoAsync());
|
||||
client.Send(new() { UdpBinding = new() { UdpToken = client.Authentication!.UdpToken } });
|
||||
Assert.True((await client.ReadUntilAsync(e => e.UdpBinding is not null)).UdpBinding.Ack);
|
||||
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
|
||||
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
|
||||
client.Authentication.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
|
||||
await peer.SendAsync(binding);
|
||||
byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||
await peer.SendAsync(keepalive);
|
||||
Assert.Equal(keepalive, await peer.ReceivePacketAsync());
|
||||
await peer.SubscribeAsync(true);
|
||||
return peer;
|
||||
}
|
||||
public async Task SubscribeAsync(bool subscribed)
|
||||
{
|
||||
Client.Send(subscribed ? new() { SubscribeVoice = new() } : new() { UnsubscribeVoice = new() });
|
||||
var result = (await Client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null)).VoiceSubscriptionResult;
|
||||
Assert.True(result.Ok); Assert.Equal(subscribed, result.Subscribed);
|
||||
}
|
||||
public async Task<StreamAnnounceResult> AnnounceAsync(StreamKind kind)
|
||||
{
|
||||
Client.Send(new() { StreamAnnounce = new() { Kind = kind, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 900000 } } });
|
||||
var result = (await Client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult;
|
||||
Assert.True(result.Ok, result.Error);
|
||||
return result;
|
||||
}
|
||||
public byte[] Seal(uint ssrc, byte[] payload, uint timestamp = 0, VoiceFrameFlags flags = 0)
|
||||
{
|
||||
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
|
||||
crypto.Encryptor.Encrypt(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload, packet);
|
||||
return packet;
|
||||
}
|
||||
public async Task SendAsync(byte[] packet) => await udp.SendToAsync(packet, SocketFlags.None, endpoint, Client.Timeout.Token);
|
||||
public async Task<byte[]> ReceivePacketAsync()
|
||||
{
|
||||
byte[] buffer = new byte[65535];
|
||||
int size = await udp.ReceiveAsync(buffer, SocketFlags.None, Client.Timeout.Token);
|
||||
return buffer[..size];
|
||||
}
|
||||
public async Task<(VoiceFrameHeader Header, byte[] Payload)> ReceiveVoiceAsync()
|
||||
{
|
||||
byte[] packet = await ReceivePacketAsync();
|
||||
byte[] plain = new byte[65535];
|
||||
Assert.True(crypto.Decryptor.TryDecrypt(packet, plain, out var header, out int length));
|
||||
LastSequence = header.Sequence;
|
||||
return (header, plain[..length]);
|
||||
}
|
||||
public async Task AssertNoVoiceAsync()
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(200);
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await udp.ReceiveAsync(new byte[65535], SocketFlags.None, timeout.Token));
|
||||
}
|
||||
public async ValueTask DisposeAsync() { udp.Dispose(); crypto.Dispose(); await Client.DisposeAsync(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Buffers.Binary;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class MediaTests
|
||||
{
|
||||
private static readonly byte[] Key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||
private static readonly VoiceFrameHeader Header = new(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960);
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void BothBackendsProduceIdenticalPackets(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed);
|
||||
using var receiver = new MediaDecryptor(Key, !managed);
|
||||
byte[] plaintext = Enumerable.Range(0, 100).Select(i => (byte)i).ToArray();
|
||||
byte[] packet = Seal(sender, plaintext);
|
||||
byte[] output = new byte[plaintext.Length];
|
||||
Assert.True(receiver.TryDecrypt(packet, output, out var header, out int written));
|
||||
Assert.Equal(Header, header);
|
||||
Assert.Equal(plaintext.Length, written);
|
||||
Assert.Equal(plaintext, output);
|
||||
Assert.False(receiver.TryDecrypt(packet, output, out _, out written));
|
||||
Assert.Equal(0, written);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ForgedCounterDoesNotPoisonReplayWindow(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed);
|
||||
using var receiver = new MediaDecryptor(Key, managed);
|
||||
byte[] output = new byte[8];
|
||||
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _));
|
||||
byte[] packet = Seal(sender, new byte[8]);
|
||||
byte[] forged = (byte[])packet.Clone();
|
||||
BinaryPrimitives.WriteUInt64BigEndian(forged.AsSpan(8), ulong.MaxValue);
|
||||
Array.Fill(output, (byte)0xaa);
|
||||
Assert.False(receiver.TryDecrypt(forged, output, out var header, out int written));
|
||||
Assert.Equal(default, header);
|
||||
Assert.Equal(0, written);
|
||||
Assert.All(output, value => Assert.Equal(0, value));
|
||||
Assert.True(receiver.TryDecrypt(packet, output, out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void TamperingEveryPacketRegionFailsAuthentication(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed);
|
||||
byte[] packet = Seal(sender, new byte[80]);
|
||||
for (int i = 0; i < packet.Length; i++)
|
||||
{
|
||||
using var receiver = new MediaDecryptor(Key, managed);
|
||||
byte[] tampered = (byte[])packet.Clone();
|
||||
tampered[i] ^= 0x80;
|
||||
Assert.False(receiver.TryDecrypt(tampered, new byte[80], out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(packet, new byte[80], out _, out _));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ReplayWindowAcceptsReorderingAndRejectsOldPackets(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed);
|
||||
using var receiver = new MediaDecryptor(Key, managed);
|
||||
var packets = Enumerable.Range(0, 130).Select(_ => Seal(sender, new byte[1])).ToArray();
|
||||
byte[] output = new byte[1];
|
||||
Assert.True(receiver.TryDecrypt(packets[64], output, out _, out _));
|
||||
Assert.False(receiver.TryDecrypt(packets[0], output, out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(packets[1], output, out _, out _));
|
||||
Assert.False(receiver.TryDecrypt(packets[1], output, out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(packets[63], output, out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(packets[129], output, out _, out _));
|
||||
Assert.False(receiver.TryDecrypt(packets[64], output, out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(packets[128], output, out _, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void CounterCrossesOldSixteenBitBoundary(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed, 65534);
|
||||
using var receiver = new MediaDecryptor(Key, managed);
|
||||
for (ulong sequence = 65534; sequence < 65540; sequence++)
|
||||
{
|
||||
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[1]), new byte[1], out var header, out _));
|
||||
Assert.Equal(sequence, header.Sequence);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void InterleavedRelayUsesRecipientCounter(bool managed)
|
||||
{
|
||||
byte[] otherKey = Enumerable.Repeat((byte)42, 32).ToArray();
|
||||
using var a = new MediaEncryptor(Key, managed);
|
||||
using var b = new MediaEncryptor(otherKey, managed);
|
||||
using var receiveA = new MediaDecryptor(Key, managed);
|
||||
using var receiveB = new MediaDecryptor(otherKey, managed);
|
||||
using var relay = new MediaEncryptor(Key, managed);
|
||||
using var listener = new MediaDecryptor(Key, managed);
|
||||
byte[] plaintext = [1, 2, 3];
|
||||
byte[] decoded = new byte[3];
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
var sender = i % 2 == 0 ? a : b;
|
||||
var receiver = i % 2 == 0 ? receiveA : receiveB;
|
||||
Assert.True(receiver.TryDecrypt(Seal(sender, plaintext), decoded, out var header, out _));
|
||||
byte[] packet = new byte[39];
|
||||
relay.Encrypt(header, decoded, packet);
|
||||
Assert.True(listener.TryDecrypt(packet, decoded, out var relayedHeader, out _));
|
||||
Assert.Equal((ulong)i, relayedHeader.Sequence);
|
||||
Assert.Equal(plaintext, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void EmptyPayloadAndLargeCountersWork(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed, ulong.MaxValue - 1);
|
||||
using var receiver = new MediaDecryptor(Key, managed);
|
||||
var packet = Seal(sender, []);
|
||||
Assert.True(receiver.TryDecrypt(packet, [], out var header, out int written));
|
||||
Assert.Equal(ulong.MaxValue - 1, header.Sequence);
|
||||
Assert.Equal(0, written);
|
||||
Assert.Throws<InvalidOperationException>(() => Seal(sender, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidArgumentsAndDisposedInstancesAreRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new MediaEncryptor(new byte[31]));
|
||||
using var sender = new MediaEncryptor(Key);
|
||||
using var receiver = new MediaDecryptor(Key);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => sender.Encrypt(Header, new byte[1], new byte[36]));
|
||||
byte[] packet = Seal(sender, new byte[8]);
|
||||
Assert.True(receiver.TryDecrypt(packet, new byte[8], out var header, out _));
|
||||
Assert.Equal(0UL, header.Sequence);
|
||||
Assert.False(receiver.TryDecrypt(new byte[35], [], out _, out _));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => receiver.TryDecrypt(packet, [], out _, out _));
|
||||
sender.Dispose();
|
||||
receiver.Dispose();
|
||||
Assert.Throws<ObjectDisposedException>(() => Seal(sender, []));
|
||||
Assert.Throws<ObjectDisposedException>(() => receiver.TryDecrypt(packet, new byte[8], out _, out _));
|
||||
}
|
||||
|
||||
private static byte[] Seal(MediaEncryptor sender, byte[] plaintext)
|
||||
{
|
||||
byte[] packet = new byte[VoiceFrameHeader.Size + plaintext.Length + MediaEncryptor.TagSize];
|
||||
Assert.Equal(packet.Length, sender.Encrypt(Header, plaintext, packet));
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using VoiceCat.Crypto;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class PasswordTests
|
||||
{
|
||||
[Fact]
|
||||
public void VerifiesLibsodiumHashesWithoutPasswordNormalization()
|
||||
{
|
||||
var hasher = new PasswordHasher();
|
||||
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "argon2id.json")));
|
||||
foreach (var item in fixture.RootElement.GetProperty("hashes").EnumerateArray())
|
||||
{
|
||||
string encodedPassword = item.GetProperty("passwordBase64").GetString()!;
|
||||
string password = Encoding.UTF8.GetString(Convert.FromBase64String(encodedPassword.PadRight((encodedPassword.Length + 3) / 4 * 4, '=')));
|
||||
string hash = item.GetProperty("hash").GetString()!;
|
||||
Assert.True(hasher.Verify(password, hash));
|
||||
Assert.False(hasher.Verify(password + "!", hash));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshHashesUseRandomSaltAndNativePhcFormat()
|
||||
{
|
||||
var hasher = new PasswordHasher();
|
||||
string first = hasher.Hash("hello");
|
||||
string second = hasher.Hash("hello");
|
||||
Assert.NotEqual(first, second);
|
||||
Assert.StartsWith("$argon2id$v=19$m=65536,t=2,p=1$", first);
|
||||
Assert.True(hasher.Verify("hello", first));
|
||||
Assert.False(hasher.Verify("wrong", first));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("$argon2id$v=19$m=999999999,t=2,p=1$c2FsdA$aGFzaA")]
|
||||
[InlineData("$argon2id$v=19$m=65536,t=99999,p=1$c2FsdA$aGFzaA")]
|
||||
[InlineData("$argon2id$v=16$m=65536,t=2,p=1$c2FsdA$aGFzaA")]
|
||||
[InlineData("$argon2id$v=19$m=65536,t=2,p=1$!!!$!!!")]
|
||||
public void MalformedOrExcessiveHashesFailClosed(string hash) => Assert.False(new PasswordHasher().Verify("hello", hash));
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using VoiceCat.Transport;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Server;
|
||||
using VoiceCat.Server.Data;
|
||||
using VoiceCat.Server.Transport;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class ProductionServerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HealthCheckPerformsTlsHandshakeAndCanPinCertificate()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
using var credentials = ServerCredentials.LoadOrCreate(fixture.Directory, "VoiceCat Server");
|
||||
var output = new StringWriter(); var error = new StringWriter();
|
||||
string endpoint = "127.0.0.1:" + fixture.Server.EndPoint.Port;
|
||||
Assert.Equal(0, await ServerCommand.RunAsync(["--health-check", endpoint], output, error));
|
||||
Assert.Contains("\"status\":\"healthy\"", output.ToString());
|
||||
output.GetStringBuilder().Clear();
|
||||
Assert.Equal(0, await ServerCommand.RunAsync(["--health-check", endpoint, "--expect-fingerprint", credentials.CertificateFingerprint], output, error));
|
||||
Assert.Equal(1, await ServerCommand.RunAsync(["--health-check", endpoint, "--expect-fingerprint", new string('0', 64)], output, error));
|
||||
Assert.Equal(1, await ServerCommand.RunAsync(["--health-check", "127.0.0.1:1"], output, error));
|
||||
}
|
||||
[PublishedServerFact]
|
||||
public async Task PublishedExecutableProvisionsAdminAndReportsFingerprintsWithoutPasswordOutput()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-published-" + Guid.NewGuid().ToString("N"));
|
||||
async Task<string> Run(params string[] arguments)
|
||||
{
|
||||
var start = new System.Diagnostics.ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_PUBLISHED_SERVER")!)
|
||||
{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
start.Environment["VOICECAT_ADMIN_PASSWORD"] = "published test password";
|
||||
foreach (string argument in arguments.Concat(new[] { "--data-dir", directory })) start.ArgumentList.Add(argument);
|
||||
using var process = System.Diagnostics.Process.Start(start)!;
|
||||
Task<string> stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
string log = await stdout + await stderr;
|
||||
Assert.True(process.ExitCode == 0, log); Assert.DoesNotContain("published test password", log);
|
||||
return log;
|
||||
}
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
try
|
||||
{
|
||||
await Run("account", "add", "Operator", "--admin");
|
||||
Assert.Contains("Operator", await Run("account", "list"));
|
||||
using (var store = new AccountStore(Path.Combine(directory, "voicecat.db")))
|
||||
Assert.True((await store.AuthenticateAsync("Operator", "published test password"))!.IsAdmin);
|
||||
string fingerprint = (await Run("--print-fingerprint")).Trim();
|
||||
Assert.Equal(64, fingerprint.Length);
|
||||
Assert.Equal(fingerprint, (await Run("--print-fingerprint")).Trim());
|
||||
await Run("account", "reset", "Operator"); await Run("account", "delete", "Operator");
|
||||
Assert.DoesNotContain("Operator", await Run("account", "list"));
|
||||
}
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
private sealed class PublishedServerFactAttribute : FactAttribute
|
||||
{
|
||||
public PublishedServerFactAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_PUBLISHED_SERVER"))) Skip = "Set VOICECAT_PUBLISHED_SERVER to the self-contained executable.";
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigurationHonorsEnvironmentAndCommandPrecedenceWithoutCreatingFiles()
|
||||
{
|
||||
var env = new Dictionary<string, string> { ["VOICECAT_BIND_PORT"] = "9000", ["VOICECAT_ALLOW_GUESTS"] = "false", ["VOICECAT_SERVER_NAME"] = "Environment" };
|
||||
var config = ServerCommand.Parse(["--port", "0", "--name", "Command", "--bind", "::1", "--idle-seconds", "0"], key => env.GetValueOrDefault(key));
|
||||
Assert.Equal(0, config.Port); Assert.Equal("Command", config.Options.Name);
|
||||
Assert.False(config.Options.AllowGuests); Assert.Equal("::1", config.BindAddress);
|
||||
Assert.Equal(TimeSpan.Zero, config.Options.IdleTimeout);
|
||||
Assert.Equal(8384, ServerCommand.Parse([], _ => null).Port);
|
||||
Assert.Equal("0.0.0.0", ServerCommand.Parse([], _ => null).BindAddress);
|
||||
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--port", "65536"], _ => null));
|
||||
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--bind", "example.com"], _ => null));
|
||||
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--port"], _ => null));
|
||||
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--unencrypted", "true"], _ => null));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => ServerCommand.Parse(["--auth-burst", "0"], _ => null));
|
||||
Assert.EndsWith("admin-data", ServerCommand.Parse(["account", "list", "--data-dir", "admin-data"], _ => null).Directory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthenticationLimitsAreSharedAcrossConnectionsAndAddressesWithBackoff()
|
||||
{
|
||||
var clock = new TestClock();
|
||||
var limiter = new AuthenticationLimiter(new() { AuthenticationBurst = 5 }, clock);
|
||||
for (int i = 0; i < 3; i++) { Assert.True(limiter.TryAcquire("a", "user")); limiter.Record("a", "user", false); }
|
||||
Assert.False(limiter.TryAcquire("a", "other"));
|
||||
Assert.False(limiter.TryAcquire("b", "user"));
|
||||
clock.Advance(1);
|
||||
Assert.True(limiter.TryAcquire("b", "user")); limiter.Record("b", "user", false);
|
||||
clock.Advance(1); Assert.False(limiter.TryAcquire("c", "user"));
|
||||
clock.Advance(1); Assert.True(limiter.TryAcquire("c", "user")); limiter.Record("c", "user", true);
|
||||
Assert.False(limiter.TryAcquire("d", "user")); // Success does not restore spent tokens.
|
||||
clock.Advance(10); Assert.True(limiter.TryAcquire("d", "user"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PasswordThrottleSurvivesReconnectAndRefillsBeforeSuccessfulLogin()
|
||||
{
|
||||
var clock = new TestClock();
|
||||
await using var fixture = new ServerFixture(options: new() { AuthenticationBurst = 1 }, timeProvider: clock);
|
||||
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"))) await store.CreateAccountAsync("Member", "secret");
|
||||
async Task<bool> Login(string password)
|
||||
{
|
||||
await using var client = await fixture.ConnectAsync();
|
||||
client.Send(new() { ClientHello = new() { ProtoVersion = 2 } }); await client.ReadUntilAsync(e => e.ServerHello is not null);
|
||||
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = password } } });
|
||||
return (await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok;
|
||||
}
|
||||
Assert.False(await Login("wrong")); Assert.False(await Login("secret"));
|
||||
clock.Advance(10); Assert.True(await Login("secret"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostPublishesReadinessPreventsDuplicateInstancesAndShutsDownActiveTls()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-host-" + Guid.NewGuid().ToString("N"));
|
||||
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
var output = new ReadyWriter(); var error = new StringWriter();
|
||||
Task<int> running = ServerCommand.RunAsync(["--data-dir", directory, "--bind", "127.0.0.1", "--port", "0"], output, error, stop.Token);
|
||||
try
|
||||
{
|
||||
using JsonDocument ready = JsonDocument.Parse(await output.Ready.Task.WaitAsync(stop.Token));
|
||||
int port = ready.RootElement.GetProperty("port").GetInt32();
|
||||
Assert.Equal(port, ready.RootElement.GetProperty("udp_port").GetInt32());
|
||||
Assert.Equal(64, ready.RootElement.GetProperty("certificate_fingerprint").GetString()!.Length);
|
||||
Assert.Equal(1, await ServerCommand.RunAsync(["--data-dir", directory, "--port", "0"], new StringWriter(), new StringWriter()));
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port), stop.Token);
|
||||
string fingerprint = ready.RootElement.GetProperty("certificate_fingerprint").GetString()!;
|
||||
await using var client = new Client(new TlsControlConnection(socket, TlsSession.CreateClient(value => value == fingerprint), CancellationToken.None));
|
||||
await client.LoginAsync("Host test");
|
||||
stop.Cancel(); Assert.Equal(0, await running.WaitAsync(TimeSpan.FromSeconds(10)));
|
||||
Assert.Equal("", error.ToString());
|
||||
}
|
||||
finally { stop.Cancel(); await running; Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
private sealed class ReadyWriter : StringWriter
|
||||
{
|
||||
internal TaskCompletionSource<string> Ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public override Task WriteLineAsync(string? value) { if (value?.Contains("\"ready\"", StringComparison.Ordinal) == true) Ready.TrySetResult(value); return base.WriteLineAsync(value); }
|
||||
}
|
||||
private sealed class TestClock : TimeProvider
|
||||
{
|
||||
private long timestamp;
|
||||
public override long TimestampFrequency => 1000;
|
||||
public override long GetTimestamp() => timestamp;
|
||||
internal void Advance(int seconds) => timestamp += seconds * 1000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class PublishServerScriptTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne()
|
||||
{
|
||||
string script = Path.Combine(FindRoot(), "scripts", "publish-server.ps1");
|
||||
|
||||
string allTargets = await RunWhatIf(script);
|
||||
Assert.Contains("win-x64", allTargets);
|
||||
Assert.Contains("linux-x64", allTargets);
|
||||
|
||||
string linuxOnly = await RunWhatIf(script, "-Runtime", "linux-x64");
|
||||
Assert.Contains("linux-x64", linuxOnly);
|
||||
Assert.DoesNotContain("win-x64", linuxOnly);
|
||||
}
|
||||
|
||||
private static async Task<string> RunWhatIf(string script, params string[] arguments)
|
||||
{
|
||||
string powerShell = OperatingSystem.IsWindows() ? "powershell.exe" : "pwsh";
|
||||
var start = new ProcessStartInfo(powerShell)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
foreach (string argument in new[] { "-NoProfile", "-NonInteractive", "-File", script, "-WhatIf" }.Concat(arguments))
|
||||
start.ArgumentList.Add(argument);
|
||||
|
||||
using Process process = Process.Start(start) ?? throw new InvalidOperationException("Could not start PowerShell.");
|
||||
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> stderr = process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
string output = await stdout + await stderr;
|
||||
Assert.True(process.ExitCode == 0, output);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static string FindRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VoiceCat.slnx")))
|
||||
directory = directory.Parent;
|
||||
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using VoiceCat.Protocol;
|
||||
using System.Net.Sockets;
|
||||
using VoiceCat.Server;
|
||||
using Voicecat.V1;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
using static VoiceCat.Tests.MediaRelayTests;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class ReaperTests
|
||||
{
|
||||
private static readonly VoiceServerOptions Options = new()
|
||||
{
|
||||
IdleTimeout = TimeSpan.FromSeconds(10), ReaperInterval = TimeSpan.FromMilliseconds(20)
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task SilentPeerIsReapedWhileTcpActivityKeepsObserverAlive()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
|
||||
await using var alice = await fixture.ConnectAsync();
|
||||
await alice.LoginAsync("Alice");
|
||||
await using var bob = await fixture.ConnectAsync();
|
||||
User self = await bob.LoginAsync("Bob");
|
||||
clock.Advance(9);
|
||||
alice.Send(new() { Ping = new() { Nonce = 99 } });
|
||||
await alice.ReadUntilAsync(e => e.Pong?.Nonce == 99);
|
||||
clock.Advance(2);
|
||||
Assert.Equal(self.Id, (await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
|
||||
Assert.Equal("Receive idle timeout.", (await bob.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Reason);
|
||||
alice.Send(new() { Subscribe = new() });
|
||||
int additionalDepartures = 0;
|
||||
var snapshot = await alice.ReadUntilAsync(e =>
|
||||
{
|
||||
if (e.UserEvent?.Kind == UserEvent.Types.Kind.Left) additionalDepartures++;
|
||||
return e.ServerState is not null;
|
||||
});
|
||||
Assert.Equal(0, additionalDepartures);
|
||||
Assert.DoesNotContain(snapshot.ServerState.Users, user => user.Id == self.Id);
|
||||
alice.Send(new() { Ping = new() { Nonce = 100 } });
|
||||
await alice.ReadUntilAsync(e => e.Pong?.Nonce == 100);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task ValidUdpActivityKeepsTcpIdleClientAlive(bool voice)
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
uint ssrc = voice ? (await alice.AnnounceAsync(StreamKind.StreamMic)).Ssrc : 0;
|
||||
clock.Advance(9);
|
||||
if (voice)
|
||||
{
|
||||
await alice.SendAsync(alice.Seal(ssrc, [1, 2, 3]));
|
||||
await bob.ReceiveVoiceAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||
await alice.SendAsync(keepalive);
|
||||
Assert.Equal(keepalive, await alice.ReceivePacketAsync());
|
||||
}
|
||||
clock.Advance(2);
|
||||
Assert.Equal(bob.Client.Authentication!.Self.Id,
|
||||
(await alice.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
|
||||
alice.Client.Send(new() { Ping = new() { Nonce = 42 } });
|
||||
await alice.Client.ReadUntilAsync(e => e.Pong?.Nonce == 42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidVoiceCannotKeepSilentSessionAlive()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
var stream = await bob.AnnounceAsync(StreamKind.StreamMic);
|
||||
clock.Advance(9);
|
||||
byte[] forged = bob.Seal(stream.Ssrc, [1]);
|
||||
forged[^1] ^= 1;
|
||||
await bob.SendAsync(forged);
|
||||
alice.Client.Send(new() { Ping = new() { Nonce = 1 } });
|
||||
await alice.Client.ReadUntilAsync(e => e.Pong is not null);
|
||||
clock.Advance(2);
|
||||
Assert.Equal(bob.Client.Authentication!.Self.Id,
|
||||
(await alice.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAwaitsActiveVoiceAndUnfinishedHandshake()
|
||||
{
|
||||
await using var fixture = new ServerFixture(options: Options);
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [1, 2]));
|
||||
await bob.ReceiveVoiceAsync();
|
||||
using var unfinished = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await unfinished.ConnectAsync(fixture.Server.EndPoint);
|
||||
await fixture.Server.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await fixture.Server.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReaperCanBeDisabled()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
await using var fixture = new ServerFixture(options: Options with { IdleTimeout = TimeSpan.Zero, ReaperInterval = TimeSpan.Zero }, timeProvider: clock);
|
||||
await using var client = await fixture.ConnectAsync();
|
||||
await client.LoginAsync("Alice");
|
||||
clock.Advance(1000);
|
||||
await Task.Delay(100);
|
||||
client.Send(new() { Ping = new() { Nonce = 1 } });
|
||||
await client.ReadUntilAsync(e => e.Pong is not null);
|
||||
}
|
||||
|
||||
private sealed class ManualClock : TimeProvider
|
||||
{
|
||||
private long timestamp;
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
public override long GetTimestamp() => Volatile.Read(ref timestamp);
|
||||
public void Advance(int seconds) => Interlocked.Add(ref timestamp, seconds * TimeSpan.TicksPerSecond);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using VoiceCat.Core;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class ServerProfileTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProfilesRoundTripWithoutPasswordMaterial()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
|
||||
string path = Path.Combine(directory, "servers.json");
|
||||
try
|
||||
{
|
||||
var guest = ServerProfile.Create(" voice.example ", 8384, ServerAuthentication.Guest, nickname: " Cat ");
|
||||
var account = ServerProfile.Create("secure.example", 9443, ServerAuthentication.Account, username: " talon ");
|
||||
var store = new ServerProfileStore(path);
|
||||
store.Save([guest, account]);
|
||||
|
||||
Assert.Equal([guest, account], store.Load());
|
||||
string json = File.ReadAllText(path);
|
||||
Assert.Contains("\"authentication\": \"Account\"", json);
|
||||
Assert.DoesNotContain("password", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingCorruptAndInvalidProfilesDoNotBreakStartup()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
|
||||
string path = Path.Combine(directory, "servers.json");
|
||||
try
|
||||
{
|
||||
var store = new ServerProfileStore(path);
|
||||
Assert.Empty(store.Load());
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, "not json");
|
||||
Assert.Empty(store.Load());
|
||||
File.WriteAllText(path, "[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"host\":\"\",\"port\":0,\"authentication\":\"Guest\"}]");
|
||||
Assert.Empty(store.Load());
|
||||
}
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountProfilesRequireAUsername()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => ServerProfile.Create("voice.example", 8384, ServerAuthentication.Account));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwiftProfilesImportAndAreBackedUpOnManagedSave()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
|
||||
string path = Path.Combine(directory, "servers.json");
|
||||
Guid accountId = Guid.NewGuid();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, $$"""
|
||||
[{"id":"{{accountId:D}}","host":"voice.example","port":8384,"authMode":"password","savedUsername":"talon","nickname":null,"keychainTag":"voicecat.server.legacy"}]
|
||||
""");
|
||||
var store = new ServerProfileStore(path);
|
||||
ServerProfile profile = Assert.Single(store.Load());
|
||||
Assert.Equal(ServerAuthentication.Account, profile.Authentication);
|
||||
Assert.Equal("talon", profile.Username);
|
||||
Assert.Equal("voicecat.server.legacy", profile.LegacyKeychainTag);
|
||||
|
||||
store.Save([profile]);
|
||||
Assert.True(File.Exists(path + ".swift-backup.json"));
|
||||
string managed = File.ReadAllText(path);
|
||||
Assert.Contains("\"authentication\": \"Account\"", managed);
|
||||
Assert.DoesNotContain("keychainTag", managed);
|
||||
}
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using VoiceCat.Transport;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Server;
|
||||
using VoiceCat.Server.Transport;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class ServerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ControlFramesCanSpanMultipleTlsRecordsAndPingEchoesCorrelation()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var client = await fixture.ConnectAsync();
|
||||
client.Send(new() { ClientHello = new() { ProtoVersion = 2, ClientName = new string('x', 48000) } });
|
||||
await client.ReadUntilAsync(e => e.ServerHello is not null);
|
||||
client.Send(new() { RequestId = 45, Ping = new() { Nonce = 123456 } });
|
||||
Envelope pong = await client.ReadUntilAsync(e => e.Pong is not null);
|
||||
Assert.Equal(45UL, pong.RequestId);
|
||||
Assert.Equal(123456UL, pong.Pong.Nonce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuestsChatJoinChannelsAndDisconnectOverTls()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await fixture.ConnectAsync();
|
||||
User a = await alice.LoginAsync("Alice");
|
||||
await using var bob = await fixture.ConnectAsync();
|
||||
User b = await bob.LoginAsync("Bob");
|
||||
Assert.NotEqual(a.Id, b.Id);
|
||||
Envelope joined = await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Joined);
|
||||
Assert.Equal(b.Id, joined.UserEvent.User.Id);
|
||||
|
||||
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, SenderId = b.Id, Body = "hello", ClientMsgId = "one" } });
|
||||
TextMessage text = (await bob.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||
Assert.Equal("hello", text.Body);
|
||||
Assert.Equal(a.Id, text.SenderId);
|
||||
Assert.True(text.SentAtUnixMs > 0);
|
||||
Assert.True((await alice.ReadUntilAsync(e => e.TextMessageAck is not null)).TextMessageAck.Ok);
|
||||
|
||||
bob.Send(new() { RequestId = 10, JoinChannel = new() { ChannelId = 2 } });
|
||||
Envelope moved = await bob.ReadUntilAsync(e => e.JoinChannelResult is not null);
|
||||
Assert.Equal(10UL, moved.RequestId);
|
||||
Assert.True(moved.JoinChannelResult.Ok);
|
||||
Assert.Equal(128000U, moved.JoinChannelResult.Audio.BitrateBps);
|
||||
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 2, Body = "unauthorized", ClientMsgId = "two" } });
|
||||
Assert.False((await alice.ReadUntilAsync(e => e.TextMessageAck is not null)).TextMessageAck.Ok);
|
||||
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, Body = "isolated" } });
|
||||
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextPrivate, TargetId = b.Id, Body = "private" } });
|
||||
Assert.Equal("private", (await bob.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage.Body);
|
||||
|
||||
bob.Send(new() { Disconnect = new() });
|
||||
Envelope left = await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left);
|
||||
Assert.Equal(b.Id, left.UserEvent.LeftId);
|
||||
alice.Send(new() { RequestId = 11, Subscribe = new() });
|
||||
ServerStateSnapshot snapshot = (await alice.ReadUntilAsync(e => e.ServerState is not null)).ServerState;
|
||||
Assert.Equal(a.Id, Assert.Single(snapshot.Users).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PasswordAuthenticationCanRetryAndGuestAccessCanBeDisabled()
|
||||
{
|
||||
await using var fixture = new ServerFixture(false);
|
||||
using (var accounts = new VoiceCat.Server.Data.AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||
await accounts.CreateAccountAsync("Admin", "secret", true);
|
||||
await using var client = await fixture.ConnectAsync();
|
||||
client.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
|
||||
ServerHello hello = (await client.ReadUntilAsync(e => e.ServerHello is not null)).ServerHello;
|
||||
Assert.Equal(["password"], hello.AuthMethods);
|
||||
client.Send(new() { AuthRequest = new() { Guest = new() { Nickname = "Guest" } } });
|
||||
Assert.False((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
|
||||
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "wrong" } } });
|
||||
Assert.False((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
|
||||
client.Send(new() { RequestId = 3, AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } });
|
||||
Envelope authenticated = await client.ReadUntilAsync(e => e.AuthResult is not null);
|
||||
Assert.True(authenticated.AuthResult.Ok);
|
||||
Assert.Equal(3UL, authenticated.RequestId);
|
||||
Assert.True(authenticated.AuthResult.Permissions.IsAdmin);
|
||||
Assert.False(authenticated.AuthResult.Self.IsGuest);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task InvalidVersionAndUnauthenticatedTextAreDisconnected(bool invalidVersion)
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var client = await fixture.ConnectAsync();
|
||||
client.Send(invalidVersion ? new() { ClientHello = new() { ProtoVersion = 1 } } : new() { TextMessage = new() { Body = "pre-auth" } });
|
||||
Assert.NotEqual(0U, (await client.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Code);
|
||||
}
|
||||
|
||||
internal sealed class ServerFixture : IAsyncDisposable
|
||||
{
|
||||
public string Directory { get; } = Path.Combine(Path.GetTempPath(), "voicecat-server-" + Guid.NewGuid().ToString("N"));
|
||||
public VoiceServer Server { get; }
|
||||
private readonly string fingerprint;
|
||||
public ServerFixture(bool guests = true, VoiceServerOptions? options = null, TimeProvider? timeProvider = null)
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(Directory);
|
||||
Server = new(Directory, new(IPAddress.Loopback, 0), options ?? new() { AllowGuests = guests }, timeProvider);
|
||||
using var credentials = ServerCredentials.LoadOrCreate(Directory, "VoiceCat Server");
|
||||
fingerprint = credentials.CertificateFingerprint;
|
||||
}
|
||||
public async Task<Client> ConnectAsync()
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(Server.EndPoint);
|
||||
return new(new(socket, TlsSession.CreateClient(value => value == fingerprint), CancellationToken.None));
|
||||
}
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Server.DisposeAsync();
|
||||
System.IO.Directory.Delete(Directory, true);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Client : IAsyncDisposable
|
||||
{
|
||||
public CancellationTokenSource Timeout { get; } = new(TimeSpan.FromSeconds(30));
|
||||
private readonly TlsControlConnection connection;
|
||||
private readonly IAsyncEnumerator<Envelope> messages;
|
||||
public Client(TlsControlConnection connection)
|
||||
{
|
||||
this.connection = connection;
|
||||
messages = connection.ReadAsync(Timeout.Token).GetAsyncEnumerator();
|
||||
}
|
||||
public void Send(Envelope envelope) => Assert.True(connection.TrySend(envelope));
|
||||
public AuthResult? Authentication { get; private set; }
|
||||
public Task<MediaSessionCrypto> TakeMediaCryptoAsync() => connection.TakeMediaCryptoAsync(Timeout.Token);
|
||||
public async Task<Envelope> ReadUntilAsync(Func<Envelope, bool> predicate)
|
||||
{
|
||||
while (await messages.MoveNextAsync()) if (predicate(messages.Current)) return messages.Current;
|
||||
throw new IOException("Connection ended before the expected message.");
|
||||
}
|
||||
public async Task<User> LoginAsync(string nickname)
|
||||
{
|
||||
Send(new() { RequestId = 1, ClientHello = new() { ProtoVersion = 2, ClientName = "Managed test" } });
|
||||
Assert.Equal(1UL, (await ReadUntilAsync(e => e.ServerHello is not null)).RequestId);
|
||||
Send(new() { RequestId = 2, AuthRequest = new() { Guest = new() { Nickname = nickname } } });
|
||||
AuthResult auth = (await ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
|
||||
Authentication = auth;
|
||||
Assert.True(auth.Ok, auth.Error);
|
||||
ServerStateSnapshot state = (await ReadUntilAsync(e => e.ServerState is not null)).ServerState;
|
||||
Assert.Equal(2, state.Channels.Count);
|
||||
Assert.Contains(state.Users, user => user.Id == auth.Self.Id);
|
||||
return auth.Self;
|
||||
}
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await messages.DisposeAsync();
|
||||
await connection.DisposeAsync();
|
||||
Timeout.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class TlsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ManagedTlsHandshakeExportsMatchingDirectionalKeys()
|
||||
{
|
||||
var (pem, key, fingerprint) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
using var client = TlsSession.CreateClient(value => value == fingerprint);
|
||||
Assert.Throws<InvalidOperationException>(() => client.CreateMediaEncryptor());
|
||||
Handshake(client, server);
|
||||
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
|
||||
Assert.Equal(server.ExportMediaKey(0), client.ExportMediaKey(0));
|
||||
Assert.Equal(server.ExportMediaKey(1), client.ExportMediaKey(1));
|
||||
Assert.NotEqual(client.ExportMediaKey(0), client.ExportMediaKey(1));
|
||||
client.WritePlaintext("hello"u8);
|
||||
Pump(client, server);
|
||||
byte[] output = new byte[5];
|
||||
Assert.Equal(5, server.ReadPlaintext(output));
|
||||
Assert.Equal("hello"u8.ToArray(), output);
|
||||
using var encryptor = server.CreateMediaEncryptor();
|
||||
using var decryptor = client.CreateMediaDecryptor();
|
||||
byte[] packet = new byte[41];
|
||||
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), "hello"u8, packet);
|
||||
Assert.True(decryptor.TryDecrypt(packet, output, out _, out _));
|
||||
Assert.Equal("hello"u8.ToArray(), output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateRejectionPreventsApplicationDataAndMediaKeys()
|
||||
{
|
||||
var (pem, key, _) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
using var client = TlsSession.CreateClient(_ => false);
|
||||
Assert.ThrowsAny<IOException>(() => Handshake(client, server));
|
||||
Assert.False(client.IsReady);
|
||||
Assert.Throws<InvalidOperationException>(() => client.CreateMediaDecryptor());
|
||||
Assert.Throws<InvalidOperationException>(() => client.WritePlaintext("secret"u8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseNotifyEndsSessionAndAbruptEofIsRejected()
|
||||
{
|
||||
var (pem, key, fingerprint) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
using var client = TlsSession.CreateClient(value => value == fingerprint);
|
||||
Handshake(client, server);
|
||||
client.Close();
|
||||
Pump(client, server);
|
||||
Assert.False(client.IsReady);
|
||||
Assert.False(server.IsReady);
|
||||
server.CompleteInput();
|
||||
using var incomplete = TlsSession.CreateClient(_ => true);
|
||||
Assert.ThrowsAny<IOException>(() => incomplete.CompleteInput());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TlsTwelveCannotNegotiateWithManagedServer()
|
||||
{
|
||||
var (pem, key, _) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
var legacy = new Org.BouncyCastle.Tls.TlsClientProtocol();
|
||||
legacy.Connect(new LegacyPeer());
|
||||
byte[] hello = new byte[legacy.GetAvailableOutputBytes()];
|
||||
legacy.ReadOutput(hello, 0, hello.Length);
|
||||
Assert.ThrowsAny<IOException>(() => server.ReceiveCiphertext(hello));
|
||||
Assert.False(server.IsReady);
|
||||
Assert.Throws<InvalidOperationException>(() => server.CreateMediaEncryptor());
|
||||
}
|
||||
|
||||
private sealed class LegacyPeer() : Org.BouncyCastle.Tls.DefaultTlsClient(new Org.BouncyCastle.Tls.Crypto.Impl.BC.BcTlsCrypto())
|
||||
{
|
||||
protected override Org.BouncyCastle.Tls.ProtocolVersion[] GetSupportedVersions() => [Org.BouncyCastle.Tls.ProtocolVersion.TLSv12];
|
||||
public override Org.BouncyCastle.Tls.TlsAuthentication GetAuthentication() => throw new InvalidOperationException("TLS 1.2 must be rejected before authentication.");
|
||||
}
|
||||
|
||||
internal static (string Certificate, string Key, string Fingerprint) Credentials()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest("CN=VoiceCat TLS test", key, HashAlgorithmName.SHA256);
|
||||
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||
return (certificate.ExportCertificatePem(), key.ExportPkcs8PrivateKeyPem(), Convert.ToHexString(SHA256.HashData(certificate.RawData)));
|
||||
}
|
||||
|
||||
internal static void Handshake(TlsSession client, TlsSession server)
|
||||
{
|
||||
for (int i = 0; i < 100 && (!client.IsReady || !server.IsReady); i++)
|
||||
{
|
||||
Pump(client, server);
|
||||
Pump(server, client);
|
||||
}
|
||||
Assert.True(client.IsReady);
|
||||
Assert.True(server.IsReady);
|
||||
}
|
||||
|
||||
private static void Pump(TlsSession sender, TlsSession receiver)
|
||||
{
|
||||
byte[] buffer = new byte[17];
|
||||
while (sender.PendingCiphertextBytes > 0)
|
||||
{
|
||||
int count = sender.DrainCiphertext(buffer);
|
||||
receiver.ReceiveCiphertext(buffer.AsSpan(0, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using VoiceCat.Crypto;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class TofuTlsTests
|
||||
{
|
||||
[Fact]
|
||||
public void PinCreatesMissingParentDirectory()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "voicecat-tofu-parent-" + Guid.NewGuid());
|
||||
string path = Path.Combine(root, "nested", "pins.txt");
|
||||
try
|
||||
{
|
||||
var store = new TofuStore(path);
|
||||
store.Pin("localhost", 8384, new string('a', 64));
|
||||
|
||||
Assert.True(File.Exists(path));
|
||||
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 8384, new string('A', 64)));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealHandshakesRequireAcceptanceAndRejectChangedCertificatesAfterRestart()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tofu-tls-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "pins.txt");
|
||||
try
|
||||
{
|
||||
using var credentials = ServerCredentials.LoadOrCreate(Path.Combine(directory, "server"), "server");
|
||||
var store = new TofuStore(path);
|
||||
using (var server = credentials.CreateTlsSession())
|
||||
using (var rejected = TlsSession.CreateClient(fingerprint =>
|
||||
{
|
||||
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
|
||||
return false;
|
||||
}))
|
||||
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(rejected, server));
|
||||
Assert.False(File.Exists(path));
|
||||
using (var server = credentials.CreateTlsSession())
|
||||
using (var accepted = TlsSession.CreateClient(fingerprint =>
|
||||
{
|
||||
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
|
||||
store.Pin("localhost", 9987, fingerprint);
|
||||
return true;
|
||||
}))
|
||||
TlsTests.Handshake(accepted, server);
|
||||
store = new(path);
|
||||
using (var server = credentials.CreateTlsSession())
|
||||
using (var returning = TlsSession.CreateClient(fingerprint => store.Check("localhost", 9987, fingerprint) == TofuStatus.Matched))
|
||||
TlsTests.Handshake(returning, server);
|
||||
using var rotated = ServerCredentials.LoadOrCreate(Path.Combine(directory, "rotated"), "server");
|
||||
using (var server = rotated.CreateTlsSession())
|
||||
using (var mismatch = TlsSession.CreateClient(fingerprint =>
|
||||
{
|
||||
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, fingerprint));
|
||||
return false;
|
||||
}))
|
||||
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(mismatch, server));
|
||||
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, credentials.CertificateFingerprint));
|
||||
}
|
||||
finally { Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../clients/windows/VoiceCat.Windows/VoiceCat.Windows.csproj" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Cli/VoiceCat.Cli.csproj" ReferenceOutputAssembly="false" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" PrivateAssets="all" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Server/VoiceCat.Server.csproj" />
|
||||
<Using Include="Xunit" />
|
||||
<None Update="Fixtures/*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class VoiceHeaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void HeaderUsesBigEndianFieldsAndPreservesUnknownValues()
|
||||
{
|
||||
var header = new VoiceFrameHeader((MediaFrameType)255, (VoiceFrameFlags)128, 0x1234, 0x56789abc, 0x0123456789abcdef, 0xfedcba98);
|
||||
byte[] bytes = new byte[20];
|
||||
header.Write(bytes);
|
||||
Assert.Equal("FF80123456789ABC0123456789ABCDEFFEDCBA98", Convert.ToHexString(bytes));
|
||||
Assert.True(VoiceFrameHeader.TryRead(bytes, out var parsed));
|
||||
Assert.Equal(header, parsed);
|
||||
Assert.False(VoiceFrameHeader.TryRead(bytes.AsSpan(0, 19), out _));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => header.Write(new byte[19]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using VoiceCat.Windows;
|
||||
using VoiceCat.Server.Data;
|
||||
using Voicecat.V1;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
using Client = VoiceCat.Windows.VoiceCatClient;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class WindowsManagedClientTests
|
||||
{
|
||||
private static async Task Until(Client client, Func<bool> predicate)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
do { client.PumpEvents(); if (predicate()) return; await Task.Delay(10, timeout.Token); } while (true);
|
||||
}
|
||||
private static async Task Login(Client client, ServerFixture fixture, bool admin = false)
|
||||
{
|
||||
client.EventReceived += e => { if (e.Type == VcEventType.ServerIdentity) client.ConfirmServerIdentity(true); };
|
||||
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", (ushort)fixture.Server.EndPoint.Port));
|
||||
if (admin) client.AuthenticateUser("Admin", "secret"); else client.AuthenticateGuest("Guest");
|
||||
await Until(client, () => client.ListUsers().Count > 0);
|
||||
}
|
||||
[Fact]
|
||||
public async Task ShippedWindowsFacadeChatsExchangesPcmAndKeepsCaptureIdsAcrossChannelMoves()
|
||||
{
|
||||
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 alice = new Client("Alice", "test", tofuStorePath: Path.Combine(fixture.Directory, "alice.pins"));
|
||||
using var bob = new Client("Bob", "test", tofuStorePath: Path.Combine(fixture.Directory, "bob.pins"));
|
||||
await Login(alice, fixture, true); await Login(bob, fixture);
|
||||
Assert.True(alice.GetPermissions().IsAdmin);
|
||||
string? body = null; bob.EventReceived += e => { if (e.Type == VcEventType.TextMessage) body = e.Text; };
|
||||
Assert.Equal(VcResult.Ok, alice.SendText(VcTextScope.Channel, 1, "Managed Windows chat"));
|
||||
await Until(bob, () => body is not null); Assert.Equal("Managed Windows chat", body);
|
||||
Assert.Equal(VcResult.Ok, alice.JoinVoice()); Assert.Equal(VcResult.Ok, bob.JoinVoice());
|
||||
alice.SetInputMode(VcInputMode.AlwaysOn); bob.SetInputMode(VcInputMode.AlwaysOn);
|
||||
var a = alice.StartStreamExternalFeed(VcStreamKind.Mic, "Mic"); var b = bob.StartStreamExternalFeed(VcStreamKind.Mic, "Mic");
|
||||
Assert.Equal(VcResult.Ok, a.Result); Assert.Equal(VcResult.Ok, b.Result);
|
||||
await Until(bob, () => bob.ManagedClient.Users.Any(u => u.Streams.Count > 0 && u.Id != bob.ManagedClient.Authentication!.Self.Id));
|
||||
long aliceEnergy = 0, bobEnergy = 0;
|
||||
alice.ManagedClient.Audio.MixedPcm += pcm => { long sum = 0; foreach (short sample in pcm) sum += Math.Abs((int)sample); Interlocked.Add(ref aliceEnergy, sum); };
|
||||
bob.ManagedClient.Audio.MixedPcm += pcm => { long sum = 0; foreach (short sample in pcm) sum += Math.Abs((int)sample); Interlocked.Add(ref bobEnergy, sum); };
|
||||
short[] tone = Enumerable.Range(0, 960).Select(i => (short)(8000 * Math.Sin(i * Math.PI * 880 / 48000))).ToArray();
|
||||
for (int i = 0; i < 40; i++) { alice.StreamFeedPcm(a.StreamId, tone, 960, 1); bob.StreamFeedPcm(b.StreamId, tone, 960, 1); alice.PumpEvents(); bob.PumpEvents(); await Task.Delay(20); }
|
||||
Assert.True(Interlocked.Read(ref aliceEnergy) > 100000); Assert.True(Interlocked.Read(ref bobEnergy) > 100000);
|
||||
uint oldId = alice.ManagedClient.LocalStreams.Single().StreamId;
|
||||
await alice.ManagedClient.RequestAsync(new() { CreateChannel = new() { Channel = new() { Name = "Stereo", ParentId = 1, Audio = AudioEngineTests.Stream(20, true).Audio } } });
|
||||
await Until(alice, () => alice.ListChannels().Any(c => c.Name == "Stereo"));
|
||||
uint channel = alice.ListChannels().Single(c => c.Name == "Stereo").Id;
|
||||
Assert.Equal(VcResult.Ok, alice.JoinChannel(channel));
|
||||
await Until(alice, () => alice.ManagedClient.LocalStreams.Any(s => s.StreamId != oldId));
|
||||
Assert.Equal(a.StreamId, Assert.Single(alice.ListUserStreams(alice.ManagedClient.Authentication!.Self.Id)).StreamId);
|
||||
Assert.True(alice.GetStreamAudioConfig(alice.ManagedClient.Authentication!.Self.Id, a.StreamId).Config!.Stereo);
|
||||
Assert.Equal(VcResult.Ok, alice.StreamFeedPcm(a.StreamId, tone, 960, 1));
|
||||
Assert.Equal(VcResult.Ok, alice.StopStream(a.StreamId));
|
||||
Assert.Empty(alice.ManagedClient.LocalStreams);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.Test.Sdk": {
|
||||
"type": "Direct",
|
||||
"requested": "[17.14.1, )",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.CodeCoverage": "17.14.1",
|
||||
"Microsoft.TestPlatform.TestHost": "17.14.1"
|
||||
}
|
||||
},
|
||||
"xunit": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.9.3, )",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
|
||||
"dependencies": {
|
||||
"xunit.analyzers": "1.18.0",
|
||||
"xunit.assert": "2.9.3",
|
||||
"xunit.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.runner.visualstudio": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.1.1, )",
|
||||
"resolved": "3.1.1",
|
||||
"contentHash": "gNu2zhnuwjq5vQlU4S7yK/lfaKZDLmtcu+vTjnhfTlMAUYn+Hmgu8IIX0UCwWepYkk+Szx03DHx1bDnc9Fd+9w=="
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"Microsoft.CodeCoverage": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
|
||||
},
|
||||
"Microsoft.Data.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"Microsoft.TestPlatform.ObjectModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ=="
|
||||
},
|
||||
"Microsoft.TestPlatform.TestHost": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.TestPlatform.ObjectModel": "17.14.1",
|
||||
"Newtonsoft.Json": "13.0.3"
|
||||
}
|
||||
},
|
||||
"Newtonsoft.Json": {
|
||||
"type": "Transitive",
|
||||
"resolved": "13.0.3",
|
||||
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
|
||||
},
|
||||
"SourceGear.sqlite3": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.50.4.2",
|
||||
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
|
||||
},
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.0.2",
|
||||
"contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.config.e_sqlite3": "3.0.2",
|
||||
"SourceGear.sqlite3": "3.50.4.2"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.config.e_sqlite3": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.0.2",
|
||||
"contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.provider.e_sqlite3": "3.0.2"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.0.2",
|
||||
"contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ=="
|
||||
},
|
||||
"SQLitePCLRaw.provider.e_sqlite3": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.0.2",
|
||||
"contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "3.0.2"
|
||||
}
|
||||
},
|
||||
"xunit.abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.3",
|
||||
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
|
||||
},
|
||||
"xunit.analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.18.0",
|
||||
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
|
||||
},
|
||||
"xunit.assert": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
|
||||
},
|
||||
"xunit.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]",
|
||||
"xunit.extensibility.execution": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
|
||||
"dependencies": {
|
||||
"xunit.abstractions": "2.0.3"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.execution": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"voicecat.audio": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Codec": "[1.0.0, )",
|
||||
"VoiceCat.Dsp": "[1.0.0, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.codec": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Audio": "[1.0.0, )",
|
||||
"VoiceCat.Crypto": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "[2.6.2, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.windows": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Core": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "[3.36.1, )"
|
||||
}
|
||||
},
|
||||
"voicecat.server": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Microsoft.Data.Sqlite.Core": "[10.0.5, )",
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": "[3.0.2, )",
|
||||
"SourceGear.sqlite3": "[3.50.4.2, )",
|
||||
"VoiceCat.Crypto": "[1.0.0, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
/*
|
||||
* test_channel_samplerate — per-channel sample_rate as an Opus bandwidth cap.
|
||||
*
|
||||
* The codec always runs at 48 kHz internally (docs/voice.md §3); a channel's sample_rate is
|
||||
* carried as OPUS_SET_MAX_BANDWIDTH so a low-bitrate / narrowband room can constrain the encoded
|
||||
* audio bandwidth without changing the PCM clock. This verifies the cap is actually in effect:
|
||||
*
|
||||
* - A channel at sample_rate = 8000 (NARROWBAND, ~4 kHz audio) and a channel at 48000
|
||||
* (FULLBAND) are each fed an identical 7 kHz tone (well above the narrowband edge).
|
||||
* - The narrowband channel's decoded energy must be substantially lower — the only difference
|
||||
* between the two runs is the channel's sample_rate, so a lower energy proves the bandwidth
|
||||
* cap filtered the out-of-band tone.
|
||||
* - vc_get_stream_audio_config reports the channel's configured sample_rate (not 48000).
|
||||
*/
|
||||
#include <cstdio>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
}} while (0)
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool generic_result_received{false};
|
||||
bool generic_ok{false};
|
||||
bool voice_subscribed{false};
|
||||
std::vector<std::pair<uint32_t, uint32_t>> streams_started; // (user_id, stream_id)
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY: vc_confirm_server_identity(s->client, 1); break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break;
|
||||
case VC_EVENT_VOICE_STATE: s->voice_subscribed = (ev->u32a == 1); break;
|
||||
case VC_EVENT_GENERIC_RESULT:
|
||||
s->generic_result_received = true;
|
||||
s->generic_ok = (ev->result == VC_OK);
|
||||
break;
|
||||
case VC_EVENT_STREAM_STARTED: s->streams_started.emplace_back(ev->user_id, ev->stream_id); break;
|
||||
default: break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template <typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static bool connect_guest(vc_client*& client, const char* name, const char* label,
|
||||
uint16_t port, EventStore& ev) {
|
||||
vc_callbacks cb{on_event, nullptr, &ev};
|
||||
vc_config cfg{label, "0.1", VC_LOG_OFF};
|
||||
client = vc_client_create(&cfg, cb);
|
||||
if (!client) return false;
|
||||
ev.client = client;
|
||||
ev.label = label;
|
||||
if (vc_set_external_playback(client, 1) != VC_OK) return false;
|
||||
if (vc_connect(client, "127.0.0.1", port) != VC_OK) return false;
|
||||
if (vc_authenticate_guest(client, name) != VC_OK) return false;
|
||||
if (!wait_for(ev, [](EventStore& s) { return s.auth_ok; }, 8000)) return false;
|
||||
if (!wait_for(ev, [](EventStore& s) { return s.channel_list_received; }, 3000)) return false;
|
||||
if (vc_join_voice(client) != VC_OK) return false;
|
||||
if (!wait_for(ev, [](EventStore& s) { return s.voice_subscribed; }, 5000)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 7 kHz tone — above the NARROWBAND (~4 kHz) edge, within FULLBAND.
|
||||
static std::vector<int16_t> make_tone(int n, float hz) {
|
||||
std::vector<int16_t> pcm(static_cast<size_t>(n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * hz * t) * 16000.0f);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
struct SinkData {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
std::atomic<int> call_count{0};
|
||||
int64_t total_energy = 0;
|
||||
};
|
||||
|
||||
static void pcm_sink(void* user, uint32_t, uint32_t,
|
||||
const int16_t* pcm, size_t n, uint32_t channels, uint32_t) {
|
||||
auto* d = static_cast<SinkData*>(user);
|
||||
std::lock_guard lk(d->mu);
|
||||
for (size_t i = 0; i < n * channels; ++i)
|
||||
d->total_energy += std::abs(static_cast<int>(pcm[i]));
|
||||
d->call_count.fetch_add(1, std::memory_order_relaxed);
|
||||
d->cv.notify_all();
|
||||
}
|
||||
|
||||
static bool sink_wait(SinkData& d, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(d.mu);
|
||||
return d.cv.wait_until(lk, deadline, [&] { return d.call_count.load() > 0; });
|
||||
}
|
||||
|
||||
static uint32_t make_channel(vc_client* admin, EventStore& evAdmin, const char* name,
|
||||
uint32_t sample_rate) {
|
||||
vc_channel_info ch{};
|
||||
ch.name = name;
|
||||
ch.audio.codec = 0; // OPUS
|
||||
ch.audio.mode = 0; // mono
|
||||
ch.audio.sample_rate = sample_rate;
|
||||
ch.audio.bitrate_bps = 32000;
|
||||
ch.audio.frame_ms = 20;
|
||||
ch.audio.fec = 1;
|
||||
ch.audio.complexity = 10;
|
||||
|
||||
{ std::lock_guard lk(evAdmin.mu); evAdmin.generic_result_received = false; }
|
||||
if (vc_create_channel(admin, &ch) != VC_OK) return 0;
|
||||
if (!wait_for(evAdmin, [](EventStore& s) { return s.generic_result_received; }, 5000)) return 0;
|
||||
{ std::lock_guard lk(evAdmin.mu); if (!evAdmin.generic_ok) return 0; }
|
||||
|
||||
vc_channel_list cl{};
|
||||
if (vc_list_channels(admin, &cl) != VC_OK) return 0;
|
||||
uint32_t id = 0;
|
||||
for (size_t i = 0; i < cl.count; ++i)
|
||||
if (cl.items[i].name && std::string(cl.items[i].name) == name) { id = cl.items[i].id; break; }
|
||||
vc_free_channel_list(&cl);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Feed a 7 kHz tone through `channel_id` and return the decoded energy the sink observed.
|
||||
// Also asserts vc_get_stream_audio_config reports `expect_sr`.
|
||||
static int64_t run_case(uint16_t port, vc_client* admin, EventStore& evAdmin,
|
||||
uint32_t channel_id, uint32_t expect_sr, const char* tag) {
|
||||
EventStore evA, evB;
|
||||
vc_client *clientA = nullptr, *clientB = nullptr;
|
||||
CHECK(connect_guest(clientA, "SrA", "sr-a", port, evA));
|
||||
CHECK(connect_guest(clientB, "SrB", "sr-b", port, evB));
|
||||
int64_t energy = -1;
|
||||
SinkData sink; // Outlives both clients and their final audio callbacks.
|
||||
if (!clientA || !clientB) goto cleanup;
|
||||
|
||||
{
|
||||
uint32_t a_uid = 0, b_uid = 0;
|
||||
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
||||
{ std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; }
|
||||
|
||||
CHECK(vc_move_user(admin, a_uid, channel_id) == VC_OK);
|
||||
CHECK(vc_move_user(admin, b_uid, channel_id) == VC_OK);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
|
||||
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
|
||||
|
||||
vc_stream_desc desc{};
|
||||
desc.kind = VC_STREAM_MIC;
|
||||
desc.external_feed = 1; // Measure only the injected tone, never the host microphone.
|
||||
uint32_t a_sid = 0;
|
||||
CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK);
|
||||
|
||||
bool b_saw_a = wait_for(evB, [&](EventStore& s) {
|
||||
for (auto& [uid, sid] : s.streams_started)
|
||||
if (uid == a_uid) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(b_saw_a);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(400));
|
||||
|
||||
vc_audio_config ac{};
|
||||
CHECK(vc_get_stream_audio_config(clientA, a_uid, a_sid, &ac) == VC_OK);
|
||||
CHECK(ac.sample_rate == expect_sr);
|
||||
|
||||
auto tone = make_tone(960, 7000.0f);
|
||||
for (int i = 0; i < 300; ++i)
|
||||
CHECK(vc_stream_feed_pcm(clientA, a_sid, tone.data(), 960, 1) == VC_OK);
|
||||
|
||||
CHECK(sink_wait(sink, 5000));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
|
||||
{ std::lock_guard lk(sink.mu); energy = sink.total_energy; }
|
||||
std::printf("test_channel_samplerate[%s]: sr=%u calls=%d energy=%lld\n",
|
||||
tag, expect_sr, sink.call_count.load(), static_cast<long long>(energy));
|
||||
CHECK(sink.call_count.load() > 0);
|
||||
|
||||
vc_stream_stop(clientA, a_sid);
|
||||
}
|
||||
|
||||
cleanup:
|
||||
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
|
||||
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
|
||||
return energy;
|
||||
}
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_chansr_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err) || !db.create_account("admin", "pass", true, err)) {
|
||||
std::printf("FAIL: provision admin: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.media_port = 0;
|
||||
cfg.server_name = "VoiceCat-ChanSrTest";
|
||||
cfg.allow_guests = true;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not start\n");
|
||||
server.stop(); server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
std::printf("channel_samplerate: server ready on :%u\n", port);
|
||||
|
||||
EventStore evAdmin;
|
||||
evAdmin.label = "admin";
|
||||
vc_callbacks cbAdmin{on_event, nullptr, &evAdmin};
|
||||
vc_config cfgAdmin{"chansr-admin", "0.1", VC_LOG_OFF};
|
||||
vc_client* admin = vc_client_create(&cfgAdmin, cbAdmin);
|
||||
CHECK(admin != nullptr);
|
||||
evAdmin.client = admin;
|
||||
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(admin, "admin", "pass") == VC_OK);
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
|
||||
uint32_t ch_full = make_channel(admin, evAdmin, "FullBand", 48000);
|
||||
uint32_t ch_narrow = make_channel(admin, evAdmin, "NarrowBand", 8000);
|
||||
CHECK(ch_full != 0);
|
||||
CHECK(ch_narrow != 0);
|
||||
|
||||
int64_t full_energy = (ch_full ? run_case(port, admin, evAdmin, ch_full, 48000, "full") : -1);
|
||||
int64_t narrow_energy = (ch_narrow ? run_case(port, admin, evAdmin, ch_narrow, 8000, "narrow") : -1);
|
||||
|
||||
// The 7 kHz tone is above the narrowband (~4 kHz) cutoff: the narrowband channel must filter
|
||||
// most of it out, so its decoded energy is far below the full-band channel's. Generous margin
|
||||
// (< 50%) to stay robust across Opus versions while still proving the cap is in effect.
|
||||
CHECK(full_energy > 0);
|
||||
CHECK(narrow_energy >= 0);
|
||||
std::printf("channel_samplerate: full=%lld narrow=%lld ratio=%.3f\n",
|
||||
static_cast<long long>(full_energy), static_cast<long long>(narrow_energy),
|
||||
full_energy > 0 ? static_cast<double>(narrow_energy) / static_cast<double>(full_energy)
|
||||
: 0.0);
|
||||
CHECK(narrow_energy < full_energy / 2);
|
||||
|
||||
vc_disconnect(admin);
|
||||
vc_client_destroy(admin);
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("channel_samplerate: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("channel_samplerate: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
/*
|
||||
* test_channel_user_list_abi — M4 channel/user/stream snapshot getters.
|
||||
*
|
||||
* Covers the new pull-based ABI surface (voicecat.h): vc_list_channels, vc_list_users,
|
||||
* vc_list_user_streams, and the new VC_EVENT_JOIN_RESULT feedback for vc_join_channel.
|
||||
* Mirrors test_vad_ptt_devices.cpp's approach (real vc_client instances against a real
|
||||
* in-process server, not raw sockets).
|
||||
*
|
||||
* 1. Pre-data: a freshly created (not yet connected) client's getters return VC_OK,
|
||||
* count=0 — never an error just because nothing has arrived yet.
|
||||
* 2. After two guests connect and auth: vc_list_channels reflects the server's real
|
||||
* channel config (this is the regression test for the SessionModel field-population
|
||||
* fix — parent_id/password_protected/max_users were silently dropped before);
|
||||
* vc_list_users on either client includes both users with correct nickname/channel_id.
|
||||
* 3. After A starts a MIC stream: B's vc_list_user_streams(A's user_id) shows it, with the
|
||||
* same stream_id as A's own VC_EVENT_STREAM_STARTED.
|
||||
* 4. vc_list_user_streams with an unknown user_id returns VC_ERR_INVALID_ARG.
|
||||
* 5. vc_join_channel's result arrives via VC_EVENT_JOIN_RESULT — success (re-joining the
|
||||
* channel already in) and failure (an unknown channel id).
|
||||
* 6. Every vc_free_*_list is idempotent (safe to call twice).
|
||||
*/
|
||||
#include <cstdio>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
|
||||
// ── Event tracking ────────────────────────────────────────────────────────────
|
||||
|
||||
struct StreamEvent {
|
||||
bool started;
|
||||
uint32_t user_id;
|
||||
uint32_t stream_id;
|
||||
};
|
||||
|
||||
struct JoinResult {
|
||||
bool ok;
|
||||
uint32_t channel_id;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
std::vector<StreamEvent> stream_events;
|
||||
std::vector<JoinResult> join_results;
|
||||
bool voice_subscribed{false};
|
||||
|
||||
const char* label{nullptr};
|
||||
vc_client* client{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_VOICE_STATE:
|
||||
s->voice_subscribed = (ev->u32a == 1);
|
||||
break;
|
||||
case VC_EVENT_STREAM_STARTED:
|
||||
s->stream_events.push_back({true, ev->user_id, ev->stream_id});
|
||||
break;
|
||||
case VC_EVENT_STREAM_STOPPED:
|
||||
s->stream_events.push_back({false, ev->user_id, ev->stream_id});
|
||||
break;
|
||||
case VC_EVENT_JOIN_RESULT:
|
||||
s->join_results.push_back(
|
||||
{ev->result == VC_OK, ev->channel_id, ev->text ? ev->text : ""});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template <typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
// ── Test harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// ── 1. Pre-data: getters never error just because nothing has arrived yet ──────
|
||||
static void test_pre_data_empty() {
|
||||
vc_config cfg{"test-predata", "0.1", VC_LOG_OFF};
|
||||
vc_callbacks cb{};
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
|
||||
vc_channel_list cl{};
|
||||
CHECK(vc_list_channels(c, &cl) == VC_OK);
|
||||
CHECK(cl.count == 0);
|
||||
vc_free_channel_list(&cl);
|
||||
vc_free_channel_list(&cl); // idempotent
|
||||
|
||||
vc_user_list ul{};
|
||||
CHECK(vc_list_users(c, &ul) == VC_OK);
|
||||
CHECK(ul.count == 0);
|
||||
vc_free_user_list(&ul);
|
||||
vc_free_user_list(&ul); // idempotent
|
||||
|
||||
// No users known yet — any user_id is "unknown".
|
||||
vc_stream_summary_list sl{};
|
||||
CHECK(vc_list_user_streams(c, 1, &sl) == VC_ERR_INVALID_ARG);
|
||||
|
||||
vc_client_destroy(c);
|
||||
std::printf("test_pre_data_empty: ok\n");
|
||||
}
|
||||
|
||||
// ── 2-6. Real connect/auth/join/stream against a real in-process server ────────
|
||||
static void test_live_channel_user_list() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_chanlist_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.media_port = 0;
|
||||
cfg.server_name = "VoiceCat-ChanListTest";
|
||||
cfg.allow_guests = true;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
|
||||
if (!ok) {
|
||||
std::printf("FAIL: server did not become ready within 10s\n");
|
||||
++g_failures;
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t port = bound_port.load();
|
||||
std::printf("test_live_channel_user_list: server ready on :%u\n", port);
|
||||
|
||||
EventStore evA;
|
||||
evA.label = "A";
|
||||
vc_callbacks cbA{on_event, nullptr, &evA};
|
||||
vc_config cfgA{"test-A", "0.1", VC_LOG_OFF};
|
||||
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
||||
CHECK(clientA != nullptr);
|
||||
evA.client = clientA;
|
||||
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(clientA, "CL-A") == VC_OK);
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
|
||||
CHECK(vc_join_voice(clientA) == VC_OK);
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000));
|
||||
|
||||
EventStore evB;
|
||||
evB.label = "B";
|
||||
vc_callbacks cbB{on_event, nullptr, &evB};
|
||||
vc_config cfgB{"test-B", "0.1", VC_LOG_OFF};
|
||||
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
||||
CHECK(clientB != nullptr);
|
||||
evB.client = clientB;
|
||||
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(clientB, "CL-B") == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
|
||||
CHECK(vc_join_voice(clientB) == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000));
|
||||
|
||||
uint32_t a_uid = 0, b_uid = 0;
|
||||
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
||||
{ std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; }
|
||||
|
||||
// ── 2a. vc_list_channels reflects the real server config (regression test for the
|
||||
// SessionModel field-population fix — parent_id/password_protected/max_users). ──────────
|
||||
{
|
||||
vc_channel_list cl{};
|
||||
CHECK(vc_list_channels(clientA, &cl) == VC_OK);
|
||||
CHECK(cl.count == 2); // Lobby (1) + Music Room (2), per session_registry.cpp
|
||||
bool found_lobby = false;
|
||||
for (size_t i = 0; i < cl.count; ++i) {
|
||||
CHECK(cl.items[i].name != nullptr);
|
||||
if (cl.items[i].id == 1) {
|
||||
found_lobby = true;
|
||||
CHECK(std::strcmp(cl.items[i].name, "Lobby") == 0);
|
||||
CHECK(cl.items[i].parent_id == 0);
|
||||
CHECK(cl.items[i].password_protected == 0);
|
||||
CHECK(cl.items[i].max_users == 20); // non-default — proves the fix
|
||||
}
|
||||
}
|
||||
CHECK(found_lobby);
|
||||
vc_free_channel_list(&cl);
|
||||
vc_free_channel_list(&cl); // idempotent
|
||||
}
|
||||
|
||||
// ── 2b. vc_list_users includes both A and B with correct nickname/channel_id ─────────────
|
||||
{
|
||||
vc_user_list ul{};
|
||||
CHECK(vc_list_users(clientB, &ul) == VC_OK);
|
||||
CHECK(ul.count == 2);
|
||||
bool found_a = false, found_b = false;
|
||||
for (size_t i = 0; i < ul.count; ++i) {
|
||||
CHECK(ul.items[i].nickname != nullptr);
|
||||
CHECK(ul.items[i].channel_id == 1); // both default into Lobby on auth
|
||||
if (ul.items[i].id == a_uid) { found_a = true; CHECK(std::strcmp(ul.items[i].nickname, "CL-A") == 0); }
|
||||
if (ul.items[i].id == b_uid) { found_b = true; CHECK(std::strcmp(ul.items[i].nickname, "CL-B") == 0); }
|
||||
}
|
||||
CHECK(found_a);
|
||||
CHECK(found_b);
|
||||
vc_free_user_list(&ul);
|
||||
vc_free_user_list(&ul); // idempotent
|
||||
}
|
||||
|
||||
// ── 3. vc_list_user_streams reflects a real stream, cross-checked against the
|
||||
// STREAM_STARTED event's stream_id. ──────────────────────────────────────────────────────
|
||||
vc_stream_desc mic_desc{};
|
||||
mic_desc.kind = VC_STREAM_MIC;
|
||||
mic_desc.label = "mic";
|
||||
uint32_t mic_sid = 0;
|
||||
CHECK(vc_stream_start(clientA, &mic_desc, &mic_sid) == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return !s.stream_events.empty(); }, 5000));
|
||||
|
||||
{
|
||||
vc_stream_summary_list sl{};
|
||||
CHECK(vc_list_user_streams(clientB, a_uid, &sl) == VC_OK);
|
||||
CHECK(sl.count == 1);
|
||||
if (sl.count == 1) {
|
||||
CHECK(sl.items[0].stream_id == mic_sid);
|
||||
CHECK(sl.items[0].kind == VC_STREAM_MIC);
|
||||
CHECK(sl.items[0].label != nullptr);
|
||||
}
|
||||
vc_free_stream_summary_list(&sl);
|
||||
vc_free_stream_summary_list(&sl); // idempotent
|
||||
}
|
||||
|
||||
// ── 4. Unknown user_id ───────────────────────────────────────────────────────────────────
|
||||
{
|
||||
vc_stream_summary_list sl{};
|
||||
CHECK(vc_list_user_streams(clientB, 0xDEADBEEF, &sl) == VC_ERR_INVALID_ARG);
|
||||
}
|
||||
|
||||
// ── 5. VC_EVENT_JOIN_RESULT — success (re-join the channel already in) and failure
|
||||
// (unknown channel id). ─────────────────────────────────────────────────────────────────
|
||||
CHECK(vc_join_channel(clientA, 1, nullptr) == VC_OK);
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return !s.join_results.empty(); }, 3000));
|
||||
{
|
||||
std::lock_guard lk(evA.mu);
|
||||
CHECK(evA.join_results.back().ok);
|
||||
CHECK(evA.join_results.back().channel_id == 1);
|
||||
}
|
||||
|
||||
size_t mark;
|
||||
{ std::lock_guard lk(evA.mu); mark = evA.join_results.size(); }
|
||||
CHECK(vc_join_channel(clientA, 999999, nullptr) == VC_OK);
|
||||
CHECK(wait_for(evA, [&](EventStore& s) { return s.join_results.size() > mark; }, 3000));
|
||||
{
|
||||
std::lock_guard lk(evA.mu);
|
||||
CHECK(!evA.join_results.back().ok);
|
||||
CHECK(!evA.join_results.back().error.empty());
|
||||
}
|
||||
|
||||
vc_stream_stop(clientA, mic_sid);
|
||||
vc_disconnect(clientA);
|
||||
vc_disconnect(clientB);
|
||||
vc_client_destroy(clientA);
|
||||
vc_client_destroy(clientB);
|
||||
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
std::printf("test_live_channel_user_list: done\n");
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_pre_data_empty();
|
||||
test_live_channel_user_list();
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("channel_user_list_abi: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("channel_user_list_abi: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
/*
|
||||
* test_disconnect_left — regression test for the ungraceful-disconnect LEFT bug.
|
||||
*
|
||||
* Verifies that when a client's TCP connection drops (vc_disconnect / socket close /
|
||||
* process kill), the server broadcasts UserEvent::LEFT to remaining clients — so peer
|
||||
* user lists stay fresh and peer audio engines remove the stale stream (no eternal PLC
|
||||
* hiss). Before the fix, ConnSession::close() silently erased the user from the registry
|
||||
* without broadcasting, leaving ghost users and never-ending comfort noise on peers.
|
||||
*/
|
||||
#include <cstdio>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
bool auth_done{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool disconnected{false};
|
||||
|
||||
std::vector<uint32_t> joined_users;
|
||||
std::vector<uint32_t> left_users;
|
||||
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->auth_done = true;
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_USER_JOINED:
|
||||
s->joined_users.push_back(ev->user_id);
|
||||
break;
|
||||
case VC_EVENT_USER_LEFT:
|
||||
s->left_users.push_back(ev->user_id);
|
||||
break;
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template <typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool user_list_contains(vc_client* c, uint32_t uid) {
|
||||
vc_user_list ul{};
|
||||
if (vc_list_users(c, &ul) != VC_OK) return false;
|
||||
bool found = false;
|
||||
for (size_t i = 0; i < ul.count; ++i) {
|
||||
if (ul.items[i].id == uid) { found = true; break; }
|
||||
}
|
||||
vc_free_user_list(&ul);
|
||||
return found;
|
||||
}
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_disc_left_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.server_name = "VoiceCat-DiscLeftTest";
|
||||
cfg.allow_guests = true;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not become ready\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
|
||||
auto make_client = [&](const char* label, const char* nick) -> EventStore* {
|
||||
auto* ev = new EventStore();
|
||||
ev->label = label;
|
||||
vc_callbacks cb{on_event, nullptr, ev};
|
||||
vc_config cfgx{label, "0.1", VC_LOG_OFF};
|
||||
ev->client = vc_client_create(&cfgx, cb);
|
||||
if (!ev->client) return nullptr;
|
||||
if (vc_connect(ev->client, "127.0.0.1", port) != VC_OK) return nullptr;
|
||||
if (vc_authenticate_guest(ev->client, nick) != VC_OK) return nullptr;
|
||||
return ev;
|
||||
};
|
||||
|
||||
EventStore* evA = make_client("clientA", "Alpha");
|
||||
CHECK(evA != nullptr);
|
||||
CHECK(wait_for(*evA, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(*evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
uint32_t a_uid = evA->self_user_id;
|
||||
CHECK(a_uid != 0);
|
||||
|
||||
EventStore* evB = make_client("clientB", "Bravo");
|
||||
CHECK(evB != nullptr);
|
||||
CHECK(wait_for(*evB, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(*evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
uint32_t b_uid = evB->self_user_id;
|
||||
CHECK(b_uid != 0);
|
||||
|
||||
// A should see B join (broadcast_user_joined fires when B authenticates).
|
||||
CHECK(wait_for(*evA, [b_uid](EventStore& s) {
|
||||
for (auto u : s.joined_users) if (u == b_uid) return true;
|
||||
return false;
|
||||
}, 5000));
|
||||
|
||||
// Both should see each other in the authoritative user list.
|
||||
CHECK(user_list_contains(evA->client, b_uid));
|
||||
CHECK(user_list_contains(evB->client, a_uid));
|
||||
|
||||
// ── Drop A's TCP connection abruptly (no LeaveChannelRequest, no Goodbye —
|
||||
// just close the socket, exactly like vccli Ctrl-C or a network drop). ──
|
||||
vc_disconnect(evA->client);
|
||||
vc_client_destroy(evA->client);
|
||||
// (evA is now a dangling store; only evB is observed below.)
|
||||
|
||||
// B must receive VC_EVENT_USER_LEFT for A — the core fix under test.
|
||||
CHECK(wait_for(*evB, [a_uid](EventStore& s) {
|
||||
for (auto u : s.left_users) if (u == a_uid) return true;
|
||||
return false;
|
||||
}, 5000));
|
||||
|
||||
// B's authoritative user list must no longer contain A.
|
||||
// Give the event a moment to propagate through the SessionModel, then poll briefly.
|
||||
bool a_gone = false;
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
if (!user_list_contains(evB->client, a_uid)) { a_gone = true; break; }
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
CHECK(a_gone);
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────────────
|
||||
vc_disconnect(evB->client);
|
||||
vc_client_destroy(evB->client);
|
||||
delete evA;
|
||||
delete evB;
|
||||
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("disconnect_left: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("disconnect_left: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
/*
|
||||
* test_dred_toggle — DRED (Deep REDundancy) per-channel toggle.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. A channel created with dred=true in AudioConfig has the flag round-trip through
|
||||
* the protocol and observable via vc_get_stream_audio_config.
|
||||
* 2. The encoder initialises with DRED enabled (no crash, valid stream).
|
||||
* 3. PCM can be injected through the encode path when DRED is active.
|
||||
*/
|
||||
#include <cstdio>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
// ── Event tracking ────────────────────────────────────────────────────────────
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool generic_result_received{false};
|
||||
bool generic_ok{false};
|
||||
bool voice_subscribed{false};
|
||||
std::vector<std::pair<uint32_t,uint32_t>> streams_started; // (user_id, stream_id)
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_VOICE_STATE:
|
||||
s->voice_subscribed = (ev->u32a == 1);
|
||||
break;
|
||||
case VC_EVENT_GENERIC_RESULT:
|
||||
s->generic_result_received = true;
|
||||
s->generic_ok = (ev->result == VC_OK);
|
||||
break;
|
||||
case VC_EVENT_STREAM_STARTED:
|
||||
s->streams_started.emplace_back(ev->user_id, ev->stream_id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template <typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
}} while (0)
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_dred_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
// Pre-provision an admin account so the server doesn't auto-generate one.
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err)) {
|
||||
std::printf("FAIL: db.open: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
if (!db.create_account("admin", "pass", true, err)) {
|
||||
std::printf("FAIL: create admin: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Start server ─────────────────────────────────────────────────────────
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.media_port = 0;
|
||||
cfg.server_name = "VoiceCat-DredTest";
|
||||
cfg.allow_guests = true;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
|
||||
if (!ok) {
|
||||
std::printf("FAIL: server did not start\n");
|
||||
server.stop(); server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
std::printf("dred_toggle: server on :%u\n", port);
|
||||
|
||||
// ── Admin client — create a channel with DRED enabled ────────────────────
|
||||
EventStore evAdmin;
|
||||
evAdmin.label = "admin";
|
||||
vc_callbacks cbAdmin{on_event, nullptr, &evAdmin};
|
||||
vc_config cfgAdmin{"test-dred-admin", "0.1", VC_LOG_OFF};
|
||||
vc_client* admin = vc_client_create(&cfgAdmin, cbAdmin);
|
||||
CHECK(admin != nullptr);
|
||||
evAdmin.client = admin;
|
||||
|
||||
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(admin, "admin", "pass") == VC_OK);
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
|
||||
// Create a channel with DRED enabled.
|
||||
vc_channel_info ch{};
|
||||
ch.name = "DRED Test Channel";
|
||||
ch.audio.codec = 0; // OPUS
|
||||
ch.audio.mode = 0; // mono
|
||||
ch.audio.sample_rate = 48000;
|
||||
ch.audio.bitrate_bps = 24000;
|
||||
ch.audio.frame_ms = 20;
|
||||
ch.audio.fec = 1;
|
||||
ch.audio.expected_packet_loss = 5;
|
||||
ch.audio.complexity = 10;
|
||||
ch.audio.dred = 1; // ← DRED enabled
|
||||
|
||||
{ std::lock_guard lk(evAdmin.mu); evAdmin.generic_result_received = false; }
|
||||
CHECK(vc_create_channel(admin, &ch) == VC_OK);
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.generic_result_received; }, 5000));
|
||||
{ std::lock_guard lk(evAdmin.mu); CHECK(evAdmin.generic_ok); }
|
||||
|
||||
// Find the new channel id.
|
||||
vc_channel_list cl{};
|
||||
CHECK(vc_list_channels(admin, &cl) == VC_OK);
|
||||
CHECK(cl.count >= 3u);
|
||||
uint32_t dred_channel_id = 0;
|
||||
for (size_t i = 0; i < cl.count; ++i) {
|
||||
if (cl.items[i].name && std::string(cl.items[i].name) == "DRED Test Channel") {
|
||||
dred_channel_id = cl.items[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
vc_free_channel_list(&cl);
|
||||
CHECK(dred_channel_id != 0);
|
||||
std::printf("dred_toggle: DRED channel id=%u\n", dred_channel_id);
|
||||
|
||||
// ── Guest client — join DRED channel and start a stream ──────────────────
|
||||
EventStore evGuest;
|
||||
evGuest.label = "guest";
|
||||
vc_callbacks cbGuest{on_event, nullptr, &evGuest};
|
||||
vc_config cfgGuest{"test-dred-guest", "0.1", VC_LOG_OFF};
|
||||
vc_client* guest = vc_client_create(&cfgGuest, cbGuest);
|
||||
CHECK(guest != nullptr);
|
||||
evGuest.client = guest;
|
||||
|
||||
CHECK(vc_connect(guest, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(guest, "GuestUser") == VC_OK);
|
||||
CHECK(wait_for(evGuest, [](EventStore& s){ return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evGuest, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
|
||||
CHECK(vc_join_voice(guest) == VC_OK);
|
||||
CHECK(wait_for(evGuest, [](EventStore& s){ return s.voice_subscribed; }, 5000));
|
||||
|
||||
// Move the guest into the DRED channel.
|
||||
uint32_t guest_uid = 0;
|
||||
{ std::lock_guard lk(evGuest.mu); guest_uid = evGuest.self_user_id; }
|
||||
CHECK(vc_move_user(admin, guest_uid, dred_channel_id) == VC_OK);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(400));
|
||||
|
||||
// Start a MIC stream in the DRED channel.
|
||||
vc_stream_desc desc{};
|
||||
desc.kind = VC_STREAM_MIC;
|
||||
uint32_t stream_id = 0;
|
||||
CHECK(vc_stream_start(guest, &desc, &stream_id) == VC_OK);
|
||||
CHECK(stream_id != 0);
|
||||
|
||||
bool stream_started = wait_for(evGuest, [&](EventStore& s){
|
||||
for (auto& [uid, sid] : s.streams_started)
|
||||
if (uid == guest_uid) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(stream_started);
|
||||
|
||||
// Allow UDP binding to settle.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
|
||||
// ── Verify DRED flag is visible in effective audio config ─────────────────
|
||||
vc_audio_config ac{};
|
||||
vc_result rc = vc_get_stream_audio_config(guest, guest_uid, stream_id, &ac);
|
||||
CHECK(rc == VC_OK);
|
||||
CHECK(ac.dred == 1);
|
||||
std::printf("dred_toggle: effective dred=%d fec=%d bitrate=%u\n",
|
||||
ac.dred, ac.fec, ac.bitrate_bps);
|
||||
|
||||
// ── Inject PCM frames through the DRED-enabled encode path ───────────────
|
||||
// A 440 Hz sine frame at 48kHz / 20ms (960 samples).
|
||||
std::vector<int16_t> sine(960);
|
||||
for (int i = 0; i < 960; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
sine[i] = static_cast<int16_t>(
|
||||
std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
|
||||
}
|
||||
// Inject 10 frames — no crash = encoder + DRED extension running correctly.
|
||||
for (int i = 0; i < 10; ++i)
|
||||
vc_test_inject_capture(guest, stream_id, sine.data(), 960);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
vc_stream_stop(guest, stream_id);
|
||||
vc_disconnect(guest);
|
||||
vc_disconnect(admin);
|
||||
vc_client_destroy(guest);
|
||||
vc_client_destroy(admin);
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("dred_toggle: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("dred_toggle: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
/*
|
||||
* test_envelope — round-trip an Envelope through FrameCodec + encode/decode.
|
||||
* Runs only under dev (requires protobuf).
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "protocol/envelope.h"
|
||||
#include "protocol/protocol.h"
|
||||
|
||||
using namespace voicecat::protocol;
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
int main() {
|
||||
// Build a ClientHello envelope.
|
||||
voicecat::v1::Envelope out_env;
|
||||
out_env.set_request_id(42);
|
||||
auto* hello = out_env.mutable_client_hello();
|
||||
hello->set_proto_version(1);
|
||||
hello->set_client_name("test-client");
|
||||
hello->set_client_version("0.0.1");
|
||||
hello->add_features("text");
|
||||
|
||||
// encode_envelope → framed wire bytes
|
||||
std::vector<uint8_t> wire;
|
||||
bool enc_ok = encode_envelope(out_env, wire);
|
||||
CHECK(enc_ok);
|
||||
CHECK(wire.size() > kLengthHeaderSize);
|
||||
|
||||
// Feed through FrameCodec to extract the payload
|
||||
FrameCodec codec;
|
||||
std::vector<std::vector<uint8_t>> frames;
|
||||
bool feed_ok = codec.feed(wire.data(), wire.size(), frames);
|
||||
CHECK(feed_ok);
|
||||
CHECK(frames.size() == 1);
|
||||
|
||||
// decode_envelope from the extracted payload
|
||||
voicecat::v1::Envelope in_env;
|
||||
bool dec_ok = decode_envelope(frames[0], in_env);
|
||||
CHECK(dec_ok);
|
||||
|
||||
// Verify round-trip fidelity
|
||||
CHECK(in_env.request_id() == 42);
|
||||
CHECK(in_env.has_client_hello());
|
||||
CHECK(in_env.client_hello().proto_version() == 1);
|
||||
CHECK(std::strcmp(in_env.client_hello().client_name().c_str(), "test-client") == 0);
|
||||
CHECK(in_env.client_hello().features_size() == 1);
|
||||
CHECK(std::strcmp(in_env.client_hello().features(0).c_str(), "text") == 0);
|
||||
|
||||
// next_request_id() is monotonically increasing
|
||||
uint64_t r1 = next_request_id();
|
||||
uint64_t r2 = next_request_id();
|
||||
CHECK(r2 == r1 + 1);
|
||||
|
||||
// Empty envelope round-trips cleanly
|
||||
{
|
||||
voicecat::v1::Envelope empty;
|
||||
std::vector<uint8_t> w2;
|
||||
CHECK(encode_envelope(empty, w2));
|
||||
FrameCodec c2;
|
||||
std::vector<std::vector<uint8_t>> f2;
|
||||
CHECK(c2.feed(w2.data(), w2.size(), f2));
|
||||
CHECK(f2.size() == 1);
|
||||
voicecat::v1::Envelope e2;
|
||||
CHECK(decode_envelope(f2[0], e2));
|
||||
CHECK(e2.request_id() == 0);
|
||||
CHECK(e2.body_case() == voicecat::v1::Envelope::BODY_NOT_SET);
|
||||
}
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("envelope: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("envelope: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
/*
|
||||
* test_external_pcm — external PCM feed/tap API.
|
||||
*
|
||||
* Three headless behavior tests (no audio hardware, no simulator):
|
||||
*
|
||||
* test_feed_pcm_round_trip — A feeds mono 440 Hz sine via vc_stream_feed_pcm; B's pcm_sink
|
||||
* fires with non-zero energy, proving the full pipeline (feed→encode→relay→decode→sink).
|
||||
*
|
||||
* test_feed_pcm_stereo — A and B join Music Room (stereo/128kbps). A feeds interleaved
|
||||
* stereo PCM (loud-L / silent-R) via vc_stream_feed_pcm(channels=2). B's pcm_sink
|
||||
* asserts L-channel energy > R-channel energy (real stereo bitstream, not a mono upmix).
|
||||
*
|
||||
* test_pcm_sink — Verifies sink metadata: correct user_id / stream_id per frame,
|
||||
* sample_rate = 48000, and that cb=NULL disables delivery.
|
||||
*/
|
||||
#include <cstdio>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
}} while (0)
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool join_ok{false};
|
||||
bool voice_subscribed{false};
|
||||
std::vector<std::pair<uint32_t, uint32_t>> streams_started; // (user_id, stream_id)
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_VOICE_STATE:
|
||||
s->voice_subscribed = (ev->u32a == 1);
|
||||
break;
|
||||
case VC_EVENT_JOIN_RESULT:
|
||||
s->join_ok = (ev->result == VC_OK);
|
||||
break;
|
||||
case VC_EVENT_STREAM_STARTED:
|
||||
s->streams_started.emplace_back(ev->user_id, ev->stream_id);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template <typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static bool connect_guest(vc_client*& client, const char* name, const char* label,
|
||||
uint16_t port, EventStore& ev) {
|
||||
vc_callbacks cb{on_event, nullptr, &ev};
|
||||
vc_config cfg{label, "0.1", VC_LOG_OFF};
|
||||
client = vc_client_create(&cfg, cb);
|
||||
if (!client) return false;
|
||||
ev.client = client;
|
||||
ev.label = label;
|
||||
if (vc_connect(client, "127.0.0.1", port) != VC_OK) return false;
|
||||
if (vc_authenticate_guest(client, name) != VC_OK) return false;
|
||||
if (!wait_for(ev, [](EventStore& s) { return s.auth_ok; }, 8000)) return false;
|
||||
if (!wait_for(ev, [](EventStore& s) { return s.channel_list_received; }, 3000)) return false;
|
||||
if (vc_join_voice(client) != VC_OK) return false;
|
||||
if (!wait_for(ev, [](EventStore& s) { return s.voice_subscribed; }, 5000)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static std::vector<int16_t> make_sine_mono(int n) {
|
||||
std::vector<int16_t> pcm(static_cast<size_t>(n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
// Loud-left / silent-right interleaved stereo (n samples per channel).
|
||||
static std::vector<int16_t> make_sine_stereo(int n) {
|
||||
std::vector<int16_t> pcm(static_cast<size_t>(n) * 2);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
pcm[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
|
||||
pcm[i * 2 + 1] = 0;
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
// ── PCM sink state ────────────────────────────────────────────────────────────
|
||||
|
||||
struct SinkData {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
std::atomic<int> call_count{0};
|
||||
uint32_t last_user_id = 0;
|
||||
uint32_t last_stream_id = 0;
|
||||
uint32_t last_channels = 0;
|
||||
uint32_t last_sample_rate = 0;
|
||||
int64_t total_energy = 0;
|
||||
int64_t left_energy = 0; // sum |pcm[i*2]| for stereo frames
|
||||
int64_t right_energy = 0; // sum |pcm[i*2+1]| for stereo frames
|
||||
};
|
||||
|
||||
static void pcm_sink(void* user, uint32_t uid, uint32_t sid,
|
||||
const int16_t* pcm, size_t n, uint32_t channels, uint32_t sr) {
|
||||
auto* d = static_cast<SinkData*>(user);
|
||||
std::lock_guard lk(d->mu);
|
||||
d->last_user_id = uid;
|
||||
d->last_stream_id = sid;
|
||||
d->last_channels = channels;
|
||||
d->last_sample_rate = sr;
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
if (channels == 2) {
|
||||
d->left_energy += std::abs(static_cast<int>(pcm[i * 2]));
|
||||
d->right_energy += std::abs(static_cast<int>(pcm[i * 2 + 1]));
|
||||
}
|
||||
for (uint32_t c = 0; c < channels; ++c)
|
||||
d->total_energy += std::abs(static_cast<int>(pcm[i * channels + c]));
|
||||
}
|
||||
d->call_count.fetch_add(1, std::memory_order_relaxed);
|
||||
d->cv.notify_all();
|
||||
}
|
||||
|
||||
static bool sink_wait(SinkData& d, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(d.mu);
|
||||
return d.cv.wait_until(lk, deadline, [&] { return d.call_count.load() > 0; });
|
||||
}
|
||||
|
||||
// ── test_feed_pcm_round_trip ──────────────────────────────────────────────────
|
||||
|
||||
static void test_feed_pcm_round_trip(uint16_t port) {
|
||||
std::printf("test_feed_pcm_round_trip: start\n");
|
||||
|
||||
EventStore evA, evB;
|
||||
vc_client *clientA = nullptr, *clientB = nullptr;
|
||||
CHECK(connect_guest(clientA, "PcmA", "pcm-a", port, evA));
|
||||
CHECK(connect_guest(clientB, "PcmB", "pcm-b", port, evB));
|
||||
if (!clientA || !clientB) goto cleanup_rt;
|
||||
|
||||
{
|
||||
uint32_t a_uid = 0;
|
||||
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
||||
|
||||
// B registers a sink before A starts speaking.
|
||||
SinkData sink;
|
||||
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
|
||||
// A announces a MIC stream in Lobby.
|
||||
vc_stream_desc desc{};
|
||||
desc.kind = VC_STREAM_MIC;
|
||||
uint32_t a_sid = 0;
|
||||
CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK);
|
||||
|
||||
bool b_saw_a = wait_for(evB, [&](EventStore& s) {
|
||||
for (auto& [uid, sid] : s.streams_started)
|
||||
if (uid == a_uid) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(b_saw_a);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
|
||||
// A feeds 250 mono frames via the new public API.
|
||||
auto sine = make_sine_mono(960);
|
||||
for (int i = 0; i < 250; ++i)
|
||||
CHECK(vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1) == VC_OK);
|
||||
|
||||
// Wait for the sink to fire at least once (decode arrived).
|
||||
bool fired = sink_wait(sink, 5000);
|
||||
CHECK(fired);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
|
||||
int calls = sink.call_count.load();
|
||||
int64_t energy = sink.total_energy;
|
||||
std::printf("test_feed_pcm_round_trip: sink calls=%d energy=%lld\n",
|
||||
calls, static_cast<long long>(energy));
|
||||
CHECK(calls > 0);
|
||||
CHECK(energy > 0);
|
||||
|
||||
vc_stream_stop(clientA, a_sid);
|
||||
CHECK(vc_set_pcm_sink(clientB, nullptr, nullptr) == VC_OK);
|
||||
}
|
||||
|
||||
cleanup_rt:
|
||||
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
|
||||
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
|
||||
std::printf("test_feed_pcm_round_trip: done\n");
|
||||
}
|
||||
|
||||
// ── test_feed_pcm_stereo ──────────────────────────────────────────────────────
|
||||
|
||||
static void test_feed_pcm_stereo(uint16_t port) {
|
||||
std::printf("test_feed_pcm_stereo: start\n");
|
||||
|
||||
EventStore evA, evB;
|
||||
vc_client *clientA = nullptr, *clientB = nullptr;
|
||||
CHECK(connect_guest(clientA, "StA", "stereo-a", port, evA));
|
||||
CHECK(connect_guest(clientB, "StB", "stereo-b", port, evB));
|
||||
if (!clientA || !clientB) goto cleanup_st;
|
||||
|
||||
{
|
||||
uint32_t a_uid = 0;
|
||||
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
||||
|
||||
// Both join Music Room (channel 2, stereo/128kbps) so the encoder is stereo.
|
||||
CHECK(vc_join_channel(clientA, 2, nullptr) == VC_OK);
|
||||
CHECK(vc_join_channel(clientB, 2, nullptr) == VC_OK);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(600));
|
||||
|
||||
SinkData sink;
|
||||
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
|
||||
// A announces a MIC stream — effective_params will be stereo (Music Room config).
|
||||
vc_stream_desc desc{};
|
||||
desc.kind = VC_STREAM_MIC;
|
||||
uint32_t a_sid = 0;
|
||||
CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK);
|
||||
|
||||
bool b_saw_a = wait_for(evB, [&](EventStore& s) {
|
||||
for (auto& [uid, sid] : s.streams_started)
|
||||
if (uid == a_uid) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(b_saw_a);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
|
||||
// A feeds 250 stereo frames: loud-L / silent-R.
|
||||
auto stereo = make_sine_stereo(960);
|
||||
for (int i = 0; i < 250; ++i)
|
||||
CHECK(vc_stream_feed_pcm(clientA, a_sid, stereo.data(), 960, 2) == VC_OK);
|
||||
|
||||
bool fired = sink_wait(sink, 5000);
|
||||
CHECK(fired);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
|
||||
int64_t L = sink.left_energy;
|
||||
int64_t R = sink.right_energy;
|
||||
uint32_t ch = sink.last_channels;
|
||||
std::printf("test_feed_pcm_stereo: channels=%u L_energy=%lld R_energy=%lld\n",
|
||||
ch, static_cast<long long>(L), static_cast<long long>(R));
|
||||
|
||||
CHECK(ch == 2); // decoder delivered stereo frames
|
||||
CHECK(L > 0); // left channel has signal
|
||||
// Opus stereo coding (mid/side): R won't be exactly 0 after decode, but should be
|
||||
// substantially quieter than L. Allow up to 30% leakage.
|
||||
CHECK(R < L || L == 0); // L >= R (loud-L / quiet-R)
|
||||
|
||||
vc_stream_stop(clientA, a_sid);
|
||||
CHECK(vc_set_pcm_sink(clientB, nullptr, nullptr) == VC_OK);
|
||||
}
|
||||
|
||||
cleanup_st:
|
||||
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
|
||||
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
|
||||
std::printf("test_feed_pcm_stereo: done\n");
|
||||
}
|
||||
|
||||
// ── test_pcm_sink ─────────────────────────────────────────────────────────────
|
||||
|
||||
static void test_pcm_sink(uint16_t port) {
|
||||
std::printf("test_pcm_sink: start\n");
|
||||
|
||||
EventStore evA, evB;
|
||||
vc_client *clientA = nullptr, *clientB = nullptr;
|
||||
CHECK(connect_guest(clientA, "SnkA", "snk-a", port, evA));
|
||||
CHECK(connect_guest(clientB, "SnkB", "snk-b", port, evB));
|
||||
if (!clientA || !clientB) goto cleanup_sk;
|
||||
|
||||
{
|
||||
uint32_t a_uid = 0;
|
||||
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
||||
|
||||
SinkData sink;
|
||||
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
|
||||
vc_stream_desc desc{};
|
||||
desc.kind = VC_STREAM_MIC;
|
||||
uint32_t a_sid = 0;
|
||||
CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK);
|
||||
|
||||
bool b_saw_a = wait_for(evB, [&](EventStore& s) {
|
||||
for (auto& [uid, sid] : s.streams_started)
|
||||
if (uid == a_uid) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(b_saw_a);
|
||||
|
||||
// Capture the stream_id that B observed for A's stream.
|
||||
uint32_t b_a_sid = 0;
|
||||
{ std::lock_guard lk(evB.mu);
|
||||
for (auto& [uid, sid] : evB.streams_started)
|
||||
if (uid == a_uid) { b_a_sid = sid; break; }
|
||||
}
|
||||
CHECK(b_a_sid != 0);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
|
||||
auto sine = make_sine_mono(960);
|
||||
for (int i = 0; i < 250; ++i)
|
||||
vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1);
|
||||
|
||||
bool fired = sink_wait(sink, 5000);
|
||||
CHECK(fired);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
|
||||
std::printf("test_pcm_sink: calls=%d user_id=%u stream_id=%u sr=%u energy=%lld\n",
|
||||
sink.call_count.load(),
|
||||
sink.last_user_id, sink.last_stream_id,
|
||||
sink.last_sample_rate,
|
||||
static_cast<long long>(sink.total_energy));
|
||||
|
||||
CHECK(sink.call_count.load() > 0);
|
||||
CHECK(sink.total_energy > 0);
|
||||
CHECK(sink.last_user_id == a_uid); // source user matches
|
||||
CHECK(sink.last_stream_id == b_a_sid); // source stream matches
|
||||
CHECK(sink.last_sample_rate == 48000); // always 48000
|
||||
|
||||
// Disable sink — subsequent frames must not reach the callback.
|
||||
CHECK(vc_set_pcm_sink(clientB, nullptr, nullptr) == VC_OK);
|
||||
int count_before_disable = sink.call_count.load();
|
||||
|
||||
// Feed more frames after disabling.
|
||||
for (int i = 0; i < 100; ++i)
|
||||
vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
|
||||
|
||||
// The count must not have increased (sink was disabled).
|
||||
int count_after = sink.call_count.load();
|
||||
std::printf("test_pcm_sink: count_before_disable=%d count_after=%d\n",
|
||||
count_before_disable, count_after);
|
||||
CHECK(count_after == count_before_disable);
|
||||
|
||||
vc_stream_stop(clientA, a_sid);
|
||||
}
|
||||
|
||||
cleanup_sk:
|
||||
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
|
||||
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
|
||||
std::printf("test_pcm_sink: done\n");
|
||||
}
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_extpcm_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.media_port = 0;
|
||||
cfg.server_name = "VoiceCat-ExtPcmTest";
|
||||
cfg.allow_guests = true;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
|
||||
if (!ok) {
|
||||
std::printf("FAIL: server did not start\n");
|
||||
server.stop(); server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t port = bound_port.load();
|
||||
std::printf("external_pcm: server ready on :%u\n", port);
|
||||
|
||||
test_feed_pcm_round_trip(port);
|
||||
test_feed_pcm_stereo(port);
|
||||
test_pcm_sink(port);
|
||||
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("external_pcm: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("external_pcm: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
/*
|
||||
* test_external_playback — verifies AudioEngine's external-playback mode (iOS VPIO path).
|
||||
*
|
||||
* When external playback is enabled, the engine opens NO hardware playback device; a mixer-timer
|
||||
* thread drives decode+mix on a ~20ms cadence and delivers the FINAL mixed PCM to the
|
||||
* mixed-output sink (the Swift AVAudioEngine VPIO renderer consumes this). This test asserts:
|
||||
* 1. The mixed sink fires steadily on the timer thread (count grows over time) with the right
|
||||
* format (48kHz, stereo), and carries real energy while a stream is being decoded.
|
||||
* 2. The per-stream pcm_sink still fires concurrently (both taps coexist).
|
||||
* 3. With no remote streams, the mixed sink KEEPS firing (silent-but-present blocks) so the
|
||||
* renderer has a continuous clock.
|
||||
*
|
||||
* White-box: constructs AudioEngine directly (no server, no audio hardware needed) — the timer
|
||||
* thread drives the mixer with no ma_device, which is the core new behavior under test.
|
||||
*/
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
|
||||
|
||||
#include "audio/audio_engine.h"
|
||||
#include "codec/opus_codec.h"
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// Shared state written by the sink callbacks (timer thread) and read by main.
|
||||
struct MixedSinkState {
|
||||
std::atomic<int> calls{0};
|
||||
std::atomic<int64_t> max_energy{0};
|
||||
std::atomic<uint32_t> last_channels{0};
|
||||
std::atomic<uint32_t> last_sample_rate{0};
|
||||
};
|
||||
static MixedSinkState g_mixed;
|
||||
static std::atomic<int> g_pcm_sink_calls{0};
|
||||
|
||||
static void mixed_cb(void* user, const int16_t* pcm, size_t spc, uint32_t ch, uint32_t sr) {
|
||||
auto* s = static_cast<MixedSinkState*>(user);
|
||||
s->calls.fetch_add(1, std::memory_order_relaxed);
|
||||
s->last_channels.store(ch, std::memory_order_relaxed);
|
||||
s->last_sample_rate.store(sr, std::memory_order_relaxed);
|
||||
int64_t e = 0;
|
||||
for (size_t i = 0; i < spc * ch; ++i) e += std::abs(static_cast<int>(pcm[i]));
|
||||
int64_t prev = s->max_energy.load(std::memory_order_relaxed);
|
||||
while (e > prev && !s->max_energy.compare_exchange_weak(prev, e, std::memory_order_relaxed)) {
|
||||
}
|
||||
}
|
||||
|
||||
static void pcm_cb(void*, uint32_t, uint32_t, const int16_t*, size_t, uint32_t, uint32_t) {
|
||||
g_pcm_sink_calls.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
int main() {
|
||||
voicecat::audio::AudioEngine engine;
|
||||
engine.set_external_playback(true);
|
||||
engine.set_mixed_output_sink(&mixed_cb, &g_mixed);
|
||||
engine.set_pcm_sink(&pcm_cb, nullptr);
|
||||
|
||||
voicecat::audio::AudioParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.capture_channels = 1;
|
||||
p.playback_channels = 2;
|
||||
p.frame_ms = 20;
|
||||
CHECK(engine.start(p)); // no hardware device opened — the timer thread drives the mixer
|
||||
|
||||
voicecat::codec::OpusParams op;
|
||||
op.sample_rate = 48000;
|
||||
op.frame_ms = 20;
|
||||
op.stereo = false;
|
||||
int frame_samples = voicecat::codec::opus_frame_samples(op); // 960
|
||||
|
||||
voicecat::codec::OpusEncoder enc;
|
||||
CHECK(enc.init(op));
|
||||
std::vector<int16_t> sine(static_cast<size_t>(frame_samples));
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
sine[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
|
||||
}
|
||||
uint8_t opus_buf[1500];
|
||||
int opus_len = enc.encode(sine.data(), frame_samples, opus_buf, sizeof(opus_buf));
|
||||
CHECK(opus_len > 0);
|
||||
|
||||
const uint32_t ssrc = 1;
|
||||
engine.init_recv_stream(ssrc, op, /*user_id=*/7, /*stream_id=*/3, /*is_voice=*/false);
|
||||
|
||||
// ── Phase 1: feed ~600ms of real frames; the timer must decode + mix them. ──────────
|
||||
uint32_t ts = 0;
|
||||
for (int i = 0; i < 30; ++i) { // 30 * 20ms = 600ms of audio
|
||||
voicecat::audio::JitterBuffer::Frame f;
|
||||
f.seq = static_cast<uint64_t>(i);
|
||||
f.timestamp = ts;
|
||||
f.fec_present = false;
|
||||
f.payload.assign(opus_buf, opus_buf + opus_len);
|
||||
engine.push_recv_frame(ssrc, std::move(f));
|
||||
ts += static_cast<uint32_t>(frame_samples);
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
|
||||
int active_calls = g_mixed.calls.load(std::memory_order_relaxed);
|
||||
std::printf("external_playback: phase1 mixed-sink calls=%d max_energy=%lld pcm_sink=%d\n",
|
||||
active_calls, static_cast<long long>(g_mixed.max_energy.load()),
|
||||
g_pcm_sink_calls.load());
|
||||
// ~25 blocks expected at a 20ms cadence over 500ms; allow generous slack for scheduler/debug.
|
||||
CHECK(active_calls >= 10);
|
||||
CHECK(g_mixed.max_energy.load(std::memory_order_relaxed) > 0); // real decoded audio in the mix
|
||||
CHECK(g_mixed.last_channels.load(std::memory_order_relaxed) == 2);
|
||||
CHECK(g_mixed.last_sample_rate.load(std::memory_order_relaxed) == 48000);
|
||||
CHECK(g_pcm_sink_calls.load(std::memory_order_relaxed) > 0); // per-stream tap coexists
|
||||
|
||||
// ── Phase 2: remove the stream; the mixed sink must KEEP firing (silent blocks). ─────
|
||||
engine.remove_stream(ssrc);
|
||||
int before = g_mixed.calls.load(std::memory_order_relaxed);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
int after = g_mixed.calls.load(std::memory_order_relaxed);
|
||||
std::printf("external_playback: phase2 silent blocks delivered=%d\n", after - before);
|
||||
CHECK(after - before >= 5); // continuous clock even with nothing to play
|
||||
|
||||
engine.stop(); // joins the mixer-timer thread
|
||||
enc.destroy();
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("external_playback: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("external_playback: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
int main() {
|
||||
std::printf("external_playback: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,150 +0,0 @@
|
||||
/*
|
||||
* test_frame_codec — unit test for FrameCodec::feed / ::emit.
|
||||
*
|
||||
* No third-party dependencies; runs under both the skeleton and dev presets.
|
||||
* Tests: empty payload, single byte, 64 KiB, exact max-size, oversized (should reject),
|
||||
* split delivery (bytes fed one-at-a-time), and batched multi-frame delivery.
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "protocol/protocol.h"
|
||||
|
||||
using namespace voicecat::protocol;
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// Round-trip a single payload through emit → feed.
|
||||
static void round_trip(const std::vector<uint8_t>& payload, const char* label) {
|
||||
std::vector<uint8_t> wire;
|
||||
FrameCodec::emit(payload, wire);
|
||||
|
||||
FrameCodec codec;
|
||||
std::vector<std::vector<uint8_t>> frames;
|
||||
bool ok = codec.feed(wire.data(), wire.size(), frames);
|
||||
CHECK(ok);
|
||||
CHECK(frames.size() == 1);
|
||||
if (!frames.empty()) {
|
||||
CHECK(frames[0] == payload);
|
||||
}
|
||||
(void)label;
|
||||
}
|
||||
|
||||
int main() {
|
||||
// --- Empty payload ---
|
||||
round_trip({}, "empty");
|
||||
|
||||
// --- Single byte ---
|
||||
round_trip({0xAB}, "1 byte");
|
||||
|
||||
// --- 64 KiB payload ---
|
||||
{
|
||||
std::vector<uint8_t> big(64 * 1024);
|
||||
for (size_t i = 0; i < big.size(); ++i) big[i] = static_cast<uint8_t>(i & 0xFF);
|
||||
round_trip(big, "64 KiB");
|
||||
}
|
||||
|
||||
// --- Exactly kMaxFrameBytes ---
|
||||
{
|
||||
std::vector<uint8_t> max_payload(kMaxFrameBytes, 0x5A);
|
||||
std::vector<uint8_t> wire;
|
||||
FrameCodec::emit(max_payload, wire);
|
||||
|
||||
FrameCodec codec;
|
||||
std::vector<std::vector<uint8_t>> frames;
|
||||
bool ok = codec.feed(wire.data(), wire.size(), frames);
|
||||
CHECK(ok);
|
||||
CHECK(frames.size() == 1);
|
||||
if (!frames.empty()) CHECK(frames[0] == max_payload);
|
||||
}
|
||||
|
||||
// --- One byte over kMaxFrameBytes — must be rejected ---
|
||||
{
|
||||
// Craft a fake header with length = kMaxFrameBytes + 1.
|
||||
uint32_t bad_len = kMaxFrameBytes + 1;
|
||||
uint8_t header[4] = {
|
||||
static_cast<uint8_t>((bad_len >> 24) & 0xFF),
|
||||
static_cast<uint8_t>((bad_len >> 16) & 0xFF),
|
||||
static_cast<uint8_t>((bad_len >> 8) & 0xFF),
|
||||
static_cast<uint8_t>( bad_len & 0xFF),
|
||||
};
|
||||
FrameCodec codec;
|
||||
std::vector<std::vector<uint8_t>> frames;
|
||||
bool ok = codec.feed(header, 4, frames);
|
||||
CHECK(!ok); // must return false
|
||||
CHECK(frames.empty());
|
||||
}
|
||||
|
||||
// --- Byte-at-a-time delivery (reassembly) ---
|
||||
{
|
||||
std::vector<uint8_t> payload = {1, 2, 3, 4, 5};
|
||||
std::vector<uint8_t> wire;
|
||||
FrameCodec::emit(payload, wire);
|
||||
|
||||
FrameCodec codec;
|
||||
std::vector<std::vector<uint8_t>> frames;
|
||||
bool ok = true;
|
||||
for (uint8_t b : wire) {
|
||||
ok = codec.feed(&b, 1, frames);
|
||||
if (!ok) break;
|
||||
}
|
||||
CHECK(ok);
|
||||
CHECK(frames.size() == 1);
|
||||
if (!frames.empty()) CHECK(frames[0] == payload);
|
||||
}
|
||||
|
||||
// --- Multiple frames in a single feed() call ---
|
||||
{
|
||||
std::vector<uint8_t> p1 = {0x01, 0x02};
|
||||
std::vector<uint8_t> p2 = {0xAA, 0xBB, 0xCC};
|
||||
std::vector<uint8_t> wire;
|
||||
FrameCodec::emit(p1, wire);
|
||||
FrameCodec::emit(p2, wire);
|
||||
|
||||
FrameCodec codec;
|
||||
std::vector<std::vector<uint8_t>> frames;
|
||||
bool ok = codec.feed(wire.data(), wire.size(), frames);
|
||||
CHECK(ok);
|
||||
CHECK(frames.size() == 2);
|
||||
if (frames.size() == 2) {
|
||||
CHECK(frames[0] == p1);
|
||||
CHECK(frames[1] == p2);
|
||||
}
|
||||
}
|
||||
|
||||
// --- pending_bytes() reflects partial state ---
|
||||
{
|
||||
std::vector<uint8_t> payload = {0xFF};
|
||||
std::vector<uint8_t> wire;
|
||||
FrameCodec::emit(payload, wire); // 5 bytes total (4 hdr + 1)
|
||||
|
||||
FrameCodec codec;
|
||||
std::vector<std::vector<uint8_t>> frames;
|
||||
|
||||
// Feed only the header.
|
||||
codec.feed(wire.data(), 4, frames);
|
||||
CHECK(frames.empty());
|
||||
CHECK(codec.pending_bytes() == 4);
|
||||
|
||||
// Feed the body.
|
||||
codec.feed(wire.data() + 4, 1, frames);
|
||||
CHECK(frames.size() == 1);
|
||||
CHECK(codec.pending_bytes() == 0);
|
||||
}
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("frame_codec: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("frame_codec: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
/*
|
||||
* test_frame_ms_reframe — channels whose frame_ms differs from the engine's 20 ms.
|
||||
*
|
||||
* The AudioEngine capture clock is fixed at 48 kHz / 20 ms, so capture and vc_stream_feed_pcm
|
||||
* always deliver 960-sample frames. A channel, however, may set any Opus frame_ms (docs/voice.md
|
||||
* §3: 2.5…60 ms). Before the reframe fix, on_capture_frame handed the engine's 960-sample frame
|
||||
* straight to an encoder configured for the channel's frame_ms: for frame_ms < 20 the receiver
|
||||
* sized its decode buffer too small and dropped every frame (dead audio); for frame_ms > 20 the
|
||||
* channel's setting was silently ignored. on_capture_frame now reframes to ls.frame_samples.
|
||||
*
|
||||
* Two end-to-end cases (admin creates the channel, two guests do a feed→encode→relay→decode→sink
|
||||
* round trip in it). Both assert the sink receives non-zero decoded energy at 48 kHz:
|
||||
*
|
||||
* frame_ms = 40 — larger window: two 960-sample engine frames accumulate into one 1920 encode.
|
||||
* frame_ms = 10 — smaller window: each 960-sample engine frame splits into two 480 encodes.
|
||||
* This is the case that was fully broken (OPUS_BUFFER_TOO_SMALL on decode).
|
||||
*/
|
||||
#include <cstdio>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
}} while (0)
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool generic_result_received{false};
|
||||
bool generic_ok{false};
|
||||
bool voice_subscribed{false};
|
||||
std::vector<std::pair<uint32_t, uint32_t>> streams_started; // (user_id, stream_id)
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY: vc_confirm_server_identity(s->client, 1); break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break;
|
||||
case VC_EVENT_VOICE_STATE: s->voice_subscribed = (ev->u32a == 1); break;
|
||||
case VC_EVENT_GENERIC_RESULT:
|
||||
s->generic_result_received = true;
|
||||
s->generic_ok = (ev->result == VC_OK);
|
||||
break;
|
||||
case VC_EVENT_STREAM_STARTED: s->streams_started.emplace_back(ev->user_id, ev->stream_id); break;
|
||||
default: break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template <typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static bool connect_guest(vc_client*& client, const char* name, const char* label,
|
||||
uint16_t port, EventStore& ev) {
|
||||
vc_callbacks cb{on_event, nullptr, &ev};
|
||||
vc_config cfg{label, "0.1", VC_LOG_OFF};
|
||||
client = vc_client_create(&cfg, cb);
|
||||
if (!client) return false;
|
||||
ev.client = client;
|
||||
ev.label = label;
|
||||
if (vc_connect(client, "127.0.0.1", port) != VC_OK) return false;
|
||||
if (vc_authenticate_guest(client, name) != VC_OK) return false;
|
||||
if (!wait_for(ev, [](EventStore& s) { return s.auth_ok; }, 8000)) return false;
|
||||
if (!wait_for(ev, [](EventStore& s) { return s.channel_list_received; }, 3000)) return false;
|
||||
if (vc_join_voice(client) != VC_OK) return false;
|
||||
if (!wait_for(ev, [](EventStore& s) { return s.voice_subscribed; }, 5000)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static std::vector<int16_t> make_sine_mono(int n) {
|
||||
std::vector<int16_t> pcm(static_cast<size_t>(n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
struct SinkData {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
std::atomic<int> call_count{0};
|
||||
uint32_t last_sample_rate = 0;
|
||||
int64_t total_energy = 0;
|
||||
};
|
||||
|
||||
static void pcm_sink(void* user, uint32_t, uint32_t,
|
||||
const int16_t* pcm, size_t n, uint32_t channels, uint32_t sr) {
|
||||
auto* d = static_cast<SinkData*>(user);
|
||||
std::lock_guard lk(d->mu);
|
||||
d->last_sample_rate = sr;
|
||||
for (size_t i = 0; i < n * channels; ++i)
|
||||
d->total_energy += std::abs(static_cast<int>(pcm[i]));
|
||||
d->call_count.fetch_add(1, std::memory_order_relaxed);
|
||||
d->cv.notify_all();
|
||||
}
|
||||
|
||||
static bool sink_wait(SinkData& d, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(d.mu);
|
||||
return d.cv.wait_until(lk, deadline, [&] { return d.call_count.load() > 0; });
|
||||
}
|
||||
|
||||
// Admin creates a mono channel with the given frame_ms; returns its id (0 on failure).
|
||||
static uint32_t make_channel(vc_client* admin, EventStore& evAdmin, const char* name,
|
||||
uint32_t frame_ms) {
|
||||
vc_channel_info ch{};
|
||||
ch.name = name;
|
||||
ch.audio.codec = 0; // OPUS
|
||||
ch.audio.mode = 0; // mono
|
||||
ch.audio.sample_rate = 48000;
|
||||
ch.audio.bitrate_bps = 24000;
|
||||
ch.audio.frame_ms = frame_ms;
|
||||
ch.audio.fec = 1;
|
||||
ch.audio.complexity = 5;
|
||||
|
||||
{ std::lock_guard lk(evAdmin.mu); evAdmin.generic_result_received = false; }
|
||||
if (vc_create_channel(admin, &ch) != VC_OK) return 0;
|
||||
if (!wait_for(evAdmin, [](EventStore& s) { return s.generic_result_received; }, 5000)) return 0;
|
||||
{ std::lock_guard lk(evAdmin.mu); if (!evAdmin.generic_ok) return 0; }
|
||||
|
||||
vc_channel_list cl{};
|
||||
if (vc_list_channels(admin, &cl) != VC_OK) return 0;
|
||||
uint32_t id = 0;
|
||||
for (size_t i = 0; i < cl.count; ++i)
|
||||
if (cl.items[i].name && std::string(cl.items[i].name) == name) { id = cl.items[i].id; break; }
|
||||
vc_free_channel_list(&cl);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Full feed→encode→relay→decode→sink round trip inside `channel_id`, asserting the channel's
|
||||
// frame_ms is in effect and the sink hears decoded energy.
|
||||
static void run_case(uint16_t port, vc_client* admin, EventStore& evAdmin,
|
||||
uint32_t channel_id, uint32_t expect_frame_ms, const char* tag) {
|
||||
std::printf("test_frame_ms_reframe[%s]: channel=%u frame_ms=%u\n", tag, channel_id,
|
||||
expect_frame_ms);
|
||||
|
||||
EventStore evA, evB;
|
||||
vc_client *clientA = nullptr, *clientB = nullptr;
|
||||
CHECK(connect_guest(clientA, "ReframeA", "rf-a", port, evA));
|
||||
CHECK(connect_guest(clientB, "ReframeB", "rf-b", port, evB));
|
||||
if (!clientA || !clientB) goto cleanup;
|
||||
|
||||
{
|
||||
uint32_t a_uid = 0, b_uid = 0;
|
||||
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
||||
{ std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; }
|
||||
|
||||
// Move both guests into the target channel so the relay is channel-scoped to them.
|
||||
CHECK(vc_move_user(admin, a_uid, channel_id) == VC_OK);
|
||||
CHECK(vc_move_user(admin, b_uid, channel_id) == VC_OK);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
|
||||
SinkData sink;
|
||||
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
|
||||
|
||||
vc_stream_desc desc{};
|
||||
desc.kind = VC_STREAM_MIC;
|
||||
uint32_t a_sid = 0;
|
||||
CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK);
|
||||
|
||||
bool b_saw_a = wait_for(evB, [&](EventStore& s) {
|
||||
for (auto& [uid, sid] : s.streams_started)
|
||||
if (uid == a_uid) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(b_saw_a);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(400));
|
||||
|
||||
// The channel's frame_ms must be the effective window on A's stream — proves the encoder
|
||||
// (and hence the reframe target) is configured for the non-20ms window.
|
||||
vc_audio_config ac{};
|
||||
CHECK(vc_get_stream_audio_config(clientA, a_uid, a_sid, &ac) == VC_OK);
|
||||
CHECK(ac.frame_ms == expect_frame_ms);
|
||||
CHECK(ac.sample_rate == 48000);
|
||||
|
||||
// Feed 300 engine-shaped (960-sample / 20 ms / 48 kHz) frames. on_capture_frame reframes
|
||||
// them to the channel's window before encoding.
|
||||
auto sine = make_sine_mono(960);
|
||||
for (int i = 0; i < 300; ++i)
|
||||
CHECK(vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1) == VC_OK);
|
||||
|
||||
CHECK(sink_wait(sink, 5000));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
|
||||
int calls = sink.call_count.load();
|
||||
int64_t energy = sink.total_energy;
|
||||
std::printf("test_frame_ms_reframe[%s]: sink calls=%d energy=%lld sr=%u\n",
|
||||
tag, calls, static_cast<long long>(energy), sink.last_sample_rate);
|
||||
CHECK(calls > 0);
|
||||
CHECK(energy > 0); // decoded audio actually arrived
|
||||
CHECK(sink.last_sample_rate == 48000);
|
||||
|
||||
vc_stream_stop(clientA, a_sid);
|
||||
CHECK(vc_set_pcm_sink(clientB, nullptr, nullptr) == VC_OK);
|
||||
}
|
||||
|
||||
cleanup:
|
||||
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
|
||||
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
|
||||
std::printf("test_frame_ms_reframe[%s]: done\n", tag);
|
||||
}
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_reframe_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
// Pre-provision an admin so we can create channels with custom frame_ms.
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err) || !db.create_account("admin", "pass", true, err)) {
|
||||
std::printf("FAIL: provision admin: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.media_port = 0;
|
||||
cfg.server_name = "VoiceCat-ReframeTest";
|
||||
cfg.allow_guests = true;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not start\n");
|
||||
server.stop(); server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
std::printf("frame_ms_reframe: server ready on :%u\n", port);
|
||||
|
||||
// Admin client creates the two test channels.
|
||||
EventStore evAdmin;
|
||||
evAdmin.label = "admin";
|
||||
vc_callbacks cbAdmin{on_event, nullptr, &evAdmin};
|
||||
vc_config cfgAdmin{"reframe-admin", "0.1", VC_LOG_OFF};
|
||||
vc_client* admin = vc_client_create(&cfgAdmin, cbAdmin);
|
||||
CHECK(admin != nullptr);
|
||||
evAdmin.client = admin;
|
||||
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(admin, "admin", "pass") == VC_OK);
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
|
||||
uint32_t ch40 = make_channel(admin, evAdmin, "Reframe40", 40);
|
||||
uint32_t ch10 = make_channel(admin, evAdmin, "Reframe10", 10);
|
||||
CHECK(ch40 != 0);
|
||||
CHECK(ch10 != 0);
|
||||
|
||||
if (ch40) run_case(port, admin, evAdmin, ch40, 40, "40ms");
|
||||
if (ch10) run_case(port, admin, evAdmin, ch10, 10, "10ms");
|
||||
|
||||
vc_disconnect(admin);
|
||||
vc_client_destroy(admin);
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("frame_ms_reframe: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("frame_ms_reframe: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
/*
|
||||
* test_jitter_depth — verifies the bounded-depth playout in AudioEngine::on_playback.
|
||||
*
|
||||
* Regression guard for the "latency keeps drifting backward, fixed only by rejoining" bug. The
|
||||
* sender omits VAD/PTT/DTX silence from its timestamps (a compressed timeline), while the
|
||||
* receiver's playout clock free-runs in real time. The old logic re-synced the playout clock to
|
||||
* the *oldest* buffered frame and could only ever *add* standing latency (a reordered/late frame
|
||||
* snapped the clock backward), with nothing to trim it — so latency ratcheted up across talkspurt
|
||||
* gaps. The fix keeps the clock a bounded `target` behind the *newest* arrival and frame-skips to
|
||||
* catch up, so depth stays bounded no matter the trigger.
|
||||
*
|
||||
* This drives many talkspurt/silence cycles with a compressed timeline plus a reordered straggler
|
||||
* each cycle (which previously snapped the clock backward), and asserts the buffered depth
|
||||
* (newest_ts - playout_ts) stays bounded while audio keeps playing. White-box via mix_for_test
|
||||
* (no audio hardware needed), same pattern as test_plc_cap.
|
||||
*/
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
|
||||
|
||||
#include "audio/audio_engine.h"
|
||||
#include "codec/opus_codec.h"
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static double rms(const int16_t* pcm, int n) {
|
||||
double sum = 0.0;
|
||||
for (int i = 0; i < n; ++i) sum += static_cast<double>(pcm[i]) * pcm[i];
|
||||
return std::sqrt(sum / n);
|
||||
}
|
||||
|
||||
int main() {
|
||||
voicecat::audio::AudioEngine engine;
|
||||
voicecat::audio::AudioParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.capture_channels = 1;
|
||||
p.playback_channels = 2;
|
||||
p.frame_ms = 20;
|
||||
CHECK(engine.start(p)); // no capture_cb — headless safe
|
||||
|
||||
voicecat::codec::OpusParams op;
|
||||
op.sample_rate = 48000;
|
||||
op.frame_ms = 20;
|
||||
op.stereo = false;
|
||||
const int frame_samples = voicecat::codec::opus_frame_samples(op); // 960
|
||||
|
||||
// A loud sine, encoded once, reused for every pushed frame.
|
||||
voicecat::codec::OpusEncoder enc;
|
||||
CHECK(enc.init(op));
|
||||
std::vector<int16_t> sine(static_cast<size_t>(frame_samples));
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
sine[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
|
||||
}
|
||||
uint8_t opus_buf[1500];
|
||||
const int opus_len = enc.encode(sine.data(), frame_samples, opus_buf, sizeof(opus_buf));
|
||||
CHECK(opus_len > 0);
|
||||
|
||||
const uint32_t ssrc = 1;
|
||||
engine.init_recv_stream(ssrc, op, /*user_id=*/0, /*stream_id=*/0, /*is_voice=*/false);
|
||||
|
||||
const uint32_t pb_frames = 480; // 10 ms hardware period
|
||||
const int out_n = static_cast<int>(pb_frames) * 2; // stereo interleaved
|
||||
std::vector<int16_t> out(static_cast<size_t>(out_n), 0);
|
||||
|
||||
auto mix_n = [&](int n) {
|
||||
for (int i = 0; i < n; ++i) engine.mix_for_test(out.data(), pb_frames);
|
||||
};
|
||||
auto push = [&](uint32_t ts, bool marker) {
|
||||
voicecat::audio::JitterBuffer::Frame f;
|
||||
f.seq = 0;
|
||||
f.timestamp = ts;
|
||||
f.fec_present = false;
|
||||
f.marker = marker;
|
||||
f.payload.assign(opus_buf, opus_buf + opus_len);
|
||||
engine.push_recv_frame(ssrc, std::move(f));
|
||||
};
|
||||
|
||||
uint32_t ts = 1000; // arbitrary non-zero start
|
||||
int32_t max_depth = 0;
|
||||
double last_voice_rms = 0.0;
|
||||
|
||||
// Seed the stream (first frame is a talkspurt marker, like a real resume).
|
||||
push(ts, /*marker=*/true);
|
||||
ts += static_cast<uint32_t>(frame_samples);
|
||||
mix_n(1);
|
||||
|
||||
// Drive the producer FASTER than the consumer: push one 960-sample frame per step but drain
|
||||
// only 480 samples (one pb_frames callback) — i.e. arrivals outrun playout by ~480 samples a
|
||||
// step, exactly the clock-drift / bursty-arrival condition that made latency ratchet up. Also
|
||||
// inject a reordered straggler periodically (the old backward-snap trigger). The bounded-depth
|
||||
// catch-up must keep the standing latency from growing without limit. Pre-fix (no catch-up,
|
||||
// snap-to-oldest) the depth would climb to ~hundreds of frames here.
|
||||
const int kSteps = 400;
|
||||
const uint32_t kStraggler = 48000u * 250u / 1000u; // 250 ms behind the leading edge
|
||||
for (int s = 0; s < kSteps; ++s) {
|
||||
push(ts, /*marker=*/false);
|
||||
ts += static_cast<uint32_t>(frame_samples);
|
||||
if (s % 25 == 12) push(ts - kStraggler, /*marker=*/false); // reordered straggler
|
||||
mix_n(1); // drain only 480 of the 960 produced — producer outruns consumer
|
||||
int32_t d = engine.stream_playout_depth_samples(ssrc);
|
||||
if (d > max_depth) max_depth = d;
|
||||
last_voice_rms = std::max(last_voice_rms, rms(out.data(), out_n));
|
||||
}
|
||||
|
||||
std::printf("jitter_depth: max_depth=%d samples (%.0f ms) voice_rms=%.1f\n", max_depth,
|
||||
static_cast<double>(max_depth) * 1000.0 / 48000.0, last_voice_rms);
|
||||
|
||||
// Bounded: with catch-up the standing latency stays near the adaptive target, well under
|
||||
// 200 ms even though arrivals outran playout for 400 steps (~4 s of pushed audio).
|
||||
CHECK(max_depth > 0); // playout ran / depth observed
|
||||
CHECK(max_depth < static_cast<int32_t>(48000 * 200 / 1000)); // bounded (was unbounded pre-fix)
|
||||
CHECK(last_voice_rms > 1.0); // audio keeps playing
|
||||
|
||||
engine.remove_stream(ssrc);
|
||||
engine.stop();
|
||||
enc.destroy();
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("jitter_depth: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("jitter_depth: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
int main() {
|
||||
std::printf("jitter_depth: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,255 +0,0 @@
|
||||
/*
|
||||
* test_m1_integration — M1 exit criterion.
|
||||
*
|
||||
* Two clients connect to a real voicecat-server over TLS 1.3:
|
||||
* Client A authenticates as guest "GuestBob"
|
||||
* Client B authenticates as password user "alice"
|
||||
* Both receive the channel list, A sends a channel message that B receives,
|
||||
* then B sends a private message that A receives.
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
// ── Event tracking ────────────────────────────────────────────────────────────
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
vc_result auth_result{VC_ERR_INTERNAL};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
std::vector<std::string> messages; // copies of received text bodies
|
||||
|
||||
// For diagnostics
|
||||
const char* label{nullptr};
|
||||
std::string last_error;
|
||||
bool disconnected{false};
|
||||
vc_connection_state last_state{VC_STATE_DISCONNECTED};
|
||||
|
||||
// Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the
|
||||
// M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test.
|
||||
vc_client* client{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
s->last_state = ev->connection_state;
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
// No human to ask in a headless test — trust on first connect unconditionally.
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_result = static_cast<vc_result>(ev->result);
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
if (!s->auth_ok) std::fprintf(stderr, "[%s] AUTH FAILED: %s\n",
|
||||
s->label ? s->label : "?", ev->text ? ev->text : "(no msg)");
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_TEXT_MESSAGE:
|
||||
if (ev->text) s->messages.emplace_back(ev->text);
|
||||
break;
|
||||
case VC_EVENT_ERROR:
|
||||
s->last_error = ev->text ? ev->text : "";
|
||||
std::fprintf(stderr, "[%s] ERROR rc=%d: %s\n",
|
||||
s->label ? s->label : "?", ev->result, s->last_error.c_str());
|
||||
break;
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
std::fprintf(stderr, "[%s] DISCONNECTED rc=%d: %s\n",
|
||||
s->label ? s->label : "?", ev->result, ev->text ? ev->text : "");
|
||||
break;
|
||||
case VC_EVENT_CONNECTION_STATE:
|
||||
std::fprintf(stderr, "[%s] STATE -> %d\n",
|
||||
s->label ? s->label : "?", (int)ev->connection_state);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template<typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() +
|
||||
std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
// ── Test harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
int main() {
|
||||
// ── Isolated temp dir for this test run ──────────────────────────────────
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
// ── Pre-provision alice's account before the server starts ───────────────
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err)) {
|
||||
std::printf("FAIL: db.open: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
auto acc = db.create_account("alice", "test-pass-alice", false, err);
|
||||
if (!acc) {
|
||||
std::printf("FAIL: create_account: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Start server on an OS-assigned port ───────────────────────────────────
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0; // OS picks port
|
||||
cfg.server_name = "VoiceCat-IntTest";
|
||||
cfg.allow_guests = true;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10),
|
||||
[&] { return ready; });
|
||||
if (!ok) {
|
||||
std::printf("FAIL: server did not become ready within 10s\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t port = bound_port.load();
|
||||
std::printf("m1_integration: server ready on :%u\n", port);
|
||||
|
||||
// ── Client A: guest "GuestBob" ────────────────────────────────────────────
|
||||
EventStore evA;
|
||||
evA.label = "clientA";
|
||||
vc_callbacks cbA{on_event, nullptr, &evA};
|
||||
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
|
||||
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
||||
CHECK(clientA != nullptr);
|
||||
evA.client = clientA;
|
||||
|
||||
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(clientA, "GuestBob") == VC_OK);
|
||||
|
||||
// Guest auth is fast; 8s is generous.
|
||||
bool authA_ok = wait_for(evA, [](EventStore& s){ return s.auth_ok; }, 8000);
|
||||
CHECK(authA_ok);
|
||||
if (!authA_ok) std::printf(" (client A auth timed out)\n");
|
||||
|
||||
bool clA_ok = wait_for(evA, [](EventStore& s){ return s.channel_list_received; }, 3000);
|
||||
CHECK(clA_ok);
|
||||
|
||||
// ── Client B: password user "alice" ───────────────────────────────────────
|
||||
EventStore evB;
|
||||
evB.label = "clientB";
|
||||
vc_callbacks cbB{on_event, nullptr, &evB};
|
||||
vc_config cfgB{"test-clientB", "0.1", VC_LOG_OFF};
|
||||
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
||||
CHECK(clientB != nullptr);
|
||||
evB.client = clientB;
|
||||
|
||||
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(clientB, "alice", "test-pass-alice") == VC_OK);
|
||||
|
||||
// Argon2id (INTERACTIVE) takes ~0.5-2 s; allow 20 s.
|
||||
bool authB_ok = wait_for(evB, [](EventStore& s){ return s.auth_ok; }, 20000);
|
||||
CHECK(authB_ok);
|
||||
if (!authB_ok) std::printf(" (client B auth timed out — Argon2id may be slow)\n");
|
||||
|
||||
bool clB_ok = wait_for(evB, [](EventStore& s){ return s.channel_list_received; }, 3000);
|
||||
CHECK(clB_ok);
|
||||
|
||||
// ── A sends channel text → B receives it ─────────────────────────────────
|
||||
const char* chan_msg = "Hello from GuestBob!";
|
||||
CHECK(vc_send_text(clientA, VC_TEXT_CHANNEL, 1, chan_msg) == VC_OK);
|
||||
|
||||
bool B_got_chan = wait_for(evB, [&](EventStore& s) {
|
||||
for (auto& m : s.messages)
|
||||
if (m == chan_msg) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(B_got_chan);
|
||||
|
||||
// ── B sends private text to A ─────────────────────────────────────────────
|
||||
uint32_t a_uid = 0;
|
||||
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
||||
|
||||
const char* priv_msg = "Private reply from alice!";
|
||||
CHECK(vc_send_text(clientB, VC_TEXT_PRIVATE, a_uid, priv_msg) == VC_OK);
|
||||
|
||||
bool A_got_priv = wait_for(evA, [&](EventStore& s) {
|
||||
for (auto& m : s.messages)
|
||||
if (m == priv_msg) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(A_got_priv);
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
vc_disconnect(clientA);
|
||||
vc_disconnect(clientB);
|
||||
vc_client_destroy(clientA);
|
||||
vc_client_destroy(clientB);
|
||||
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("m1_integration: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("m1_integration: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
/*
|
||||
* test_m2_voice — M2 exit criterion.
|
||||
*
|
||||
* Two headless clients authenticate over TLS, perform UDP binding, announce a voice stream,
|
||||
* then Client A sends synthetic Opus frames (440 Hz sine PCM) encrypted with ChaCha20-Poly1305.
|
||||
* The server SFU relay re-encrypts and forwards them to Client B.
|
||||
*
|
||||
* Assertions:
|
||||
* 1. UDP binding and StreamAnnounce succeed for both clients.
|
||||
* 2. Client B receives >= 25 out of 50 sent frames (50% floor accounts for startup latency).
|
||||
* 3. Client B successfully decrypts all frames it receives (auth tag valid).
|
||||
* 4. After simulated loss (every 5th frame skipped), packets_lost counter increases.
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
# define WIN32_LEAN_AND_MEAN
|
||||
# include <winsock2.h>
|
||||
# include <ws2tcpip.h>
|
||||
using sock_t = SOCKET;
|
||||
static constexpr sock_t kBadSock = INVALID_SOCKET;
|
||||
static void close_sock(sock_t s) { closesocket(s); }
|
||||
static int sock_error() { return WSAGetLastError(); }
|
||||
#else
|
||||
# include <arpa/inet.h>
|
||||
# include <netdb.h>
|
||||
# include <netinet/in.h>
|
||||
# include <sys/socket.h>
|
||||
# include <unistd.h>
|
||||
using sock_t = int;
|
||||
static constexpr sock_t kBadSock = -1;
|
||||
static void close_sock(sock_t s) { ::close(s); }
|
||||
static int sock_error() { return errno; }
|
||||
#endif
|
||||
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include "protocol/envelope.h"
|
||||
#include "protocol/protocol.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
#include "codec/opus_codec.h"
|
||||
|
||||
using namespace voicecat;
|
||||
using namespace voicecat::net;
|
||||
using namespace voicecat::crypto;
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
}} while (0)
|
||||
|
||||
// Blocking socket helpers
|
||||
static bool tcp_send_all(TlsContext& tls, const uint8_t* data, size_t len) {
|
||||
size_t off = 0;
|
||||
while (off < len) {
|
||||
int n = tls.write(data + off, len - off);
|
||||
if (n <= 0) return false;
|
||||
off += static_cast<size_t>(n);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool tcp_send_envelope(TlsContext& tls, const v1::Envelope& env) {
|
||||
std::vector<uint8_t> frame;
|
||||
protocol::encode_envelope(env, frame);
|
||||
return tcp_send_all(tls, frame.data(), frame.size());
|
||||
}
|
||||
|
||||
// Read one Envelope from TLS (blocking, with 5s timeout between reads).
|
||||
static bool tcp_recv_envelope(TlsContext& tls, protocol::FrameCodec& codec, v1::Envelope& out) {
|
||||
uint8_t buf[16384];
|
||||
for (int i = 0; i < 100; ++i) { // 100 * 50ms = 5s
|
||||
int n = tls.read(buf, sizeof(buf));
|
||||
if (TlsContext::is_timeout_error(n)) continue;
|
||||
if (n <= 0) return false;
|
||||
std::vector<std::vector<uint8_t>> frames;
|
||||
if (!codec.feed(buf, static_cast<size_t>(n), frames)) return false;
|
||||
for (auto& f : frames) {
|
||||
if (protocol::decode_envelope(f, out)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── UDP raw socket (BSD sockets, not UdpMediaChannel) ─────────────────────────
|
||||
// We use the raw BSD API here so we can set a receive timeout easily.
|
||||
|
||||
static sock_t udp_bind_os(uint16_t& out_port) {
|
||||
#ifdef _WIN32
|
||||
WSADATA wsa; WSAStartup(MAKEWORD(2,2), &wsa);
|
||||
#endif
|
||||
sock_t s = ::socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (s == kBadSock) return kBadSock;
|
||||
sockaddr_in addr{};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
addr.sin_port = 0;
|
||||
if (::bind(s, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
|
||||
close_sock(s); return kBadSock;
|
||||
}
|
||||
socklen_t len = sizeof(addr);
|
||||
::getsockname(s, reinterpret_cast<sockaddr*>(&addr), &len);
|
||||
out_port = ntohs(addr.sin_port);
|
||||
// 500ms receive timeout
|
||||
#ifdef _WIN32
|
||||
DWORD tv = 500;
|
||||
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&tv), sizeof(tv));
|
||||
#else
|
||||
struct timeval tv{0, 500000};
|
||||
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
#endif
|
||||
return s;
|
||||
}
|
||||
|
||||
static bool udp_send(sock_t s, const uint8_t* data, size_t len, uint16_t dst_port) {
|
||||
sockaddr_in dst{};
|
||||
dst.sin_family = AF_INET;
|
||||
dst.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
dst.sin_port = htons(dst_port);
|
||||
int n = static_cast<int>(::sendto(s, reinterpret_cast<const char*>(data),
|
||||
static_cast<int>(len), 0,
|
||||
reinterpret_cast<sockaddr*>(&dst), sizeof(dst)));
|
||||
return n == static_cast<int>(len);
|
||||
}
|
||||
|
||||
static int udp_recv(sock_t s, uint8_t* buf, size_t cap) {
|
||||
return static_cast<int>(::recv(s, reinterpret_cast<char*>(buf),
|
||||
static_cast<int>(cap), 0));
|
||||
}
|
||||
|
||||
// ── TestClient ────────────────────────────────────────────────────────────────
|
||||
|
||||
struct TestClient {
|
||||
std::string label;
|
||||
sock_t tcp_sock = kBadSock;
|
||||
sock_t udp_sock = kBadSock;
|
||||
uint16_t udp_local_port = 0;
|
||||
|
||||
std::unique_ptr<TlsContext> tls;
|
||||
std::unique_ptr<SodiumMediaCrypto> send_crypto;
|
||||
std::unique_ptr<SodiumMediaCrypto> recv_crypto;
|
||||
protocol::FrameCodec codec;
|
||||
|
||||
uint8_t udp_token[16]{};
|
||||
uint16_t server_udp_port = 0;
|
||||
uint32_t assigned_ssrc = 0;
|
||||
bool stream_ok = false;
|
||||
|
||||
std::atomic<int> frames_received{0};
|
||||
std::atomic<int> decrypt_errors{0};
|
||||
|
||||
// Decoded payloads for verification
|
||||
std::mutex payloads_mu;
|
||||
std::vector<std::vector<uint8_t>> payloads;
|
||||
|
||||
bool connect(const char* host, uint16_t port) {
|
||||
#ifdef _WIN32
|
||||
WSADATA wsa; WSAStartup(MAKEWORD(2,2), &wsa);
|
||||
#endif
|
||||
struct addrinfo hints{};
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
struct addrinfo* res = nullptr;
|
||||
if (getaddrinfo(host, std::to_string(port).c_str(), &hints, &res) != 0 || !res)
|
||||
return false;
|
||||
tcp_sock = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
|
||||
if (tcp_sock == kBadSock) { freeaddrinfo(res); return false; }
|
||||
if (::connect(tcp_sock, res->ai_addr, static_cast<int>(res->ai_addrlen)) != 0) {
|
||||
close_sock(tcp_sock); tcp_sock = kBadSock; freeaddrinfo(res); return false;
|
||||
}
|
||||
freeaddrinfo(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tls_handshake() {
|
||||
tls = std::make_unique<TlsContext>(TlsContext::Role::Client, nullptr);
|
||||
tls->set_read_timeout(50); // 50ms read timeout for drain-between-reads
|
||||
std::string err;
|
||||
if (!tls->handshake(static_cast<int>(tcp_sock), err)) {
|
||||
std::printf("[%s] TLS failed: %s\n", label.c_str(), err.c_str());
|
||||
return false;
|
||||
}
|
||||
// Derive media keying material immediately after handshake.
|
||||
send_crypto = SodiumMediaCrypto::derive_send(*tls, true); // client sends on ctx=0x00
|
||||
recv_crypto = SodiumMediaCrypto::derive_recv(*tls, true); // client recvs on ctx=0x01
|
||||
return send_crypto && recv_crypto;
|
||||
}
|
||||
|
||||
bool do_hello_and_auth(const char* nickname) {
|
||||
// ClientHello
|
||||
{
|
||||
v1::Envelope env;
|
||||
env.set_request_id(1);
|
||||
env.mutable_client_hello()->set_proto_version(2);
|
||||
env.mutable_client_hello()->set_client_name(label);
|
||||
if (!tcp_send_envelope(*tls, env)) return false;
|
||||
}
|
||||
|
||||
// ServerHello
|
||||
{
|
||||
v1::Envelope env;
|
||||
if (!tcp_recv_envelope(*tls, codec, env)) return false;
|
||||
if (!env.has_server_hello()) return false;
|
||||
server_udp_port = static_cast<uint16_t>(env.server_hello().udp_port());
|
||||
}
|
||||
|
||||
// AuthRequest (guest)
|
||||
{
|
||||
v1::Envelope env;
|
||||
env.set_request_id(2);
|
||||
env.mutable_auth_request()->mutable_guest()->set_nickname(nickname);
|
||||
if (!tcp_send_envelope(*tls, env)) return false;
|
||||
}
|
||||
|
||||
// Wait for AuthResult (may be preceded by other messages)
|
||||
for (int attempt = 0; attempt < 20; ++attempt) {
|
||||
v1::Envelope env;
|
||||
if (!tcp_recv_envelope(*tls, codec, env)) return false;
|
||||
if (env.has_auth_result()) {
|
||||
if (!env.auth_result().ok()) return false;
|
||||
const auto& tok = env.auth_result().udp_token();
|
||||
if (tok.size() == 16) std::memcpy(udp_token, tok.data(), 16);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drain any pending TCP messages (e.g., ServerState snapshot).
|
||||
void drain_incoming(int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() +
|
||||
std::chrono::milliseconds(timeout_ms);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
v1::Envelope env;
|
||||
if (tcp_recv_envelope(*tls, codec, env)) {
|
||||
if (env.has_udp_binding()) { /* handled */ }
|
||||
if (env.has_stream_announce_result()) { /* handled below */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool do_udp_binding() {
|
||||
// Bind local UDP socket.
|
||||
udp_sock = udp_bind_os(udp_local_port);
|
||||
if (udp_sock == kBadSock) return false;
|
||||
|
||||
// Send TCP UdpBinding (declares intent to bind).
|
||||
{
|
||||
v1::Envelope env;
|
||||
env.set_request_id(3);
|
||||
env.mutable_udp_binding()->set_udp_token(udp_token, 16);
|
||||
if (!tcp_send_envelope(*tls, env)) return false;
|
||||
}
|
||||
|
||||
// Wait for TCP UdpBinding ack.
|
||||
bool got_ack = false;
|
||||
for (int i = 0; i < 20 && !got_ack; ++i) {
|
||||
v1::Envelope env;
|
||||
if (!tcp_recv_envelope(*tls, codec, env)) break;
|
||||
if (env.has_udp_binding() && env.udp_binding().ack()) got_ack = true;
|
||||
}
|
||||
if (!got_ack) return false;
|
||||
|
||||
// Send UDP binding packet (type=kFrameUdpBinding + token).
|
||||
auto pkt = make_udp_binding_packet(udp_token, 16);
|
||||
if (!udp_send(udp_sock, pkt.data(), pkt.size(), server_udp_port)) return false;
|
||||
|
||||
// Brief pause to let the server process the UDP binding.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool do_subscribe_voice() {
|
||||
{
|
||||
v1::Envelope env;
|
||||
env.set_request_id(3);
|
||||
env.mutable_subscribe_voice();
|
||||
if (!tcp_send_envelope(*tls, env)) return false;
|
||||
}
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
v1::Envelope env;
|
||||
if (!tcp_recv_envelope(*tls, codec, env)) return false;
|
||||
if (env.has_voice_subscription_result()) {
|
||||
return env.voice_subscription_result().subscribed();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool do_stream_announce() {
|
||||
// Send StreamAnnounce.
|
||||
{
|
||||
v1::Envelope env;
|
||||
env.set_request_id(4);
|
||||
auto* ann = env.mutable_stream_announce();
|
||||
ann->set_kind(v1::STREAM_MIC);
|
||||
auto* audio = ann->mutable_requested_audio();
|
||||
audio->set_sample_rate(48000);
|
||||
audio->set_bitrate_bps(24000);
|
||||
audio->set_frame_ms(20);
|
||||
audio->set_fec(true);
|
||||
if (!tcp_send_envelope(*tls, env)) return false;
|
||||
}
|
||||
|
||||
// Wait for StreamAnnounceResult.
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
v1::Envelope env;
|
||||
if (!tcp_recv_envelope(*tls, codec, env)) return false;
|
||||
if (env.has_stream_announce_result()) {
|
||||
const auto& r = env.stream_announce_result();
|
||||
if (r.ok()) {
|
||||
assigned_ssrc = r.ssrc();
|
||||
stream_ok = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (udp_sock != kBadSock) { close_sock(udp_sock); udp_sock = kBadSock; }
|
||||
if (tcp_sock != kBadSock) { close_sock(tcp_sock); tcp_sock = kBadSock; }
|
||||
tls.reset();
|
||||
}
|
||||
};
|
||||
|
||||
// ── generate 440 Hz mono PCM (48 kHz, 20 ms = 960 samples) ───────────────────
|
||||
static std::vector<int16_t> make_sine_frame(int frame_idx, int frame_samples = 960) {
|
||||
std::vector<int16_t> pcm(frame_samples);
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(frame_idx * frame_samples + i) / 48000.0f;
|
||||
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
int main() {
|
||||
// ── Temp data dir ────────────────────────────────────────────────────────
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_m2_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
|
||||
// ── Start server ─────────────────────────────────────────────────────────
|
||||
std::atomic<uint16_t> tcp_port{0}, udp_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
int ready_flags = 0;
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = tmp.string();
|
||||
cfg.bind_port = 0;
|
||||
cfg.media_port = 0;
|
||||
cfg.allow_guests = true;
|
||||
cfg.server_name = "VoiceCat-M2Test";
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
tcp_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready_flags |= 1; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
cfg.on_media_ready = [&](uint16_t p) {
|
||||
udp_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready_flags |= 2; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(15),
|
||||
[&] { return (ready_flags & 3) == 3; });
|
||||
if (!ok) {
|
||||
std::printf("FAIL: server did not become ready in time\n");
|
||||
server.stop(); server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
std::printf("m2_voice: server TCP:%u UDP:%u\n", tcp_port.load(), udp_port.load());
|
||||
|
||||
// ── Client A ─────────────────────────────────────────────────────────────
|
||||
TestClient A;
|
||||
A.label = "ClientA";
|
||||
CHECK(A.connect("127.0.0.1", tcp_port.load()));
|
||||
CHECK(A.tls_handshake());
|
||||
CHECK(A.do_hello_and_auth("SenderBob"));
|
||||
CHECK(A.server_udp_port == udp_port.load());
|
||||
CHECK(A.do_udp_binding());
|
||||
CHECK(A.do_subscribe_voice());
|
||||
CHECK(A.do_stream_announce());
|
||||
CHECK(A.stream_ok);
|
||||
std::printf("m2_voice: A ssrc=%u local_udp=%u\n", A.assigned_ssrc, A.udp_local_port);
|
||||
|
||||
// ── Client B ─────────────────────────────────────────────────────────────
|
||||
TestClient B;
|
||||
B.label = "ClientB";
|
||||
CHECK(B.connect("127.0.0.1", tcp_port.load()));
|
||||
CHECK(B.tls_handshake());
|
||||
CHECK(B.do_hello_and_auth("ReceiverAlice"));
|
||||
CHECK(B.server_udp_port == udp_port.load());
|
||||
CHECK(B.do_udp_binding());
|
||||
CHECK(B.do_subscribe_voice());
|
||||
// B doesn't need to announce a stream to receive relayed frames
|
||||
|
||||
if (g_failures > 0) {
|
||||
std::printf("m2_voice: setup failed — aborting\n");
|
||||
A.close(); B.close();
|
||||
server.stop(); server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── A sends 50 Opus frames ────────────────────────────────────────────────
|
||||
constexpr int kFramesToSend = 50;
|
||||
constexpr int kFrameSamples = 960; // 20 ms @48 kHz
|
||||
|
||||
voicecat::codec::OpusEncoder enc;
|
||||
{
|
||||
voicecat::codec::OpusParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.frame_ms = 20;
|
||||
p.fec = true;
|
||||
CHECK(enc.init(p));
|
||||
}
|
||||
|
||||
std::vector<uint8_t> aead_buf(4096); // scratch
|
||||
|
||||
for (int i = 0; i < kFramesToSend; ++i) {
|
||||
auto pcm = make_sine_frame(i, kFrameSamples);
|
||||
|
||||
uint8_t opus_buf[1000];
|
||||
int opus_len = enc.encode(pcm.data(), kFrameSamples, opus_buf, sizeof(opus_buf));
|
||||
if (opus_len <= 0) continue;
|
||||
const uint8_t* payload = opus_buf;
|
||||
size_t payload_len = static_cast<size_t>(opus_len);
|
||||
|
||||
// Build the voice frame header (AAD).
|
||||
VoiceFrame hdr;
|
||||
hdr.ssrc = A.assigned_ssrc;
|
||||
hdr.seq = static_cast<uint64_t>(i);
|
||||
hdr.timestamp = static_cast<uint32_t>(i * kFrameSamples);
|
||||
uint8_t header_bytes[kVoiceHeaderSize];
|
||||
serialize_header(hdr, header_bytes);
|
||||
|
||||
// AEAD-seal the payload.
|
||||
size_t sealed_cap = payload_len + crypto_aead_chacha20poly1305_ietf_ABYTES;
|
||||
if (aead_buf.size() < kVoiceHeaderSize + sealed_cap) aead_buf.resize(kVoiceHeaderSize + sealed_cap);
|
||||
std::memcpy(aead_buf.data(), header_bytes, kVoiceHeaderSize);
|
||||
long sealed = A.send_crypto->seal(payload, payload_len,
|
||||
header_bytes, kVoiceHeaderSize,
|
||||
aead_buf.data() + kVoiceHeaderSize, sealed_cap);
|
||||
if (sealed < 0) continue;
|
||||
|
||||
udp_send(A.udp_sock, aead_buf.data(),
|
||||
kVoiceHeaderSize + static_cast<size_t>(sealed),
|
||||
udp_port.load());
|
||||
|
||||
// 20 ms inter-frame spacing to let the server process
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
|
||||
enc.destroy();
|
||||
|
||||
// ── B collects received frames (2s window after last send) ────────────────
|
||||
int recv_count = 0, decrypt_ok = 0;
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
uint8_t udp_in[2048];
|
||||
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
int n = udp_recv(B.udp_sock, udp_in, sizeof(udp_in));
|
||||
if (n < static_cast<int>(kVoiceHeaderSize)) continue;
|
||||
|
||||
// Decrypt
|
||||
VoiceFrame hdr_in{};
|
||||
parse_header(udp_in, static_cast<size_t>(n), hdr_in);
|
||||
|
||||
size_t sealed_len = static_cast<size_t>(n) - kVoiceHeaderSize;
|
||||
std::vector<uint8_t> plain(sealed_len);
|
||||
long plain_len = B.recv_crypto->open(
|
||||
udp_in + kVoiceHeaderSize, sealed_len,
|
||||
udp_in, kVoiceHeaderSize,
|
||||
plain.data(), plain.size());
|
||||
recv_count++;
|
||||
if (plain_len >= 0) decrypt_ok++;
|
||||
}
|
||||
|
||||
std::printf("m2_voice: A sent %d frames, B received %d, decrypted OK: %d\n",
|
||||
kFramesToSend, recv_count, decrypt_ok);
|
||||
|
||||
CHECK(recv_count >= 25); // ≥ 50% of sent frames arrived
|
||||
CHECK(decrypt_ok == recv_count); // all received frames decrypt correctly
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
A.close();
|
||||
B.close();
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("m2_voice: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("m2_voice: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
/*
|
||||
* test_m3_multistream — M3 exit criterion, exercised through the real C ABI.
|
||||
*
|
||||
* Mirrors test_voice_client_abi.cpp's approach (real vc_client instances, not raw sockets —
|
||||
* the M2 lesson is that ABI-level coverage is what actually proves the client library works).
|
||||
* Covers the whole M3 milestone in one flow:
|
||||
*
|
||||
* 1. A starts two concurrent local streams (MIC + SCREEN_AUDIO) -- distinct stream ids,
|
||||
* both visible to B as separate STREAM_STARTED events for the same user.
|
||||
* 2. Synthetic PCM (vc_test_inject_capture) flows into both of A's streams without crashing
|
||||
* and without disrupting the control/voice plane; B observes a VC_EVENT_TALK_STATE
|
||||
* talking=true edge for A's MIC stream while both are still in the same channel (voice
|
||||
* only relays within a channel, so this must happen before step 4 moves A elsewhere).
|
||||
* 3. B independently gains/mutes/NS-toggles A's two streams (vc_set_remote_stream) --
|
||||
* one call doesn't clobber the other's routing; a bogus stream_id is rejected.
|
||||
* 4. Per-channel Opus configurability: A joins "Music Room" (channel 2, stereo/128kbps/
|
||||
* OPUS_AUDIO/no DTX) before announcing there, while B stays in "Lobby" (channel 1,
|
||||
* mono/24kbps/OPUS_VOIP/DTX) -- vc_get_stream_audio_config shows the two streams'
|
||||
* effective config differs exactly as the server enforces it.
|
||||
*/
|
||||
#include <cstdio>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
// ── Event tracking ────────────────────────────────────────────────────────────
|
||||
|
||||
struct StreamEvent {
|
||||
bool started; // true = STARTED, false = STOPPED
|
||||
uint32_t user_id;
|
||||
uint32_t stream_id;
|
||||
};
|
||||
|
||||
struct TalkEvent {
|
||||
uint32_t user_id;
|
||||
uint32_t stream_id;
|
||||
bool talking;
|
||||
};
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
std::vector<StreamEvent> stream_events;
|
||||
std::vector<TalkEvent> talk_events;
|
||||
bool voice_subscribed{false};
|
||||
bool disconnected{false};
|
||||
|
||||
const char* label{nullptr};
|
||||
|
||||
// Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the
|
||||
// M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test.
|
||||
vc_client* client{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
// No human to ask in a headless test — trust on first connect unconditionally.
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
if (!s->auth_ok) std::fprintf(stderr, "[%s] AUTH FAILED: %s\n",
|
||||
s->label ? s->label : "?", ev->text ? ev->text : "(no msg)");
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_VOICE_STATE:
|
||||
s->voice_subscribed = (ev->u32a == 1);
|
||||
break;
|
||||
case VC_EVENT_STREAM_STARTED:
|
||||
s->stream_events.push_back({true, ev->user_id, ev->stream_id});
|
||||
break;
|
||||
case VC_EVENT_STREAM_STOPPED:
|
||||
s->stream_events.push_back({false, ev->user_id, ev->stream_id});
|
||||
break;
|
||||
case VC_EVENT_TALK_STATE:
|
||||
s->talk_events.push_back({ev->user_id, ev->stream_id, ev->u32a != 0});
|
||||
break;
|
||||
case VC_EVENT_ERROR:
|
||||
std::fprintf(stderr, "[%s] ERROR rc=%d: %s\n",
|
||||
s->label ? s->label : "?", ev->result, ev->text ? ev->text : "");
|
||||
break;
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template <typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static std::vector<int16_t> make_sine_frame(int frame_idx, float freq_hz,
|
||||
int frame_samples = 960) {
|
||||
std::vector<int16_t> pcm(frame_samples);
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(frame_idx * frame_samples + i) / 48000.0f;
|
||||
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * freq_hz * t) * 16000.0f);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
// ── Test harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_m3_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.media_port = 0;
|
||||
cfg.server_name = "VoiceCat-M3Test";
|
||||
cfg.allow_guests = true;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
|
||||
if (!ok) {
|
||||
std::printf("FAIL: server did not become ready within 10s\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t port = bound_port.load();
|
||||
std::printf("m3_multistream: server ready on :%u\n", port);
|
||||
|
||||
// ── Client A: guest "M3-A" ────────────────────────────────────────────────
|
||||
EventStore evA;
|
||||
evA.label = "clientA";
|
||||
vc_callbacks cbA{on_event, nullptr, &evA};
|
||||
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
|
||||
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
||||
CHECK(clientA != nullptr);
|
||||
evA.client = clientA;
|
||||
|
||||
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(clientA, "M3-A") == VC_OK);
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
|
||||
CHECK(vc_join_voice(clientA) == VC_OK);
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000));
|
||||
|
||||
// ── Client B: guest "M3-B" ────────────────────────────────────────────────
|
||||
EventStore evB;
|
||||
evB.label = "clientB";
|
||||
vc_callbacks cbB{on_event, nullptr, &evB};
|
||||
vc_config cfgB{"test-clientB", "0.1", VC_LOG_OFF};
|
||||
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
||||
CHECK(clientB != nullptr);
|
||||
evB.client = clientB;
|
||||
|
||||
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(clientB, "M3-B") == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
|
||||
CHECK(vc_join_voice(clientB) == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000));
|
||||
|
||||
uint32_t a_uid = 0;
|
||||
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
||||
|
||||
// Both guests land in channel 1 (Lobby) automatically; give the async UDP binding
|
||||
// handshake a moment to complete on both clients before announcing streams.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
|
||||
// ── 1. A starts MIC + SCREEN_AUDIO concurrently ──────────────────────────
|
||||
vc_stream_desc mic_desc{};
|
||||
mic_desc.kind = VC_STREAM_MIC;
|
||||
mic_desc.label = "mic";
|
||||
uint32_t mic_sid = 0;
|
||||
CHECK(vc_stream_start(clientA, &mic_desc, &mic_sid) == VC_OK);
|
||||
|
||||
vc_stream_desc screen_desc{};
|
||||
screen_desc.kind = VC_STREAM_SCREEN_AUDIO;
|
||||
screen_desc.label = "desktop audio";
|
||||
uint32_t screen_sid = 0;
|
||||
CHECK(vc_stream_start(clientA, &screen_desc, &screen_sid) == VC_OK);
|
||||
|
||||
CHECK(mic_sid != 0 && screen_sid != 0 && mic_sid != screen_sid);
|
||||
|
||||
// B observes two distinct STREAM_STARTED events for user A.
|
||||
bool b_saw_both = wait_for(evB, [&](EventStore& s) {
|
||||
bool saw_mic = false, saw_screen = false;
|
||||
for (auto& e : s.stream_events) {
|
||||
if (!e.started || e.user_id != a_uid) continue;
|
||||
if (e.stream_id == mic_sid) saw_mic = true;
|
||||
if (e.stream_id == screen_sid) saw_screen = true;
|
||||
}
|
||||
return saw_mic && saw_screen;
|
||||
}, 5000);
|
||||
CHECK(b_saw_both);
|
||||
|
||||
// Also wait for A's own view of both streams (vc_test_inject_capture requires the
|
||||
// LocalStream to be active, which flips on A's io_thread_ independently of -- and not
|
||||
// necessarily before -- the broadcast B observes above).
|
||||
bool a_self_saw_both = wait_for(evA, [&](EventStore& s) {
|
||||
bool saw_mic = false, saw_screen = false;
|
||||
for (auto& e : s.stream_events) {
|
||||
if (!e.started || e.user_id != a_uid) continue;
|
||||
if (e.stream_id == mic_sid) saw_mic = true;
|
||||
if (e.stream_id == screen_sid) saw_screen = true;
|
||||
}
|
||||
return saw_mic && saw_screen;
|
||||
}, 5000);
|
||||
CHECK(a_self_saw_both);
|
||||
|
||||
// ── 2. Inject synthetic PCM into both of A's local streams ──────────────
|
||||
for (int i = 0; i < 25; ++i) {
|
||||
auto mic_pcm = make_sine_frame(i, 440.0f);
|
||||
auto screen_pcm = make_sine_frame(i, 880.0f);
|
||||
CHECK(vc_test_inject_capture(clientA, mic_sid, mic_pcm.data(), mic_pcm.size()) == VC_OK);
|
||||
CHECK(vc_test_inject_capture(clientA, screen_sid, screen_pcm.data(), screen_pcm.size()) == VC_OK);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
|
||||
// No disconnects/errors should have resulted from the dual-stream PCM flow.
|
||||
{ std::lock_guard lk(evA.mu); CHECK(!evA.disconnected); }
|
||||
{ std::lock_guard lk(evB.mu); CHECK(!evB.disconnected); }
|
||||
|
||||
// ── 5. Talk indicators ────────────────────────────────────────────────────
|
||||
// While A and B are still both in Lobby (voice actually relays between them here --
|
||||
// the SFU forwards within a channel, so this must happen before A moves to Music Room
|
||||
// in step 4 below), confirm B observed a talking=true edge for A's MIC stream.
|
||||
bool b_saw_talking = wait_for(evB, [&](EventStore& s) {
|
||||
for (auto& e : s.talk_events)
|
||||
if (e.user_id == a_uid && e.stream_id == mic_sid && e.talking) return true;
|
||||
return false;
|
||||
}, 3000);
|
||||
CHECK(b_saw_talking);
|
||||
|
||||
// ── 3. B independently controls gain/mute/NS on each of A's streams ─────
|
||||
CHECK(vc_set_remote_stream(clientB, a_uid, mic_sid, 1.0f, 0, 0) == VC_OK);
|
||||
CHECK(vc_set_remote_stream(clientB, a_uid, screen_sid, 0.3f, 1, 1) == VC_OK);
|
||||
CHECK(vc_set_remote_stream(clientB, a_uid, 0xDEADBEEF, 1.0f, 0, 0) == VC_ERR_INVALID_ARG);
|
||||
|
||||
// Toggle NS on/off a few times -- plumbing should never fault or disrupt the stream.
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
CHECK(vc_set_remote_stream(clientB, a_uid, mic_sid, 1.0f, 0, 1) == VC_OK);
|
||||
CHECK(vc_set_remote_stream(clientB, a_uid, mic_sid, 1.0f, 0, 0) == VC_OK);
|
||||
}
|
||||
{ std::lock_guard lk(evB.mu); CHECK(!evB.disconnected); }
|
||||
|
||||
// ── 3b. Read back what B just set (vc_get_remote_stream round-trips the recv state) ─
|
||||
{
|
||||
vc_remote_stream_state st{};
|
||||
// mic_sid: last write above was (1.0, mute=0, nr=0)
|
||||
CHECK(vc_get_remote_stream(clientB, a_uid, mic_sid, &st) == VC_OK);
|
||||
CHECK(fabsf(st.gain - 1.0f) < 1e-5f);
|
||||
CHECK(st.muted == 0);
|
||||
CHECK(st.noise_reduction == 0);
|
||||
|
||||
// screen_sid: set to (0.3, mute=1, nr=1) at line ~282
|
||||
CHECK(vc_get_remote_stream(clientB, a_uid, screen_sid, &st) == VC_OK);
|
||||
CHECK(fabsf(st.gain - 0.3f) < 1e-5f);
|
||||
CHECK(st.muted == 1);
|
||||
CHECK(st.noise_reduction == 1);
|
||||
|
||||
// Unknown stream_id on a known user -> INVALID_ARG.
|
||||
CHECK(vc_get_remote_stream(clientB, a_uid, 0xDEADBEEF, &st) == VC_ERR_INVALID_ARG);
|
||||
// Null out -> INVALID_ARG.
|
||||
CHECK(vc_get_remote_stream(clientB, a_uid, mic_sid, nullptr) == VC_ERR_INVALID_ARG);
|
||||
}
|
||||
|
||||
|
||||
// ── 4. Per-channel Opus configurability ──────────────────────────────────
|
||||
// A moves to "Music Room" (channel 2: stereo/128kbps/OPUS_AUDIO/no DTX) and announces a
|
||||
// fresh MIC stream there; B stays in "Lobby" (channel 1: mono/24kbps/OPUS_VOIP/DTX) with
|
||||
// its own MIC stream. Their effective_audio should differ exactly as configured server-side.
|
||||
CHECK(vc_stream_stop(clientA, mic_sid) == VC_OK);
|
||||
CHECK(vc_join_channel(clientA, 2, nullptr) == VC_OK);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
|
||||
vc_stream_desc music_mic_desc{};
|
||||
music_mic_desc.kind = VC_STREAM_MIC;
|
||||
music_mic_desc.label = "music-mic";
|
||||
uint32_t a_music_mic_sid = 0;
|
||||
CHECK(vc_stream_start(clientA, &music_mic_desc, &a_music_mic_sid) == VC_OK);
|
||||
CHECK(wait_for(evA, [&](EventStore& s) {
|
||||
for (auto& e : s.stream_events)
|
||||
if (e.started && e.user_id == a_uid && e.stream_id == a_music_mic_sid) return true;
|
||||
return false;
|
||||
}, 5000));
|
||||
|
||||
vc_stream_desc b_mic_desc{};
|
||||
b_mic_desc.kind = VC_STREAM_MIC;
|
||||
b_mic_desc.label = "lobby-mic";
|
||||
uint32_t b_mic_sid = 0;
|
||||
CHECK(vc_stream_start(clientB, &b_mic_desc, &b_mic_sid) == VC_OK);
|
||||
CHECK(wait_for(evB, [&](EventStore& s) {
|
||||
uint32_t self = s.self_user_id;
|
||||
for (auto& e : s.stream_events)
|
||||
if (e.started && e.user_id == self && e.stream_id == b_mic_sid) return true;
|
||||
return false;
|
||||
}, 5000));
|
||||
|
||||
vc_audio_config a_cfg{};
|
||||
vc_audio_config b_cfg{};
|
||||
CHECK(vc_get_stream_audio_config(clientA, a_uid, a_music_mic_sid, &a_cfg) == VC_OK);
|
||||
uint32_t b_uid = 0;
|
||||
{ std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; }
|
||||
CHECK(vc_get_stream_audio_config(clientB, b_uid, b_mic_sid, &b_cfg) == VC_OK);
|
||||
|
||||
// Music Room: stereo, 128kbps, OPUS_AUDIO, DTX off. Lobby: mono, 24kbps, OPUS_VOIP, DTX on.
|
||||
CHECK(a_cfg.mode == 1 /* stereo */);
|
||||
CHECK(b_cfg.mode == 0 /* mono */);
|
||||
CHECK(a_cfg.bitrate_bps == 128000);
|
||||
CHECK(b_cfg.bitrate_bps == 24000);
|
||||
CHECK(a_cfg.application == 1 /* OPUS_AUDIO */);
|
||||
CHECK(b_cfg.application == 0 /* OPUS_VOIP */);
|
||||
CHECK(a_cfg.dtx == 0);
|
||||
CHECK(b_cfg.dtx != 0);
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
vc_disconnect(clientA);
|
||||
vc_disconnect(clientB);
|
||||
vc_client_destroy(clientA);
|
||||
vc_client_destroy(clientB);
|
||||
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("m3_multistream: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("m3_multistream: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
/*
|
||||
* test_m5_admin_accounts — Phase 3 of M5: in-app account management over the wire.
|
||||
*
|
||||
* Verifies:
|
||||
* - admin can create an account via vc_create_account.
|
||||
* - the new account can authenticate.
|
||||
* - admin can reset the password.
|
||||
* - the new account can authenticate with the new password.
|
||||
* - admin can list accounts and sees the new entry.
|
||||
* - admin can delete the account.
|
||||
* - the deleted account can no longer authenticate.
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
bool auth_done{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool disconnected{false};
|
||||
|
||||
struct ResultAck {
|
||||
bool ok{false};
|
||||
uint32_t code{0};
|
||||
std::string message;
|
||||
};
|
||||
std::vector<ResultAck> generic_results;
|
||||
|
||||
bool account_list_received{false};
|
||||
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->auth_done = true;
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_GENERIC_RESULT: {
|
||||
EventStore::ResultAck gr;
|
||||
gr.ok = (ev->result == VC_OK);
|
||||
gr.code = ev->u32a;
|
||||
gr.message = ev->text ? ev->text : "";
|
||||
s->generic_results.push_back(std::move(gr));
|
||||
break;
|
||||
}
|
||||
case VC_EVENT_ACCOUNT_LIST:
|
||||
s->account_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template<typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool wait_generic(EventStore& s, int timeout_ms) {
|
||||
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
|
||||
}
|
||||
|
||||
static bool last_generic_ok(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
return !s.generic_results.empty() && s.generic_results.back().ok;
|
||||
}
|
||||
|
||||
static void reset_generic(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
s.generic_results.clear();
|
||||
}
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_m5_acct_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
|
||||
if (!db.create_account("admin", "admin-pass", true, err)) {
|
||||
std::printf("FAIL: create admin: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.server_name = "VoiceCat-M5-Acct";
|
||||
cfg.allow_guests = false;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not become ready\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
|
||||
EventStore evAdmin;
|
||||
evAdmin.label = "admin";
|
||||
vc_callbacks cbA{on_event, nullptr, &evAdmin};
|
||||
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
|
||||
vc_client* admin = vc_client_create(&cfgA, cbA);
|
||||
evAdmin.client = admin;
|
||||
CHECK(admin != nullptr);
|
||||
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
|
||||
// Create charlie.
|
||||
reset_generic(evAdmin);
|
||||
CHECK(vc_create_account(admin, "charlie", "charlie-pass") == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 5000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Charlie logs in.
|
||||
{
|
||||
EventStore evCharlie;
|
||||
evCharlie.label = "charlie";
|
||||
vc_callbacks cb{on_event, nullptr, &evCharlie};
|
||||
vc_config cfgC{"test-charlie", "0.1", VC_LOG_OFF};
|
||||
vc_client* charlie = vc_client_create(&cfgC, cb);
|
||||
evCharlie.client = charlie;
|
||||
CHECK(charlie != nullptr);
|
||||
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(charlie, "charlie", "charlie-pass") == VC_OK);
|
||||
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(evCharlie.auth_ok);
|
||||
vc_disconnect(charlie);
|
||||
vc_client_destroy(charlie);
|
||||
}
|
||||
|
||||
// Reset charlie's password.
|
||||
reset_generic(evAdmin);
|
||||
CHECK(vc_reset_password(admin, "charlie", "new-pass") == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 5000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Charlie logs in with new password.
|
||||
{
|
||||
EventStore evCharlie;
|
||||
evCharlie.label = "charlie2";
|
||||
vc_callbacks cb{on_event, nullptr, &evCharlie};
|
||||
vc_config cfgC{"test-charlie2", "0.1", VC_LOG_OFF};
|
||||
vc_client* charlie = vc_client_create(&cfgC, cb);
|
||||
evCharlie.client = charlie;
|
||||
CHECK(charlie != nullptr);
|
||||
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(charlie, "charlie", "new-pass") == VC_OK);
|
||||
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(evCharlie.auth_ok);
|
||||
vc_disconnect(charlie);
|
||||
vc_client_destroy(charlie);
|
||||
}
|
||||
|
||||
// List accounts.
|
||||
reset_generic(evAdmin);
|
||||
evAdmin.account_list_received = false;
|
||||
CHECK(vc_list_accounts(admin) == VC_OK);
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.account_list_received; }, 3000));
|
||||
|
||||
// Delete charlie.
|
||||
reset_generic(evAdmin);
|
||||
CHECK(vc_delete_account(admin, "charlie") == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 3000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Charlie cannot log in anymore.
|
||||
{
|
||||
EventStore evCharlie;
|
||||
evCharlie.label = "charlie3";
|
||||
vc_callbacks cb{on_event, nullptr, &evCharlie};
|
||||
vc_config cfgC{"test-charlie3", "0.1", VC_LOG_OFF};
|
||||
vc_client* charlie = vc_client_create(&cfgC, cb);
|
||||
evCharlie.client = charlie;
|
||||
CHECK(charlie != nullptr);
|
||||
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(charlie, "charlie", "new-pass") == VC_OK);
|
||||
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_done || s.disconnected; }, 20000));
|
||||
CHECK(!evCharlie.auth_ok);
|
||||
vc_disconnect(charlie);
|
||||
vc_client_destroy(charlie);
|
||||
}
|
||||
|
||||
vc_disconnect(admin);
|
||||
vc_client_destroy(admin);
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("m5_admin_accounts: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("m5_admin_accounts: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
/*
|
||||
* test_m5_channel_crud — Phase 4 of M5: channel CRUD & password enforcement.
|
||||
*
|
||||
* Verifies:
|
||||
* - admin can create a password-protected channel.
|
||||
* - normal user cannot join without the password.
|
||||
* - normal user can join with the password.
|
||||
* - admin can edit the channel name.
|
||||
* - both clients receive VC_EVENT_CHANNEL_LIST after the update.
|
||||
* - admin can delete the channel; remaining users are moved to Lobby.
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
int channel_list_count{0};
|
||||
bool disconnected{false};
|
||||
|
||||
struct ResultAck {
|
||||
bool ok{false};
|
||||
uint32_t code{0};
|
||||
std::string message;
|
||||
};
|
||||
std::vector<ResultAck> generic_results;
|
||||
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
++s->channel_list_count;
|
||||
break;
|
||||
case VC_EVENT_JOIN_RESULT: {
|
||||
EventStore::ResultAck gr;
|
||||
gr.ok = (ev->result == VC_OK);
|
||||
gr.code = ev->channel_id;
|
||||
gr.message = ev->text ? ev->text : "";
|
||||
s->generic_results.push_back(std::move(gr));
|
||||
break;
|
||||
}
|
||||
case VC_EVENT_GENERIC_RESULT: {
|
||||
EventStore::ResultAck gr;
|
||||
gr.ok = (ev->result == VC_OK);
|
||||
gr.code = ev->u32a;
|
||||
gr.message = ev->text ? ev->text : "";
|
||||
s->generic_results.push_back(std::move(gr));
|
||||
break;
|
||||
}
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template<typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool wait_generic(EventStore& s, int timeout_ms) {
|
||||
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
|
||||
}
|
||||
|
||||
static bool last_generic_ok(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
return !s.generic_results.empty() && s.generic_results.back().ok;
|
||||
}
|
||||
|
||||
static void reset_generic(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
s.generic_results.clear();
|
||||
}
|
||||
|
||||
static int channel_count(vc_client* c) {
|
||||
vc_channel_list cl{};
|
||||
if (vc_list_channels(c, &cl) != VC_OK) return -1;
|
||||
int n = static_cast<int>(cl.count);
|
||||
vc_free_channel_list(&cl);
|
||||
return n;
|
||||
}
|
||||
|
||||
static uint32_t find_channel_by_name(vc_client* c, const char* name) {
|
||||
vc_channel_list cl{};
|
||||
if (vc_list_channels(c, &cl) != VC_OK) return 0;
|
||||
uint32_t id = 0;
|
||||
for (size_t i = 0; i < cl.count; ++i) {
|
||||
if (std::strcmp(cl.items[i].name, name) == 0) {
|
||||
id = cl.items[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
vc_free_channel_list(&cl);
|
||||
return id;
|
||||
}
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_m5_ch_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
|
||||
if (!db.create_account("admin", "admin-pass", true, err)) {
|
||||
std::printf("FAIL: create admin: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
if (!db.create_account("bob", "bob-pass", false, err)) {
|
||||
std::printf("FAIL: create bob: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.server_name = "VoiceCat-M5-Ch";
|
||||
cfg.allow_guests = false;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not become ready\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
|
||||
EventStore evAdmin;
|
||||
evAdmin.label = "admin";
|
||||
vc_callbacks cbA{on_event, nullptr, &evAdmin};
|
||||
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
|
||||
vc_client* admin = vc_client_create(&cfgA, cbA);
|
||||
evAdmin.client = admin;
|
||||
CHECK(admin != nullptr);
|
||||
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.channel_list_count > 0; }, 3000));
|
||||
|
||||
EventStore evBob;
|
||||
evBob.label = "bob";
|
||||
vc_callbacks cbB{on_event, nullptr, &evBob};
|
||||
vc_config cfgB{"test-bob", "0.1", VC_LOG_OFF};
|
||||
vc_client* bob = vc_client_create(&cfgB, cbB);
|
||||
evBob.client = bob;
|
||||
CHECK(bob != nullptr);
|
||||
CHECK(vc_connect(bob, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(bob, "bob", "bob-pass") == VC_OK);
|
||||
CHECK(wait_for(evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(evBob, [](EventStore& s){ return s.channel_list_count > 0; }, 3000));
|
||||
|
||||
// Admin creates a password-protected channel.
|
||||
reset_generic(evAdmin);
|
||||
int base_count = channel_count(admin);
|
||||
CHECK(base_count >= 2); // Lobby + Music Room
|
||||
|
||||
vc_channel_info ch{};
|
||||
ch.name = "Private Room";
|
||||
ch.topic = "secret";
|
||||
ch.password_protected = 1;
|
||||
ch.password = "swordfish";
|
||||
ch.max_users = 10;
|
||||
ch.sort_order = 5;
|
||||
CHECK(vc_create_channel(admin, &ch) == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 3000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Wait for the new channel to appear in bob's list.
|
||||
CHECK(wait_for(evBob, [](EventStore& s){ return s.channel_list_count > 1; }, 3000));
|
||||
int new_count = channel_count(bob);
|
||||
CHECK(new_count == base_count + 1);
|
||||
|
||||
uint32_t private_id = find_channel_by_name(admin, "Private Room");
|
||||
CHECK(private_id != 0);
|
||||
|
||||
// Bob tries to join without password — should fail.
|
||||
reset_generic(evBob);
|
||||
CHECK(vc_join_channel(bob, private_id, nullptr) == VC_OK);
|
||||
CHECK(wait_generic(evBob, 3000));
|
||||
CHECK(!last_generic_ok(evBob));
|
||||
|
||||
// Bob joins with password — should succeed.
|
||||
reset_generic(evBob);
|
||||
CHECK(vc_join_channel(bob, private_id, "swordfish") == VC_OK);
|
||||
CHECK(wait_generic(evBob, 3000));
|
||||
CHECK(last_generic_ok(evBob));
|
||||
|
||||
// Admin edits the channel name.
|
||||
reset_generic(evAdmin);
|
||||
int admin_list_count = evAdmin.channel_list_count;
|
||||
vc_channel_info edit{};
|
||||
edit.id = private_id;
|
||||
edit.name = "Renamed Room";
|
||||
edit.topic = "still secret";
|
||||
edit.password_protected = 1;
|
||||
edit.password = "swordfish"; // keep same password
|
||||
edit.sort_order = 5;
|
||||
CHECK(vc_edit_channel(admin, &edit) == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 3000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Both clients should receive a channel-list update.
|
||||
CHECK(wait_for(evAdmin, [admin_list_count](EventStore& s){ return s.channel_list_count > admin_list_count; }, 3000));
|
||||
CHECK(wait_for(evBob, [private_id](EventStore& s){ return find_channel_by_name(s.client, "Renamed Room") == private_id; }, 3000));
|
||||
|
||||
// Admin deletes the channel.
|
||||
reset_generic(evAdmin);
|
||||
CHECK(vc_delete_channel(admin, private_id) == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 3000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Bob should see the channel disappear and be back in Lobby (id=1).
|
||||
CHECK(wait_for(evBob, [private_id](EventStore& s){ return find_channel_by_name(s.client, "Renamed Room") == 0; }, 3000));
|
||||
{
|
||||
vc_user_list ul{};
|
||||
CHECK(vc_list_users(bob, &ul) == VC_OK);
|
||||
bool found_self = false;
|
||||
for (size_t i = 0; i < ul.count; ++i) {
|
||||
if (ul.items[i].id == evBob.self_user_id) {
|
||||
found_self = true;
|
||||
CHECK(ul.items[i].channel_id == 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
CHECK(found_self);
|
||||
vc_free_user_list(&ul);
|
||||
}
|
||||
|
||||
vc_disconnect(admin);
|
||||
vc_disconnect(bob);
|
||||
vc_client_destroy(admin);
|
||||
vc_client_destroy(bob);
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("m5_channel_crud: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("m5_channel_crud: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
/*
|
||||
* test_m5_kick_ban_move_mute — Phase 2 of M5: moderation.
|
||||
*
|
||||
* Verifies:
|
||||
* - admin can kick a user (target receives disconnect).
|
||||
* - admin can ban a password user; re-auth fails.
|
||||
* - admin can move a user to another channel.
|
||||
* - admin can server-mute/deafen a user; the target reflects it locally.
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
bool auth_done{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool disconnected{false};
|
||||
vc_result disconnect_reason{VC_OK};
|
||||
std::string disconnect_text;
|
||||
|
||||
struct ResultAck {
|
||||
bool ok{false};
|
||||
uint32_t code{0};
|
||||
std::string message;
|
||||
};
|
||||
std::vector<ResultAck> generic_results;
|
||||
|
||||
uint32_t last_updated_user{0};
|
||||
uint32_t last_updated_channel{0};
|
||||
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->auth_done = true;
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_GENERIC_RESULT: {
|
||||
EventStore::ResultAck gr;
|
||||
gr.ok = (ev->result == VC_OK);
|
||||
gr.code = ev->u32a;
|
||||
gr.message = ev->text ? ev->text : "";
|
||||
s->generic_results.push_back(std::move(gr));
|
||||
break;
|
||||
}
|
||||
case VC_EVENT_USER_UPDATED:
|
||||
s->last_updated_user = ev->user_id;
|
||||
s->last_updated_channel = ev->channel_id;
|
||||
break;
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
s->disconnect_reason = static_cast<vc_result>(ev->result);
|
||||
s->disconnect_text = ev->text ? ev->text : "";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template<typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool wait_generic(EventStore& s, int timeout_ms) {
|
||||
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
|
||||
}
|
||||
|
||||
static bool last_generic_ok(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
return !s.generic_results.empty() && s.generic_results.back().ok;
|
||||
}
|
||||
|
||||
static void reset_generic(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
s.generic_results.clear();
|
||||
}
|
||||
|
||||
static void reset_updated(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
s.last_updated_user = 0;
|
||||
s.last_updated_channel = 0;
|
||||
}
|
||||
|
||||
static bool wait_updated(EventStore& s, uint32_t user_id, int timeout_ms) {
|
||||
return wait_for(s, [user_id](EventStore& st) {
|
||||
return st.last_updated_user == user_id;
|
||||
}, timeout_ms);
|
||||
}
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_m5_mod_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
|
||||
if (!db.create_account("admin", "admin-pass", true, err)) {
|
||||
std::printf("FAIL: create admin: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
if (!db.create_account("alice", "alice-pass", false, err)) {
|
||||
std::printf("FAIL: create alice: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
if (!db.create_account("bob", "bob-pass", false, err)) {
|
||||
std::printf("FAIL: create bob: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.server_name = "VoiceCat-M5-Mod";
|
||||
cfg.allow_guests = false;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not become ready\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
|
||||
auto make_client = [&](const char* label, const char* user, const char* pass) -> EventStore* {
|
||||
auto* ev = new EventStore();
|
||||
ev->label = label;
|
||||
vc_callbacks cb{on_event, nullptr, ev};
|
||||
vc_config cfgx{label, "0.1", VC_LOG_OFF};
|
||||
ev->client = vc_client_create(&cfgx, cb);
|
||||
if (!ev->client) return nullptr;
|
||||
if (vc_connect(ev->client, "127.0.0.1", port) != VC_OK) return nullptr;
|
||||
if (vc_authenticate_user(ev->client, user, pass) != VC_OK) return nullptr;
|
||||
return ev;
|
||||
};
|
||||
|
||||
EventStore* evAdmin = make_client("admin", "admin", "admin-pass");
|
||||
CHECK(evAdmin != nullptr);
|
||||
CHECK(wait_for(*evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(*evAdmin, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
uint32_t admin_id = evAdmin->self_user_id;
|
||||
CHECK(admin_id != 0);
|
||||
|
||||
EventStore* evAlice = make_client("alice", "alice", "alice-pass");
|
||||
CHECK(evAlice != nullptr);
|
||||
CHECK(wait_for(*evAlice, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(*evAlice, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
uint32_t alice_id = evAlice->self_user_id;
|
||||
CHECK(alice_id != 0);
|
||||
|
||||
EventStore* evBob = make_client("bob", "bob", "bob-pass");
|
||||
CHECK(evBob != nullptr);
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
uint32_t bob_id = evBob->self_user_id;
|
||||
CHECK(bob_id != 0);
|
||||
|
||||
// Kick bob.
|
||||
reset_generic(*evAdmin);
|
||||
CHECK(vc_kick_user(evAdmin->client, bob_id, "bye bob") == VC_OK);
|
||||
CHECK(wait_generic(*evAdmin, 3000));
|
||||
CHECK(last_generic_ok(*evAdmin));
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.disconnected; }, 3000));
|
||||
CHECK(evBob->disconnect_reason == VC_ERR_IO);
|
||||
vc_client_destroy(evBob->client);
|
||||
delete evBob;
|
||||
|
||||
// Reconnect bob.
|
||||
evBob = make_client("bob", "bob", "bob-pass");
|
||||
CHECK(evBob != nullptr);
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
bob_id = evBob->self_user_id;
|
||||
CHECK(bob_id != 0);
|
||||
|
||||
// Ban bob permanently.
|
||||
reset_generic(*evAdmin);
|
||||
CHECK(vc_ban_user(evAdmin->client, bob_id, "spam", 0) == VC_OK);
|
||||
CHECK(wait_generic(*evAdmin, 3000));
|
||||
CHECK(last_generic_ok(*evAdmin));
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.disconnected; }, 3000));
|
||||
vc_client_destroy(evBob->client);
|
||||
delete evBob;
|
||||
|
||||
// Bob tries to reconnect — should fail auth due to username ban.
|
||||
{
|
||||
EventStore evBanned;
|
||||
evBanned.label = "banned-bob";
|
||||
vc_callbacks cb{on_event, nullptr, &evBanned};
|
||||
vc_config cfgb{"banned-bob", "0.1", VC_LOG_OFF};
|
||||
vc_client* banned_bob = vc_client_create(&cfgb, cb);
|
||||
evBanned.client = banned_bob;
|
||||
CHECK(banned_bob != nullptr);
|
||||
CHECK(vc_connect(banned_bob, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(banned_bob, "bob", "bob-pass") == VC_OK);
|
||||
CHECK(wait_for(evBanned, [](EventStore& s){ return s.auth_done || s.disconnected; }, 20000));
|
||||
CHECK(!evBanned.auth_ok); // banned
|
||||
vc_disconnect(banned_bob);
|
||||
vc_client_destroy(banned_bob);
|
||||
}
|
||||
|
||||
// Move alice to Music Room (channel id 2).
|
||||
reset_generic(*evAdmin);
|
||||
reset_updated(*evAlice);
|
||||
CHECK(vc_move_user(evAdmin->client, alice_id, 2) == VC_OK);
|
||||
CHECK(wait_generic(*evAdmin, 3000));
|
||||
CHECK(last_generic_ok(*evAdmin));
|
||||
CHECK(wait_updated(*evAlice, alice_id, 3000));
|
||||
{
|
||||
vc_channel_list cl{};
|
||||
CHECK(vc_list_channels(evAlice->client, &cl) == VC_OK);
|
||||
CHECK(cl.count >= 2);
|
||||
vc_free_channel_list(&cl);
|
||||
vc_user_list ul{};
|
||||
CHECK(vc_list_users(evAlice->client, &ul) == VC_OK);
|
||||
bool found_alice = false;
|
||||
for (size_t i = 0; i < ul.count; ++i) {
|
||||
if (ul.items[i].id == alice_id) {
|
||||
found_alice = true;
|
||||
CHECK(ul.items[i].channel_id == 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
CHECK(found_alice);
|
||||
vc_free_user_list(&ul);
|
||||
}
|
||||
|
||||
// Server-mute alice.
|
||||
reset_generic(*evAdmin);
|
||||
reset_updated(*evAlice);
|
||||
CHECK(vc_set_server_mute(evAdmin->client, alice_id, true, false) == VC_OK);
|
||||
CHECK(wait_generic(*evAdmin, 3000));
|
||||
CHECK(last_generic_ok(*evAdmin));
|
||||
CHECK(wait_updated(*evAlice, alice_id, 3000));
|
||||
|
||||
// Cleanup.
|
||||
vc_disconnect(evAdmin->client);
|
||||
vc_disconnect(evAlice->client);
|
||||
vc_client_destroy(evAdmin->client);
|
||||
vc_client_destroy(evAlice->client);
|
||||
delete evAdmin;
|
||||
delete evAlice;
|
||||
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("m5_kick_ban_move_mute: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("m5_kick_ban_move_mute: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
/*
|
||||
* test_m5_permissions — Phase 1 of M5: server-side permission enforcement.
|
||||
*
|
||||
* Verifies:
|
||||
* - admin gets is_admin/can_create_temp_channel/etc.
|
||||
* - normal password user gets no permissions.
|
||||
* - admin can create a channel.
|
||||
* - normal user cannot create a channel.
|
||||
* - admin can grant can_create_temp_channel via vc_set_permission.
|
||||
* - granted normal user can now create a channel.
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool disconnected{false};
|
||||
|
||||
// VC_EVENT_GENERIC_RESULT tracking
|
||||
struct ResultAck {
|
||||
bool ok{false};
|
||||
uint32_t code{0};
|
||||
std::string message;
|
||||
};
|
||||
std::vector<ResultAck> generic_results;
|
||||
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_GENERIC_RESULT: {
|
||||
EventStore::ResultAck gr;
|
||||
gr.ok = (ev->result == VC_OK);
|
||||
gr.code = ev->u32a;
|
||||
gr.message = ev->text ? ev->text : "";
|
||||
s->generic_results.push_back(std::move(gr));
|
||||
break;
|
||||
}
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template<typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool wait_generic(EventStore& s, int timeout_ms) {
|
||||
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
|
||||
}
|
||||
|
||||
static bool last_generic_ok(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
return !s.generic_results.empty() && s.generic_results.back().ok;
|
||||
}
|
||||
|
||||
static void reset_generic(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
s.generic_results.clear();
|
||||
}
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_m5_perm_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
// Pre-provision admin and normal user.
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
|
||||
if (!db.create_account("admin", "admin-pass", true, err)) {
|
||||
std::printf("FAIL: create admin: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
if (!db.create_account("bob", "bob-pass", false, err)) {
|
||||
std::printf("FAIL: create bob: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.server_name = "VoiceCat-M5-Perm";
|
||||
cfg.allow_guests = false;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not become ready\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
|
||||
// Admin client.
|
||||
EventStore evA;
|
||||
evA.label = "admin";
|
||||
vc_callbacks cbA{on_event, nullptr, &evA};
|
||||
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
|
||||
vc_client* admin = vc_client_create(&cfgA, cbA);
|
||||
evA.client = admin;
|
||||
CHECK(admin != nullptr);
|
||||
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
|
||||
CHECK(wait_for(evA, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(evA, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
uint32_t admin_id = evA.self_user_id;
|
||||
CHECK(admin_id != 0);
|
||||
|
||||
// Normal client.
|
||||
EventStore evB;
|
||||
evB.label = "bob";
|
||||
vc_callbacks cbB{on_event, nullptr, &evB};
|
||||
vc_config cfgB{"test-bob", "0.1", VC_LOG_OFF};
|
||||
vc_client* bob = vc_client_create(&cfgB, cbB);
|
||||
evB.client = bob;
|
||||
CHECK(bob != nullptr);
|
||||
CHECK(vc_connect(bob, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(bob, "bob", "bob-pass") == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(evB, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
uint32_t bob_id = evB.self_user_id;
|
||||
CHECK(bob_id != 0);
|
||||
|
||||
// Verify permissions reported by the core.
|
||||
vc_permissions admin_perms{};
|
||||
CHECK(vc_get_permissions(admin, &admin_perms) == VC_OK);
|
||||
CHECK(admin_perms.is_admin != 0);
|
||||
CHECK(admin_perms.can_create_temp_channel != 0);
|
||||
|
||||
vc_permissions bob_perms{};
|
||||
CHECK(vc_get_permissions(bob, &bob_perms) == VC_OK);
|
||||
CHECK(bob_perms.is_admin == 0);
|
||||
CHECK(bob_perms.can_create_temp_channel == 0);
|
||||
|
||||
// Admin creates a channel.
|
||||
vc_channel_info ch{};
|
||||
ch.name = "Admin Channel";
|
||||
ch.topic = "created by admin";
|
||||
ch.max_users = 0;
|
||||
ch.sort_order = 10;
|
||||
CHECK(vc_create_channel(admin, &ch) == VC_OK);
|
||||
CHECK(wait_generic(evA, 3000));
|
||||
CHECK(last_generic_ok(evA));
|
||||
|
||||
// Normal user tries to create a channel — should be denied.
|
||||
reset_generic(evB);
|
||||
vc_channel_info ch2{};
|
||||
ch2.name = "Bob Channel";
|
||||
ch2.topic = "created by bob";
|
||||
ch2.sort_order = 11;
|
||||
CHECK(vc_create_channel(bob, &ch2) == VC_OK);
|
||||
CHECK(wait_generic(evB, 3000));
|
||||
CHECK(!last_generic_ok(evB));
|
||||
|
||||
// Admin grants can_create_temp_channel to bob.
|
||||
reset_generic(evA);
|
||||
vc_permissions grant{};
|
||||
grant.can_create_temp_channel = 1;
|
||||
CHECK(vc_set_permission(admin, bob_id, &grant) == VC_OK);
|
||||
CHECK(wait_generic(evA, 3000));
|
||||
CHECK(last_generic_ok(evA));
|
||||
|
||||
// Bob creates a channel — should succeed now.
|
||||
reset_generic(evB);
|
||||
CHECK(vc_create_channel(bob, &ch2) == VC_OK);
|
||||
CHECK(wait_generic(evB, 3000));
|
||||
CHECK(last_generic_ok(evB));
|
||||
|
||||
// Cleanup.
|
||||
vc_disconnect(admin);
|
||||
vc_disconnect(bob);
|
||||
vc_client_destroy(admin);
|
||||
vc_client_destroy(bob);
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("m5_permissions: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("m5_permissions: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
/*
|
||||
* test_media_aead — ChaCha20-Poly1305 AEAD seal/open, anti-replay, tamper detection.
|
||||
*
|
||||
* Uses a synthetic 32-byte key directly (no TLS context needed for unit tests).
|
||||
*/
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
|
||||
using namespace voicecat::crypto;
|
||||
using namespace voicecat::net;
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
}} while (0)
|
||||
|
||||
// Build a synthetic voice-frame-header AAD.
|
||||
static std::vector<uint8_t> make_aad(uint64_t seq) {
|
||||
VoiceFrame f;
|
||||
f.ssrc = 0xCAFEBABE;
|
||||
f.seq = seq;
|
||||
std::vector<uint8_t> aad(kVoiceHeaderSize);
|
||||
serialize_header(f, aad.data());
|
||||
return aad;
|
||||
}
|
||||
|
||||
static void test_seal_open_round_trip() {
|
||||
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
|
||||
crypto_generichash(key, sizeof(key),
|
||||
reinterpret_cast<const uint8_t*>("test-key"), 8, nullptr, 0);
|
||||
|
||||
SodiumMediaCrypto sender(key);
|
||||
SodiumMediaCrypto receiver(key);
|
||||
|
||||
// Copy the receiver state so it starts with the same key but its own counter.
|
||||
|
||||
std::vector<uint8_t> plain(100, 0xAB);
|
||||
auto aad = make_aad(0);
|
||||
|
||||
// Seal
|
||||
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
|
||||
long sealed_len = sender.seal(plain.data(), plain.size(),
|
||||
aad.data(), aad.size(),
|
||||
cipher.data(), cipher.size());
|
||||
CHECK(sealed_len == static_cast<long>(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES));
|
||||
|
||||
// Open
|
||||
std::vector<uint8_t> recovered(plain.size());
|
||||
long plain_len = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
|
||||
aad.data(), aad.size(),
|
||||
recovered.data(), recovered.size());
|
||||
CHECK(plain_len == static_cast<long>(plain.size()));
|
||||
CHECK(std::memcmp(plain.data(), recovered.data(), plain.size()) == 0);
|
||||
}
|
||||
|
||||
static void test_anti_replay() {
|
||||
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
|
||||
crypto_generichash(key, sizeof(key),
|
||||
reinterpret_cast<const uint8_t*>("replay-key"), 10, nullptr, 0);
|
||||
|
||||
SodiumMediaCrypto sender(key);
|
||||
SodiumMediaCrypto receiver(key);
|
||||
|
||||
std::vector<uint8_t> plain(50, 0x55);
|
||||
auto aad = make_aad(0);
|
||||
|
||||
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
|
||||
long sealed_len = sender.seal(plain.data(), plain.size(),
|
||||
aad.data(), aad.size(),
|
||||
cipher.data(), cipher.size());
|
||||
CHECK(sealed_len > 0);
|
||||
|
||||
std::vector<uint8_t> recovered(plain.size());
|
||||
|
||||
// First open succeeds.
|
||||
long r1 = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
|
||||
aad.data(), aad.size(),
|
||||
recovered.data(), recovered.size());
|
||||
CHECK(r1 == static_cast<long>(plain.size()));
|
||||
|
||||
// Replay of the same ciphertext must fail.
|
||||
long r2 = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
|
||||
aad.data(), aad.size(),
|
||||
recovered.data(), recovered.size());
|
||||
CHECK(r2 < 0);
|
||||
}
|
||||
|
||||
static void test_tamper_detection() {
|
||||
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
|
||||
crypto_generichash(key, sizeof(key),
|
||||
reinterpret_cast<const uint8_t*>("tamper-key"), 10, nullptr, 0);
|
||||
|
||||
SodiumMediaCrypto sender(key);
|
||||
SodiumMediaCrypto receiver(key);
|
||||
|
||||
std::vector<uint8_t> plain(40, 0x77);
|
||||
auto aad = make_aad(0);
|
||||
|
||||
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
|
||||
long sealed_len = sender.seal(plain.data(), plain.size(),
|
||||
aad.data(), aad.size(),
|
||||
cipher.data(), cipher.size());
|
||||
CHECK(sealed_len > 0);
|
||||
|
||||
// Flip a byte in the ciphertext.
|
||||
cipher[5] ^= 0xFF;
|
||||
|
||||
std::vector<uint8_t> recovered(plain.size());
|
||||
long r = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
|
||||
aad.data(), aad.size(),
|
||||
recovered.data(), recovered.size());
|
||||
CHECK(r < 0);
|
||||
}
|
||||
|
||||
static void test_multiple_packets() {
|
||||
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
|
||||
crypto_generichash(key, sizeof(key),
|
||||
reinterpret_cast<const uint8_t*>("multi-key"), 9, nullptr, 0);
|
||||
|
||||
SodiumMediaCrypto sender(key);
|
||||
SodiumMediaCrypto receiver(key);
|
||||
|
||||
std::vector<uint8_t> plain(60, 0x99);
|
||||
|
||||
for (uint64_t seq = 0; seq < 10; ++seq) {
|
||||
auto aad = make_aad(seq);
|
||||
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
|
||||
long sealed_len = sender.seal(plain.data(), plain.size(),
|
||||
aad.data(), aad.size(),
|
||||
cipher.data(), cipher.size());
|
||||
CHECK(sealed_len > 0);
|
||||
|
||||
std::vector<uint8_t> recovered(plain.size());
|
||||
long plain_len = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
|
||||
aad.data(), aad.size(),
|
||||
recovered.data(), recovered.size());
|
||||
CHECK(plain_len == static_cast<long>(plain.size()));
|
||||
CHECK(std::memcmp(plain.data(), recovered.data(), plain.size()) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a voice-frame-header AAD with a given ssrc + seq.
|
||||
static std::vector<uint8_t> make_aad_ssrc(uint32_t ssrc, uint64_t seq) {
|
||||
VoiceFrame f;
|
||||
f.ssrc = ssrc;
|
||||
f.seq = seq;
|
||||
std::vector<uint8_t> aad(kVoiceHeaderSize);
|
||||
serialize_header(f, aad.data());
|
||||
return aad;
|
||||
}
|
||||
|
||||
// Overwrite the 8-byte big-endian seq field (header bytes [8..15]) in an AAD buffer.
|
||||
static void set_aad_seq(std::vector<uint8_t>& aad, uint64_t seq) {
|
||||
for (int i = 0; i < 8; ++i)
|
||||
aad[8 + i] = static_cast<uint8_t>((seq >> (56 - 8 * i)) & 0xFF);
|
||||
}
|
||||
|
||||
// Simulate one server relay hop for a single frame, sender → recipient R.
|
||||
// - sender seals with its send key, setting header seq = its own send counter (client contract).
|
||||
// - server opens with the sender's key, then re-seals with R's send key.
|
||||
// - if rewrite_seq, the re-sealed header's seq is set to R's send counter (the fix); otherwise
|
||||
// the sender's seq is forwarded verbatim (the bug).
|
||||
// Returns true iff R successfully decrypts the relayed frame.
|
||||
static bool relay_one(SodiumMediaCrypto& sender_send, SodiumMediaCrypto& server_recv,
|
||||
SodiumMediaCrypto& r_send, SodiumMediaCrypto& r_recv,
|
||||
uint32_t ssrc, const std::vector<uint8_t>& plain, bool rewrite_seq) {
|
||||
// Client A→server: seq carries the sender's send counter.
|
||||
auto in_aad = make_aad_ssrc(ssrc, sender_send.peek_send_counter());
|
||||
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
|
||||
long sealed = sender_send.seal(plain.data(), plain.size(), in_aad.data(), in_aad.size(),
|
||||
cipher.data(), cipher.size());
|
||||
if (sealed < 0) return false;
|
||||
|
||||
// Server decrypts the inbound frame.
|
||||
std::vector<uint8_t> recovered(plain.size());
|
||||
long opened = server_recv.open(cipher.data(), static_cast<size_t>(sealed),
|
||||
in_aad.data(), in_aad.size(),
|
||||
recovered.data(), recovered.size());
|
||||
if (opened < 0) return false;
|
||||
|
||||
// Server re-seals to R. Header passes through except seq, which (when fixed) is set to R's
|
||||
// own send counter so R's open() reconstructs the matching nonce.
|
||||
std::vector<uint8_t> out_aad = in_aad; // copy header verbatim
|
||||
if (rewrite_seq) set_aad_seq(out_aad, r_send.peek_send_counter());
|
||||
std::vector<uint8_t> relay_cipher(recovered.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
|
||||
long resealed = r_send.seal(recovered.data(), static_cast<size_t>(opened),
|
||||
out_aad.data(), out_aad.size(),
|
||||
relay_cipher.data(), relay_cipher.size());
|
||||
if (resealed < 0) return false;
|
||||
|
||||
// R decrypts the relayed frame.
|
||||
std::vector<uint8_t> r_recovered(recovered.size());
|
||||
long r_opened = r_recv.open(relay_cipher.data(), static_cast<size_t>(resealed),
|
||||
out_aad.data(), out_aad.size(),
|
||||
r_recovered.data(), r_recovered.size());
|
||||
return r_opened == static_cast<long>(plain.size());
|
||||
}
|
||||
|
||||
// Regression test for the relay nonce-desync bug: two senders (A, B) relayed into one recipient
|
||||
// (R) interleaved. The media AEAD nonce is an implicit per-direction counter reconstructed from
|
||||
// the header seq; if the relay forwards the sender's seq verbatim, it no longer matches R's send
|
||||
// counter and frames fail to decrypt. The relay must rewrite seq = R's send counter.
|
||||
static void test_relay_interleaved_reseal() {
|
||||
auto make_key = [](const char* label) {
|
||||
std::array<uint8_t, crypto_aead_chacha20poly1305_ietf_KEYBYTES> k{};
|
||||
crypto_generichash(k.data(), k.size(),
|
||||
reinterpret_cast<const uint8_t*>(label),
|
||||
std::strlen(label), nullptr, 0);
|
||||
return k;
|
||||
};
|
||||
auto kA = make_key("relay-A"); // A↔server direction
|
||||
auto kB = make_key("relay-B"); // B↔server direction
|
||||
auto kR = make_key("relay-R"); // server↔R direction
|
||||
|
||||
std::vector<uint8_t> plain(80, 0x3C);
|
||||
|
||||
// Fixed path: interleaved A/B frames all decrypt at R.
|
||||
{
|
||||
SodiumMediaCrypto a_send(kA.data()), srv_recv_a(kA.data());
|
||||
SodiumMediaCrypto b_send(kB.data()), srv_recv_b(kB.data());
|
||||
SodiumMediaCrypto r_send(kR.data()), r_recv(kR.data());
|
||||
|
||||
bool all_ok = true;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
all_ok &= relay_one(a_send, srv_recv_a, r_send, r_recv, 0x1111, plain, /*rewrite=*/true);
|
||||
all_ok &= relay_one(b_send, srv_recv_b, r_send, r_recv, 0x2222, plain, /*rewrite=*/true);
|
||||
}
|
||||
CHECK(all_ok); // with the fix, every interleaved relayed frame decrypts at R
|
||||
}
|
||||
|
||||
// Control: forwarding seq verbatim (the bug) must drop frames once the counters diverge.
|
||||
{
|
||||
SodiumMediaCrypto a_send(kA.data()), srv_recv_a(kA.data());
|
||||
SodiumMediaCrypto b_send(kB.data()), srv_recv_b(kB.data());
|
||||
SodiumMediaCrypto r_send(kR.data()), r_recv(kR.data());
|
||||
|
||||
int failures = 0;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (!relay_one(a_send, srv_recv_a, r_send, r_recv, 0x1111, plain, /*rewrite=*/false)) ++failures;
|
||||
if (!relay_one(b_send, srv_recv_b, r_send, r_recv, 0x2222, plain, /*rewrite=*/false)) ++failures;
|
||||
}
|
||||
CHECK(failures > 0); // proves the verbatim-seq path is broken (locks in the regression)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the bad-wifi wedge: the anti-replay window must NOT be advanced by a
|
||||
// packet that fails authentication. A single corrupted/forged frame carrying a huge seq
|
||||
// used to shove recv_highest_ far ahead (before the AEAD tag was checked), after which
|
||||
// every legitimate frame was rejected as "too old" — permanent silence. open() now
|
||||
// advances the window only after a successful tag check (RFC 3711 §3.3).
|
||||
static void test_corrupted_seq_does_not_poison_window() {
|
||||
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
|
||||
crypto_generichash(key, sizeof(key),
|
||||
reinterpret_cast<const uint8_t*>("poison-key"), 10, nullptr, 0);
|
||||
SodiumMediaCrypto sender(key);
|
||||
SodiumMediaCrypto receiver(key);
|
||||
|
||||
std::vector<uint8_t> plain(64, 0x5A);
|
||||
std::vector<uint8_t> recovered(plain.size());
|
||||
|
||||
auto seal_at_current = [&](std::vector<uint8_t>& aad_out, std::vector<uint8_t>& cipher_out) {
|
||||
aad_out = make_aad_ssrc(0xABCD, sender.peek_send_counter());
|
||||
cipher_out.assign(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES, 0);
|
||||
long s = sender.seal(plain.data(), plain.size(), aad_out.data(), aad_out.size(),
|
||||
cipher_out.data(), cipher_out.size());
|
||||
CHECK(s > 0);
|
||||
};
|
||||
|
||||
// 1. A normal frame (counter 0) decrypts. recv_highest_ = 0.
|
||||
std::vector<uint8_t> aad0, cipher0;
|
||||
seal_at_current(aad0, cipher0); // sender counter 0 → 1
|
||||
CHECK(receiver.open(cipher0.data(), cipher0.size(), aad0.data(), aad0.size(),
|
||||
recovered.data(), recovered.size()) == static_cast<long>(plain.size()));
|
||||
|
||||
// 2. A frame whose header seq has been corrupted to a huge value: it fails auth
|
||||
// (the AAD no longer matches what was sealed) and must NOT move the window.
|
||||
std::vector<uint8_t> aad1, cipher1;
|
||||
seal_at_current(aad1, cipher1); // sender counter 1 → 2
|
||||
std::vector<uint8_t> forged_aad = aad1;
|
||||
set_aad_seq(forged_aad, 0x0000FFFFFFFFFFFFULL); // bit-flip-style corruption
|
||||
CHECK(receiver.open(cipher1.data(), cipher1.size(), forged_aad.data(), forged_aad.size(),
|
||||
recovered.data(), recovered.size()) < 0);
|
||||
|
||||
// 3. The next legitimate frame (counter 2) must still decrypt. On the old code this
|
||||
// returned "too old" because step 2 had poisoned recv_highest_.
|
||||
std::vector<uint8_t> aad2, cipher2;
|
||||
seal_at_current(aad2, cipher2); // sender counter 2 → 3
|
||||
CHECK(receiver.open(cipher2.data(), cipher2.size(), aad2.data(), aad2.size(),
|
||||
recovered.data(), recovered.size()) == static_cast<long>(plain.size()));
|
||||
}
|
||||
|
||||
// Regression for the 16-bit seq wrap: with a full 64-bit wire counter, sealing/opening
|
||||
// across the old u16 boundary (65,535 → 65,536) must keep decrypting. On the old code the
|
||||
// nonce desynced at the wrap and every frame failed auth permanently.
|
||||
static void test_seq_past_16bit_boundary() {
|
||||
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
|
||||
crypto_generichash(key, sizeof(key),
|
||||
reinterpret_cast<const uint8_t*>("wrap-key"), 8, nullptr, 0);
|
||||
SodiumMediaCrypto sender(key);
|
||||
SodiumMediaCrypto receiver(key);
|
||||
|
||||
std::vector<uint8_t> plain(48, 0x6B);
|
||||
std::vector<uint8_t> recovered(plain.size());
|
||||
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
|
||||
|
||||
bool all_ok = true;
|
||||
for (uint64_t i = 0; i < 70000; ++i) { // crosses 65,536
|
||||
auto aad = make_aad_ssrc(0x1234, sender.peek_send_counter());
|
||||
long s = sender.seal(plain.data(), plain.size(), aad.data(), aad.size(),
|
||||
cipher.data(), cipher.size());
|
||||
if (s < 0) { all_ok = false; break; }
|
||||
long o = receiver.open(cipher.data(), static_cast<size_t>(s), aad.data(), aad.size(),
|
||||
recovered.data(), recovered.size());
|
||||
if (o != static_cast<long>(plain.size())) { all_ok = false; break; }
|
||||
}
|
||||
CHECK(all_ok);
|
||||
}
|
||||
|
||||
int main() {
|
||||
if (sodium_init() < 0) {
|
||||
std::printf("FAIL: sodium_init failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
test_seal_open_round_trip();
|
||||
test_anti_replay();
|
||||
test_tamper_detection();
|
||||
test_multiple_packets();
|
||||
test_relay_interleaved_reseal();
|
||||
test_corrupted_seq_does_not_poison_window();
|
||||
test_seq_past_16bit_boundary();
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("media_aead: all tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("media_aead: %d test(s) FAILED\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
* test_noise_suppression — the RNNoise backend behind ApmProcessor actually denoises.
|
||||
*
|
||||
* This is the behavior exit-criterion for the noise-suppression feature (docs/voice.md §10-11):
|
||||
* a real DSP backend, not the old inert passthrough. ApmProcessor::create() returns the RNNoise
|
||||
* processor when the core is built with VOICECAT_HAS_NS (the dev/release presets). We feed it
|
||||
* mono 48 kHz white noise in 20 ms (960-sample) frames — exercising the internal 480-sample
|
||||
* chunking — and assert the output noise floor collapses while values stay finite/in-range.
|
||||
*
|
||||
* Registered only under VOICECAT_USE_VCPKG_DEPS, where VOICECAT_HAS_NS is defined, so a large
|
||||
* reduction is expected; a passthrough build would (correctly) fail this test.
|
||||
*/
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
#include "audio/apm_processor.h"
|
||||
|
||||
namespace vca = voicecat::audio;
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static constexpr int kFrameSamples = 960; // 20 ms @ 48 kHz (two RNNoise 480-sample frames)
|
||||
|
||||
int main() {
|
||||
auto ns = vca::ApmProcessor::create();
|
||||
CHECK(ns != nullptr);
|
||||
if (!ns) return 1;
|
||||
|
||||
// Deterministic white noise (xorshift) at ~int16/10 amplitude, processed frame by frame.
|
||||
uint32_t rng = 0x12345678u;
|
||||
auto next_noise = [&]() -> int16_t {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
// map to roughly [-3000, 3000]
|
||||
return static_cast<int16_t>((static_cast<int32_t>(rng % 6001)) - 3000);
|
||||
};
|
||||
|
||||
const int kFrames = 200;
|
||||
const int kWarmup = 60; // let RNNoise's recurrent state settle before measuring
|
||||
double in_sumsq = 0.0, out_sumsq = 0.0;
|
||||
long measured = 0;
|
||||
std::vector<int16_t> frame(kFrameSamples);
|
||||
|
||||
for (int f = 0; f < kFrames; ++f) {
|
||||
double frame_in_sq = 0.0;
|
||||
for (int i = 0; i < kFrameSamples; ++i) {
|
||||
frame[i] = next_noise();
|
||||
frame_in_sq += static_cast<double>(frame[i]) * frame[i];
|
||||
}
|
||||
bool gate = ns->process_capture(frame.data(), kFrameSamples, 48000);
|
||||
CHECK(gate); // NS never gates — always passes the frame on
|
||||
|
||||
if (f >= kWarmup) {
|
||||
in_sumsq += frame_in_sq;
|
||||
for (int i = 0; i < kFrameSamples; ++i) {
|
||||
// Output must stay finite and within int16 range (clamping correctness).
|
||||
CHECK(frame[i] >= -32768 && frame[i] <= 32767);
|
||||
out_sumsq += static_cast<double>(frame[i]) * frame[i];
|
||||
}
|
||||
measured += kFrameSamples;
|
||||
}
|
||||
}
|
||||
|
||||
CHECK(measured > 0);
|
||||
double in_rms = std::sqrt(in_sumsq / measured);
|
||||
double out_rms = std::sqrt(out_sumsq / measured);
|
||||
double reduction = (in_rms > 0.0) ? (1.0 - out_rms / in_rms) : 0.0;
|
||||
std::printf("noise-only: in_rms=%.1f out_rms=%.1f reduction=%.1f%%\n", in_rms, out_rms,
|
||||
100.0 * reduction);
|
||||
|
||||
// RNNoise drops pure noise by ~99%; require a large, unambiguous reduction so a passthrough
|
||||
// (no real backend) is caught. The threshold is deliberately conservative vs. the ~99% seen.
|
||||
CHECK(reduction > 0.80);
|
||||
|
||||
// A 48-kHz guard miss must pass audio through untouched (our clock is always 48 kHz, but the
|
||||
// backstop matters): feed a non-48k sample-rate and confirm the buffer is unchanged.
|
||||
std::vector<int16_t> probe(kFrameSamples);
|
||||
for (int i = 0; i < kFrameSamples; ++i) probe[i] = next_noise();
|
||||
std::vector<int16_t> probe_copy = probe;
|
||||
ns->process_capture(probe.data(), kFrameSamples, 16000);
|
||||
CHECK(probe == probe_copy);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("noise_suppression: OK\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("noise_suppression: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/*
|
||||
* test_opus_codec — Opus encode/decode round-trip, PLC, energy check.
|
||||
*
|
||||
* Requires VOICECAT_HAS_OPUS (dev and release presets).
|
||||
*/
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "codec/opus_codec.h"
|
||||
|
||||
namespace vc_codec = voicecat::codec;
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
}} while (0)
|
||||
|
||||
|
||||
static constexpr int kSampleRate = 48000;
|
||||
static constexpr int kFrameMs = 20;
|
||||
static constexpr int kFrameSamples = kSampleRate / 1000 * kFrameMs; // 960
|
||||
|
||||
// Generate one frame of 440 Hz sine wave at 16-bit mono, 48 kHz.
|
||||
static std::vector<int16_t> make_sine_frame(int samples, float freq = 440.0f) {
|
||||
std::vector<int16_t> pcm(samples);
|
||||
for (int i = 0; i < samples; ++i) {
|
||||
float t = static_cast<float>(i) / kSampleRate;
|
||||
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * freq * t) * 16000.0f);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
// Compute RMS energy of a PCM buffer.
|
||||
static double rms(const int16_t* pcm, int n) {
|
||||
double sum = 0.0;
|
||||
for (int i = 0; i < n; ++i) sum += static_cast<double>(pcm[i]) * pcm[i];
|
||||
return std::sqrt(sum / n);
|
||||
}
|
||||
|
||||
static void test_encode_decode_round_trip() {
|
||||
vc_codec::OpusParams p;
|
||||
p.sample_rate = kSampleRate;
|
||||
p.frame_ms = kFrameMs;
|
||||
p.fec = true;
|
||||
|
||||
vc_codec::OpusEncoder enc;
|
||||
vc_codec::OpusDecoder dec;
|
||||
CHECK(enc.init(p));
|
||||
CHECK(dec.init(p));
|
||||
|
||||
auto src = make_sine_frame(kFrameSamples);
|
||||
|
||||
uint8_t encoded[4000];
|
||||
int enc_bytes = enc.encode(src.data(), kFrameSamples, encoded, sizeof(encoded));
|
||||
CHECK(enc_bytes > 0);
|
||||
CHECK(enc_bytes < 1000); // Opus at 24 kbps/20 ms ≈ 60 bytes, well under 1000
|
||||
|
||||
std::vector<int16_t> decoded(kFrameSamples);
|
||||
int dec_samples = dec.decode(encoded, enc_bytes, decoded.data(), kFrameSamples);
|
||||
CHECK(dec_samples == kFrameSamples);
|
||||
|
||||
// Energy check: decoded RMS should be within 3 dB of original (Opus is lossy).
|
||||
double rms_src = rms(src.data(), kFrameSamples);
|
||||
double rms_dec = rms(decoded.data(), dec_samples);
|
||||
CHECK(rms_src > 0.0);
|
||||
CHECK(rms_dec > 0.0);
|
||||
double ratio_db = 20.0 * std::log10(rms_dec / rms_src);
|
||||
std::printf(" opus round-trip: enc_bytes=%d dec_samples=%d rms_ratio_db=%.1f\n",
|
||||
enc_bytes, dec_samples, ratio_db);
|
||||
CHECK(std::abs(ratio_db) < 3.0);
|
||||
|
||||
enc.destroy();
|
||||
dec.destroy();
|
||||
}
|
||||
|
||||
static void test_plc() {
|
||||
vc_codec::OpusParams p;
|
||||
p.sample_rate = kSampleRate;
|
||||
p.frame_ms = kFrameMs;
|
||||
|
||||
vc_codec::OpusDecoder dec;
|
||||
CHECK(dec.init(p));
|
||||
|
||||
// First send a real packet so the decoder has state for PLC.
|
||||
vc_codec::OpusEncoder enc;
|
||||
CHECK(enc.init(p));
|
||||
auto src = make_sine_frame(kFrameSamples);
|
||||
uint8_t encoded[4000];
|
||||
int enc_bytes = enc.encode(src.data(), kFrameSamples, encoded, sizeof(encoded));
|
||||
CHECK(enc_bytes > 0);
|
||||
|
||||
std::vector<int16_t> real_out(kFrameSamples);
|
||||
int r = dec.decode(encoded, enc_bytes, real_out.data(), kFrameSamples);
|
||||
CHECK(r == kFrameSamples);
|
||||
|
||||
// Now simulate packet loss with PLC (nullptr, len=0).
|
||||
std::vector<int16_t> plc_out(kFrameSamples, 0);
|
||||
int plc_samples = dec.decode(nullptr, 0, plc_out.data(), kFrameSamples);
|
||||
CHECK(plc_samples == kFrameSamples);
|
||||
|
||||
// PLC output should not be silent (Opus extrapolates from previous frame).
|
||||
double plc_rms = rms(plc_out.data(), kFrameSamples);
|
||||
std::printf(" plc_rms=%.1f (should be > 0)\n", plc_rms);
|
||||
CHECK(plc_rms > 0.0);
|
||||
|
||||
enc.destroy();
|
||||
dec.destroy();
|
||||
}
|
||||
|
||||
static void test_frame_samples_helper() {
|
||||
vc_codec::OpusParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.frame_ms = 20;
|
||||
CHECK(vc_codec::opus_frame_samples(p) == 960);
|
||||
|
||||
p.frame_ms = 10;
|
||||
CHECK(vc_codec::opus_frame_samples(p) == 480);
|
||||
|
||||
p.frame_ms = 40;
|
||||
CHECK(vc_codec::opus_frame_samples(p) == 1920);
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_frame_samples_helper();
|
||||
test_encode_decode_round_trip();
|
||||
test_plc();
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("opus_codec: all tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("opus_codec: %d test(s) FAILED\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
* test_plc_cap — verifies the PLC cap in AudioEngine::on_playback.
|
||||
*
|
||||
* After ~2s of pure packet-loss concealment (no real packets decoded), the mixer stops
|
||||
* calling opus_decode(nullptr,0,...) and emits digital silence instead. This bounds the
|
||||
* Opus comfort-noise hiss so a stale stream left in the mixer can never hiss forever —
|
||||
* defense-in-depth for the server's UserEvent::LEFT broadcast (the primary fix that
|
||||
* triggers remove_stream on disconnect). Also verifies that a fresh real packet resets
|
||||
* the PLC streak and audio resumes.
|
||||
*
|
||||
* White-box: drives AudioEngine::mix_for_test directly (no audio hardware needed).
|
||||
*/
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
|
||||
|
||||
#include "audio/audio_engine.h"
|
||||
#include "codec/opus_codec.h"
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static double rms(const int16_t* pcm, int n) {
|
||||
double sum = 0.0;
|
||||
for (int i = 0; i < n; ++i) sum += static_cast<double>(pcm[i]) * pcm[i];
|
||||
return std::sqrt(sum / n);
|
||||
}
|
||||
|
||||
static int64_t abs_energy(const int16_t* pcm, int n) {
|
||||
int64_t e = 0;
|
||||
for (int i = 0; i < n; ++i) e += static_cast<int64_t>(std::abs(static_cast<int>(pcm[i])));
|
||||
return e;
|
||||
}
|
||||
|
||||
int main() {
|
||||
voicecat::audio::AudioEngine engine;
|
||||
voicecat::audio::AudioParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.capture_channels = 1;
|
||||
p.playback_channels = 2;
|
||||
p.frame_ms = 20;
|
||||
CHECK(engine.start(p)); // no capture_cb — headless safe (devices may fail to init; ok)
|
||||
|
||||
voicecat::codec::OpusParams op;
|
||||
op.sample_rate = 48000;
|
||||
op.frame_ms = 20;
|
||||
op.stereo = false;
|
||||
int frame_samples = voicecat::codec::opus_frame_samples(op); // 960
|
||||
|
||||
// Encode a loud sine wave to seed the decoder's PLC state.
|
||||
voicecat::codec::OpusEncoder enc;
|
||||
CHECK(enc.init(op));
|
||||
std::vector<int16_t> sine(static_cast<size_t>(frame_samples));
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
sine[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
|
||||
}
|
||||
uint8_t opus_buf[1500];
|
||||
int opus_len = enc.encode(sine.data(), frame_samples, opus_buf, sizeof(opus_buf));
|
||||
CHECK(opus_len > 0);
|
||||
|
||||
const uint32_t ssrc = 1;
|
||||
engine.init_recv_stream(ssrc, op, /*user_id=*/0, /*stream_id=*/0, /*is_voice=*/false);
|
||||
|
||||
// Push one real frame to seed the decoder.
|
||||
voicecat::audio::JitterBuffer::Frame f;
|
||||
f.seq = 0;
|
||||
f.timestamp = 0;
|
||||
f.fec_present = false;
|
||||
f.payload.assign(opus_buf, opus_buf + opus_len);
|
||||
engine.push_recv_frame(ssrc, std::move(f));
|
||||
|
||||
// mix_for_test period — 480 frames @ 48kHz = 10ms (typical WASAPI shared period).
|
||||
const uint32_t pb_frames = 480;
|
||||
const int out_n = static_cast<int>(pb_frames) * 2; // stereo interleaved
|
||||
std::vector<int16_t> out(static_cast<size_t>(out_n), 0);
|
||||
|
||||
// 1) Decode the real frame (first mix call) — seeds PLC state.
|
||||
engine.mix_for_test(out.data(), pb_frames);
|
||||
|
||||
// 2) Drive ~50ms of pure PLC — should produce comfort noise (non-zero).
|
||||
double early_rms = 0.0;
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
engine.mix_for_test(out.data(), pb_frames);
|
||||
early_rms = std::max(early_rms, rms(out.data(), out_n));
|
||||
}
|
||||
CHECK(early_rms > 1.0); // PLC of a loud sine is audible, not digital silence
|
||||
|
||||
// 3) Drive well past the 2s PLC cap (250 callbacks = 2.5s of output).
|
||||
// After the cap, on_playback emits silence (memset 0) instead of PLC noise.
|
||||
for (int i = 0; i < 250; ++i) {
|
||||
engine.mix_for_test(out.data(), pb_frames);
|
||||
}
|
||||
|
||||
// 4) The output must now be digital silence (all zeros), not comfort noise.
|
||||
// Drain a couple more callbacks to flush any ring residue, then assert.
|
||||
int64_t energy = 0;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
engine.mix_for_test(out.data(), pb_frames);
|
||||
energy = std::max(energy, abs_energy(out.data(), out_n));
|
||||
}
|
||||
CHECK(energy == 0); // capped PLC = silence
|
||||
|
||||
// 5) Resumption: push a fresh real frame — PLC streak resets, audio returns. marker=true is
|
||||
// what the real sender stamps on the first frame after a silence (talkspurt restart); it
|
||||
// makes the playout clock reseed to this leading edge immediately (no prebuffer delay).
|
||||
voicecat::audio::JitterBuffer::Frame f2;
|
||||
f2.seq = 1;
|
||||
f2.timestamp = 200000; // far ahead — playout-clock reseeds to it
|
||||
f2.fec_present = false;
|
||||
f2.marker = true;
|
||||
f2.payload.assign(opus_buf, opus_buf + opus_len);
|
||||
engine.push_recv_frame(ssrc, std::move(f2));
|
||||
|
||||
double resume_rms = 0.0;
|
||||
for (int i = 0; i < 5; ++i) { // a few calls to flush silence residue + decode real
|
||||
engine.mix_for_test(out.data(), pb_frames);
|
||||
resume_rms = std::max(resume_rms, rms(out.data(), out_n));
|
||||
}
|
||||
CHECK(resume_rms > 1.0); // real audio is back
|
||||
|
||||
engine.remove_stream(ssrc);
|
||||
engine.stop();
|
||||
enc.destroy();
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("plc_cap: all checks passed (early_rms=%.1f resume_rms=%.1f)\n",
|
||||
early_rms, resume_rms);
|
||||
return 0;
|
||||
}
|
||||
std::printf("plc_cap: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
int main() {
|
||||
std::printf("plc_cap: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,195 +0,0 @@
|
||||
/*
|
||||
* test_reaper_timeout — verifies the server's keepalive reaper (docs/protocol.md §7).
|
||||
*
|
||||
* Simulates a half-open connection: client B authenticates then goes completely silent
|
||||
* (no TCP traffic, no pings — the 15s ping interval far exceeds the test's 2s reaper
|
||||
* timeout). Client A stays alive by sending channel text every 500ms, which bumps its
|
||||
* last_seen on the server. After ~2s the reaper drops B: B's TCP connection is closed
|
||||
* (B sees VC_EVENT_DISCONNECTED) and A receives VC_EVENT_USER_LEFT for B (the Tier 1
|
||||
* LEFT-broadcast fires from close()).
|
||||
*
|
||||
* This catches the "ghost user forever" failure mode for half-open connections (NAT
|
||||
* timeout, wifi loss without RST, laptop sleep) that never produce a TCP EOF.
|
||||
*/
|
||||
#include <cstdio>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool disconnected{false};
|
||||
|
||||
std::vector<uint32_t> left_users;
|
||||
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_USER_LEFT:
|
||||
s->left_users.push_back(ev->user_id);
|
||||
break;
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template <typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_reaper_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.server_name = "VoiceCat-ReaperTest";
|
||||
cfg.allow_guests = true;
|
||||
cfg.reaper_timeout_ms = 2000; // 2s — drop sessions silent for this long
|
||||
cfg.reaper_sweep_ms = 500; // check every 500ms
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not become ready\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
|
||||
auto make_client = [&](const char* label, const char* nick) -> EventStore* {
|
||||
auto* ev = new EventStore();
|
||||
ev->label = label;
|
||||
vc_callbacks cb{on_event, nullptr, ev};
|
||||
vc_config cfgx{label, "0.1", VC_LOG_OFF};
|
||||
ev->client = vc_client_create(&cfgx, cb);
|
||||
if (!ev->client) return nullptr;
|
||||
if (vc_connect(ev->client, "127.0.0.1", port) != VC_OK) return nullptr;
|
||||
if (vc_authenticate_guest(ev->client, nick) != VC_OK) return nullptr;
|
||||
return ev;
|
||||
};
|
||||
|
||||
EventStore* evA = make_client("clientA", "Alpha");
|
||||
CHECK(evA != nullptr);
|
||||
CHECK(wait_for(*evA, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(*evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
uint32_t a_uid = evA->self_user_id;
|
||||
CHECK(a_uid != 0);
|
||||
|
||||
EventStore* evB = make_client("clientB", "Bravo");
|
||||
CHECK(evB != nullptr);
|
||||
CHECK(wait_for(*evB, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(*evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
uint32_t b_uid = evB->self_user_id;
|
||||
CHECK(b_uid != 0);
|
||||
|
||||
// A stays alive by sending channel text every 500ms (bumps A's last_seen on the server).
|
||||
// B goes completely silent — no TCP traffic, no pings (15s ping >> 2s reaper timeout).
|
||||
// Start the keepalive IMMEDIATELY: the reaper timeout is only 2s, so A must begin
|
||||
// sending well before its last_seen goes stale.
|
||||
std::atomic<bool> keepalive_stop{false};
|
||||
std::thread keepalive([&] {
|
||||
while (!keepalive_stop.load()) {
|
||||
vc_send_text(evA->client, VC_TEXT_CHANNEL, 1, ".");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
}
|
||||
});
|
||||
|
||||
// Give B time to go stale and the reaper to fire (2s timeout + 500ms sweep + margin).
|
||||
// A must receive VC_EVENT_USER_LEFT for B.
|
||||
CHECK(wait_for(*evA, [b_uid](EventStore& s) {
|
||||
for (auto u : s.left_users) if (u == b_uid) return true;
|
||||
return false;
|
||||
}, 10000));
|
||||
|
||||
// B's TCP connection is closed by the reaper → B sees VC_EVENT_DISCONNECTED.
|
||||
CHECK(wait_for(*evB, [](EventStore& s) { return s.disconnected; }, 5000));
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────────────
|
||||
keepalive_stop.store(true);
|
||||
keepalive.join();
|
||||
|
||||
vc_disconnect(evA->client);
|
||||
if (!evB->disconnected) vc_disconnect(evB->client);
|
||||
vc_client_destroy(evA->client);
|
||||
vc_client_destroy(evB->client);
|
||||
delete evA;
|
||||
delete evB;
|
||||
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("reaper_timeout: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("reaper_timeout: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/*
|
||||
* test_recv_noise_reduction — receive-side NR actually denoises a STEREO voice stream, and
|
||||
* never touches a screen-audio share. Regression for the bug where a stereo mic (shipped in
|
||||
* f72219d) silently bypassed listener-side NR because the decode loop gated on
|
||||
* `dec_channels == 1` as a proxy for "is voice" (docs/voice.md §10).
|
||||
*
|
||||
* White-box: drive AudioEngine in external-playback mode (the mixer-timer thread decodes+mixes
|
||||
* with no hardware device, same as test_external_playback) and tap the per-stream pcm_sink, which
|
||||
* fires AFTER the receive-side NR pass. We feed deterministic stereo white noise (paced at the
|
||||
* 20 ms engine cadence so the jitter buffer's catch-up never drops the backlog) and compare the
|
||||
* played-out noise floor across three runs:
|
||||
* • voice stream, NR off → baseline (decode only)
|
||||
* • voice stream, NR on → RNNoise folds stereo→mono and collapses the noise floor
|
||||
* • screen-audio (is_voice=false), NR on → NR is skipped; floor ≈ baseline
|
||||
*
|
||||
* Registered only under VOICECAT_USE_VCPKG_DEPS, where VOICECAT_HAS_NS is defined (a passthrough
|
||||
* build would correctly fail the reduction assertion).
|
||||
*/
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
|
||||
|
||||
#include "audio/audio_engine.h"
|
||||
#include "codec/opus_codec.h"
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
namespace vca = voicecat::audio;
|
||||
namespace vcc = voicecat::codec;
|
||||
|
||||
// pcm_sink accumulator (written on the mixer-timer thread, read by main after stop()).
|
||||
struct Sink {
|
||||
std::atomic<int> frames{0};
|
||||
std::atomic<long long> sumsq{0};
|
||||
std::atomic<long long> count{0};
|
||||
std::atomic<uint32_t> last_ch{0};
|
||||
int warmup = 0; // skip RNNoise's recurrent-state settle window
|
||||
};
|
||||
|
||||
static void sink_cb(void* user, uint32_t, uint32_t, const int16_t* pcm, size_t spc, uint32_t ch,
|
||||
uint32_t) {
|
||||
auto* s = static_cast<Sink*>(user);
|
||||
int f = s->frames.fetch_add(1, std::memory_order_relaxed);
|
||||
s->last_ch.store(ch, std::memory_order_relaxed);
|
||||
if (f < s->warmup) return;
|
||||
const size_t total = spc * ch;
|
||||
long long sq = 0;
|
||||
for (size_t i = 0; i < total; ++i) {
|
||||
long long v = pcm[i];
|
||||
sq += v * v;
|
||||
}
|
||||
s->sumsq.fetch_add(sq, std::memory_order_relaxed);
|
||||
s->count.fetch_add(static_cast<long long>(total), std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// Run one playout scenario and return the RMS of the played-out PCM, or -1 if nothing played.
|
||||
static double run_scenario(bool is_voice, bool nr_on,
|
||||
const std::vector<std::vector<uint8_t>>& frames, const vcc::OpusParams& op,
|
||||
int frame_samples) {
|
||||
vca::AudioEngine engine;
|
||||
engine.set_external_playback(true);
|
||||
Sink sink;
|
||||
sink.warmup = 20;
|
||||
engine.set_pcm_sink(&sink_cb, &sink);
|
||||
|
||||
vca::AudioParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.capture_channels = 1;
|
||||
p.playback_channels = 2;
|
||||
p.frame_ms = 20;
|
||||
if (!engine.start(p)) return -1;
|
||||
|
||||
const uint32_t ssrc = 1;
|
||||
engine.init_recv_stream(ssrc, op, /*user_id=*/7, /*stream_id=*/3, is_voice);
|
||||
if (nr_on) engine.set_stream_noise_reduction(ssrc, true);
|
||||
|
||||
uint32_t ts = 0;
|
||||
for (size_t i = 0; i < frames.size(); ++i) {
|
||||
vca::JitterBuffer::Frame f;
|
||||
f.seq = static_cast<uint64_t>(i);
|
||||
f.timestamp = ts;
|
||||
f.fec_present = false;
|
||||
f.payload = frames[i];
|
||||
engine.push_recv_frame(ssrc, std::move(f));
|
||||
ts += static_cast<uint32_t>(frame_samples);
|
||||
// Pace at the engine cadence so production ≈ consumption: the jitter buffer never builds a
|
||||
// backlog big enough to trigger catch-up frame-dropping, so every frame is decoded.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(120)); // drain
|
||||
engine.stop();
|
||||
|
||||
CHECK(sink.last_ch.load(std::memory_order_relaxed) == 2); // stereo throughout
|
||||
long long c = sink.count.load(std::memory_order_relaxed);
|
||||
if (c == 0) return -1;
|
||||
return std::sqrt(static_cast<double>(sink.sumsq.load(std::memory_order_relaxed)) /
|
||||
static_cast<double>(c));
|
||||
}
|
||||
|
||||
int main() {
|
||||
// Stereo Opus params (a stereo mic with send-side NR off arrives exactly like this).
|
||||
vcc::OpusParams op;
|
||||
op.sample_rate = 48000;
|
||||
op.frame_ms = 20;
|
||||
op.stereo = true;
|
||||
int frame_samples = vcc::opus_frame_samples(op); // 960 per channel
|
||||
|
||||
// Encode a run of deterministic interleaved-stereo white noise (L and R independent so the
|
||||
// bitstream is genuinely stereo, not L==R).
|
||||
uint32_t rng = 0xC0FFEEu;
|
||||
auto next_noise = [&]() -> int16_t {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
return static_cast<int16_t>((static_cast<int32_t>(rng % 6001)) - 3000); // ~[-3000,3000]
|
||||
};
|
||||
|
||||
vcc::OpusEncoder enc;
|
||||
CHECK(enc.init(op));
|
||||
const int kFrames = 80; // ~1.6 s; warmup 20 leaves ~60 measured
|
||||
std::vector<std::vector<uint8_t>> frames;
|
||||
frames.reserve(kFrames);
|
||||
std::vector<int16_t> interleaved(static_cast<size_t>(frame_samples) * 2);
|
||||
uint8_t opus_buf[1500];
|
||||
for (int f = 0; f < kFrames; ++f) {
|
||||
for (int i = 0; i < frame_samples * 2; ++i) interleaved[i] = next_noise();
|
||||
int len = enc.encode(interleaved.data(), frame_samples, opus_buf, sizeof(opus_buf));
|
||||
CHECK(len > 0);
|
||||
frames.emplace_back(opus_buf, opus_buf + len);
|
||||
}
|
||||
enc.destroy();
|
||||
|
||||
double voice_off = run_scenario(/*is_voice=*/true, /*nr_on=*/false, frames, op, frame_samples);
|
||||
double voice_on = run_scenario(/*is_voice=*/true, /*nr_on=*/true, frames, op, frame_samples);
|
||||
double screen_on = run_scenario(/*is_voice=*/false, /*nr_on=*/true, frames, op, frame_samples);
|
||||
|
||||
std::printf("recv_nr: voice_off_rms=%.1f voice_on_rms=%.1f screen_on_rms=%.1f\n", voice_off,
|
||||
voice_on, screen_on);
|
||||
|
||||
CHECK(voice_off > 0.0);
|
||||
CHECK(voice_on > 0.0);
|
||||
CHECK(screen_on > 0.0);
|
||||
|
||||
// The fix: enabling NR on the stereo VOICE stream must collapse the noise floor. RNNoise drops
|
||||
// pure noise ~99%; require a clear, unambiguous reduction (the pre-fix bug left it unchanged).
|
||||
CHECK(voice_on < voice_off * 0.6);
|
||||
|
||||
// A screen-audio share is not voice: NR must be a no-op there, so its floor stays ≈ the
|
||||
// undenoised baseline (same decode path, no RNNoise). Loose band absorbs run-to-run timing.
|
||||
CHECK(screen_on > voice_off * 0.7);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("recv_noise_reduction: OK\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("recv_noise_reduction: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
int main() {
|
||||
std::printf("recv_noise_reduction: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* test_smoke — verifies the core links and the C ABI behaves as specified.
|
||||
*
|
||||
* This is intentionally a behavior test, not a "does it compile" check: it asserts the
|
||||
* documented contract (version present, handle lifecycle, invalid-arg guards).
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "voicecat.h"
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
int main() {
|
||||
// Version + result strings are always available.
|
||||
CHECK(vc_version_string() != nullptr);
|
||||
CHECK(std::strlen(vc_version_string()) > 0);
|
||||
CHECK(std::strcmp(vc_result_string(VC_OK), "ok") == 0);
|
||||
// Null-config create is rejected; valid create yields a handle.
|
||||
vc_callbacks cb{};
|
||||
CHECK(vc_client_create(nullptr, cb) == nullptr);
|
||||
|
||||
vc_config cfg{};
|
||||
cfg.client_name = "test";
|
||||
cfg.client_version = "0";
|
||||
cfg.log_level = VC_LOG_OFF;
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
|
||||
// Invalid-arg guards on the ABI.
|
||||
CHECK(vc_connect(nullptr, "h", 1) == VC_ERR_INVALID_ARG);
|
||||
CHECK(vc_connect(c, nullptr, 1) == VC_ERR_INVALID_ARG);
|
||||
CHECK(vc_send_text(c, VC_TEXT_CHANNEL, 0, nullptr) == VC_ERR_INVALID_ARG);
|
||||
|
||||
vc_result rc_connect = vc_connect(c, "127.0.0.1", 8384);
|
||||
CHECK(rc_connect == VC_OK);
|
||||
|
||||
// Auth before connected → NOT_CONNECTED.
|
||||
{
|
||||
vc_config cfg2 = cfg;
|
||||
vc_client* c2 = vc_client_create(&cfg2, cb);
|
||||
vc_result rc_auth = vc_authenticate_guest(c2, "nick");
|
||||
CHECK(rc_auth == VC_ERR_NOT_CONNECTED);
|
||||
vc_client_destroy(c2);
|
||||
}
|
||||
|
||||
// join_channel before connected → NOT_CONNECTED.
|
||||
vc_result rc_join = vc_join_channel(c, 1, nullptr);
|
||||
CHECK(rc_join == VC_ERR_NOT_CONNECTED);
|
||||
|
||||
vc_device_list dl{};
|
||||
vc_result rc_devices = vc_list_devices(c, VC_DEVICE_INPUT, &dl);
|
||||
// Never assert count > 0 — a headless CI agent may report zero audio devices.
|
||||
CHECK(rc_devices == VC_OK);
|
||||
vc_free_device_list(&dl);
|
||||
|
||||
vc_client_destroy(c);
|
||||
vc_client_destroy(nullptr); // must be safe
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("smoke: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("smoke: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* test_tcp_loopback — in-process TCP acceptor + client, sends 10 frames.
|
||||
* Runs only under dev (requires Asio).
|
||||
*/
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "net/transport.h"
|
||||
|
||||
using namespace voicecat::net;
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
int main() {
|
||||
constexpr int kFrameCount = 10;
|
||||
constexpr uint16_t kPort = 19850;
|
||||
|
||||
std::mutex mtx;
|
||||
std::condition_variable cv;
|
||||
std::vector<std::vector<uint8_t>> received;
|
||||
std::atomic<bool> server_connected{false};
|
||||
|
||||
// Server io_context + acceptor.
|
||||
asio::io_context server_io;
|
||||
auto work = asio::make_work_guard(server_io);
|
||||
std::thread server_thread([&] { server_io.run(); });
|
||||
|
||||
TcpAcceptor acceptor(server_io, kPort, [&](asio::ip::tcp::socket sock) {
|
||||
TcpChannelCallbacks cbs;
|
||||
cbs.on_frame = [&](std::vector<uint8_t> frame) {
|
||||
std::lock_guard<std::mutex> lk(mtx);
|
||||
received.push_back(std::move(frame));
|
||||
cv.notify_all();
|
||||
};
|
||||
cbs.on_connected = [&] { server_connected.store(true); };
|
||||
auto conn = std::make_shared<TcpServerConn>(std::move(sock), std::move(cbs));
|
||||
return conn;
|
||||
});
|
||||
acceptor.start();
|
||||
|
||||
// Client.
|
||||
std::atomic<bool> client_connected{false};
|
||||
TcpChannelCallbacks client_cbs;
|
||||
client_cbs.on_connected = [&] { client_connected.store(true); };
|
||||
client_cbs.on_connect_error = [](std::error_code ec) {
|
||||
std::printf("connect error: %s\n", ec.message().c_str());
|
||||
};
|
||||
|
||||
TcpControlChannel client(std::move(client_cbs));
|
||||
client.async_connect("127.0.0.1", kPort);
|
||||
|
||||
// Wait for connection.
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
||||
while (!client_connected.load() &&
|
||||
std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
CHECK(client_connected.load());
|
||||
|
||||
// Send kFrameCount distinct frames.
|
||||
for (int i = 0; i < kFrameCount; ++i) {
|
||||
std::vector<uint8_t> payload = {static_cast<uint8_t>(i), 0xAB, 0xCD};
|
||||
client.send_frame(payload);
|
||||
}
|
||||
|
||||
// Wait for all frames to arrive on the server side.
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mtx);
|
||||
bool ok = cv.wait_for(lk, std::chrono::seconds(5),
|
||||
[&] { return static_cast<int>(received.size()) >= kFrameCount; });
|
||||
CHECK(ok);
|
||||
}
|
||||
CHECK(static_cast<int>(received.size()) == kFrameCount);
|
||||
for (int i = 0; i < kFrameCount && i < static_cast<int>(received.size()); ++i) {
|
||||
CHECK(received[i].size() == 3);
|
||||
if (!received[i].empty()) CHECK(received[i][0] == static_cast<uint8_t>(i));
|
||||
}
|
||||
|
||||
// Clean up.
|
||||
client.close();
|
||||
acceptor.stop();
|
||||
work.reset();
|
||||
server_io.stop();
|
||||
server_thread.join();
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("tcp_loopback: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("tcp_loopback: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
/*
|
||||
* test_tls_loopback — in-process TLS 1.3 server + client over a loopback TCP socket pair.
|
||||
* Validates: cert generation, handshake, ServerIdentity fingerprint, framed message exchange.
|
||||
*/
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
# include <winsock2.h>
|
||||
# include <ws2tcpip.h>
|
||||
# pragma comment(lib, "ws2_32.lib")
|
||||
using sock_t = SOCKET;
|
||||
static constexpr sock_t kBadSock = INVALID_SOCKET;
|
||||
static void close_sock(sock_t s) { closesocket(s); }
|
||||
static int last_err() { return WSAGetLastError(); }
|
||||
#else
|
||||
# include <arpa/inet.h>
|
||||
# include <netinet/in.h>
|
||||
# include <sys/socket.h>
|
||||
# include <unistd.h>
|
||||
using sock_t = int;
|
||||
static constexpr sock_t kBadSock = -1;
|
||||
static void close_sock(sock_t s) { ::close(s); }
|
||||
static int last_err() { return errno; }
|
||||
#endif
|
||||
|
||||
#include "crypto/crypto.h"
|
||||
|
||||
using namespace voicecat::crypto;
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { if (!(cond)) { std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); ++g_failures; } } while (0)
|
||||
|
||||
// Create a blocking loopback TCP socket pair: returns {server_fd, client_fd}
|
||||
static std::pair<sock_t, sock_t> make_socket_pair(uint16_t port) {
|
||||
sock_t listener = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (listener == kBadSock) return {kBadSock, kBadSock};
|
||||
|
||||
int opt = 1;
|
||||
setsockopt(listener, SOL_SOCKET, SO_REUSEADDR,
|
||||
reinterpret_cast<const char*>(&opt), sizeof(opt));
|
||||
|
||||
sockaddr_in addr{};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
addr.sin_port = htons(port);
|
||||
if (::bind(listener, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
|
||||
close_sock(listener); return {kBadSock, kBadSock};
|
||||
}
|
||||
if (::listen(listener, 1) != 0) {
|
||||
close_sock(listener); return {kBadSock, kBadSock};
|
||||
}
|
||||
|
||||
sock_t client = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (client == kBadSock) { close_sock(listener); return {kBadSock, kBadSock}; }
|
||||
if (::connect(client, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
|
||||
close_sock(listener); close_sock(client); return {kBadSock, kBadSock};
|
||||
}
|
||||
|
||||
sockaddr_in peer{};
|
||||
socklen_t plen = sizeof(peer);
|
||||
sock_t server = ::accept(listener, reinterpret_cast<sockaddr*>(&peer), &plen);
|
||||
close_sock(listener);
|
||||
if (server == kBadSock) { close_sock(client); return {kBadSock, kBadSock}; }
|
||||
return {server, client};
|
||||
}
|
||||
|
||||
// Write all bytes to a TLS context.
|
||||
static bool tls_write_all(TlsContext& tls, const uint8_t* data, size_t len) {
|
||||
size_t off = 0;
|
||||
while (off < len) {
|
||||
int n = tls.write(data + off, len - off);
|
||||
if (n <= 0) return false;
|
||||
off += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read exactly len bytes from a TLS context.
|
||||
static bool tls_read_exact(TlsContext& tls, uint8_t* buf, size_t len) {
|
||||
size_t off = 0;
|
||||
while (off < len) {
|
||||
int n = tls.read(buf + off, len - off);
|
||||
if (n <= 0) return false;
|
||||
off += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int main() {
|
||||
#ifdef _WIN32
|
||||
WSADATA wsa{};
|
||||
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
|
||||
std::printf("WSAStartup failed\n");
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Generate server identity + cert
|
||||
ServerIdentity identity = ServerIdentity::generate();
|
||||
ServerCert cert = ServerCert::generate("test-server");
|
||||
|
||||
CHECK(!cert.pem_cert.empty());
|
||||
CHECK(!cert.pem_key.empty());
|
||||
|
||||
auto [server_fd_native, client_fd_native] = make_socket_pair(19851);
|
||||
CHECK(server_fd_native != kBadSock);
|
||||
CHECK(client_fd_native != kBadSock);
|
||||
if (server_fd_native == kBadSock || client_fd_native == kBadSock) {
|
||||
std::printf("tls_loopback: socket pair failed (err=%d)\n", last_err());
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::string server_error, client_error;
|
||||
std::atomic<bool> server_ok{false}, client_ok{false};
|
||||
|
||||
static const char kMsg1[] = "hello from server";
|
||||
static const char kMsg2[] = "hello from client";
|
||||
constexpr size_t kMsg1Len = sizeof(kMsg1) - 1;
|
||||
constexpr size_t kMsg2Len = sizeof(kMsg2) - 1;
|
||||
|
||||
char client_recv[64]{};
|
||||
char server_recv[64]{};
|
||||
|
||||
// Server thread: handshake, send msg1, recv msg2
|
||||
std::thread server_thr([&] {
|
||||
TlsContext tls(TlsContext::Role::Server, &cert);
|
||||
int fd = static_cast<int>(server_fd_native);
|
||||
if (!tls.handshake(fd, server_error)) { close_sock(server_fd_native); return; }
|
||||
server_ok.store(true);
|
||||
tls_write_all(tls, reinterpret_cast<const uint8_t*>(kMsg1), kMsg1Len);
|
||||
tls_read_exact(tls, reinterpret_cast<uint8_t*>(server_recv), kMsg2Len);
|
||||
close_sock(server_fd_native);
|
||||
});
|
||||
|
||||
// Client thread: handshake, recv msg1, send msg2
|
||||
std::thread client_thr([&] {
|
||||
TlsContext tls(TlsContext::Role::Client, nullptr);
|
||||
int fd = static_cast<int>(client_fd_native);
|
||||
if (!tls.handshake(fd, client_error)) { close_sock(client_fd_native); return; }
|
||||
client_ok.store(true);
|
||||
tls_read_exact(tls, reinterpret_cast<uint8_t*>(client_recv), kMsg1Len);
|
||||
tls_write_all(tls, reinterpret_cast<const uint8_t*>(kMsg2), kMsg2Len);
|
||||
close_sock(client_fd_native);
|
||||
});
|
||||
|
||||
server_thr.join();
|
||||
client_thr.join();
|
||||
|
||||
if (!server_error.empty()) std::printf("server TLS error: %s\n", server_error.c_str());
|
||||
if (!client_error.empty()) std::printf("client TLS error: %s\n", client_error.c_str());
|
||||
|
||||
CHECK(server_ok.load());
|
||||
CHECK(client_ok.load());
|
||||
CHECK(std::memcmp(client_recv, kMsg1, kMsg1Len) == 0);
|
||||
CHECK(std::memcmp(server_recv, kMsg2, kMsg2Len) == 0);
|
||||
|
||||
// Verify ServerIdentity round-trip
|
||||
{
|
||||
ServerIdentity id2 = ServerIdentity::generate();
|
||||
CHECK(id2.pk != identity.pk); // different key
|
||||
// Fingerprint is SHA-256 of pk — non-zero
|
||||
bool nonzero = false;
|
||||
for (auto b : id2.fingerprint) if (b) { nonzero = true; break; }
|
||||
CHECK(nonzero);
|
||||
// fingerprint_hex should be 32 colons + 64 hex chars = 95 chars (AA:BB:...)
|
||||
std::string hex = id2.fingerprint_hex();
|
||||
CHECK(hex.size() == 95);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
WSACleanup();
|
||||
#endif
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("tls_loopback: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("tls_loopback: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
/*
|
||||
* test_tofu_flow — M4 TOFU server-identity gate (voicecat.h's VC_EVENT_SERVER_IDENTITY /
|
||||
* vc_confirm_server_identity / vc_get_server_identity_display).
|
||||
*
|
||||
* Needs a real server (in-process, like the other ABI tests) so a real TLS handshake
|
||||
* happens — pinning a fingerprint against a mock would prove nothing.
|
||||
*
|
||||
* 1. First connect to a fresh server blocks (no AUTH_RESULT) until
|
||||
* vc_confirm_server_identity() is called; then it proceeds normally.
|
||||
* 2. Rejecting (accept=0) disconnects with VC_ERR_CRYPTO and does NOT persist a pin — a
|
||||
* second attempt to the same server still reports FIRST_CONNECT.
|
||||
* 3. Reconnecting to a server with the SAME identity (same data_dir, restarted on the
|
||||
* same port) reports MATCHED.
|
||||
* 4. Reconnecting to a server with a DIFFERENT identity on the same host:port (key
|
||||
* rotation / MITM) reports MISMATCH.
|
||||
* 5. vc_confirm_server_identity with nothing pending returns VC_ERR_INVALID_ARG.
|
||||
* 6. vc_get_server_identity_display is empty pre-connect and populated (64 hex chars —
|
||||
* the raw, colon-free encoding of the Ed25519 fingerprint) after ServerHello.
|
||||
*/
|
||||
#include <cstdio>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
|
||||
// ── Event tracking — deliberately does NOT auto-confirm, so the test drives the gate ──────
|
||||
|
||||
struct GatedEventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
bool got_identity{false};
|
||||
vc_tofu_status identity_status{};
|
||||
bool disconnected{false};
|
||||
vc_result disconnect_result{VC_OK};
|
||||
|
||||
vc_client* client{nullptr};
|
||||
};
|
||||
|
||||
static void on_event_gated(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<GatedEventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
s->got_identity = true;
|
||||
s->identity_status = static_cast<vc_tofu_status>(ev->u32a);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
break;
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
s->disconnect_result = static_cast<vc_result>(ev->result);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template <typename Pred>
|
||||
static bool wait_for(GatedEventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
// ── Test harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// ── A small helper to start/stop an in-process server on a chosen (or OS-assigned) port ───
|
||||
|
||||
struct RunningServer {
|
||||
voicecat::server::Config cfg;
|
||||
std::unique_ptr<voicecat::server::Server> server;
|
||||
std::thread server_thread;
|
||||
uint16_t port{0};
|
||||
|
||||
bool start(const std::string& data_dir, uint16_t want_port, const char* name) {
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = want_port;
|
||||
cfg.media_port = 0;
|
||||
cfg.server_name = name;
|
||||
cfg.allow_guests = true;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
server = std::make_unique<voicecat::server::Server>(cfg);
|
||||
server_thread = std::thread([this] { server->run(); });
|
||||
|
||||
std::unique_lock lk(ready_mu);
|
||||
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
|
||||
if (!ok) return false;
|
||||
port = bound_port.load();
|
||||
return true;
|
||||
}
|
||||
|
||||
void stop_and_join() {
|
||||
if (server) server->stop();
|
||||
if (server_thread.joinable()) server_thread.join();
|
||||
}
|
||||
};
|
||||
|
||||
// ── 1. First connect blocks until confirmed ─────────────────────────────────────
|
||||
static void test_first_connect_blocks(uint16_t port, const std::string& tofu_path) {
|
||||
GatedEventStore ev;
|
||||
vc_callbacks cb{on_event_gated, nullptr, &ev};
|
||||
vc_config cfg{"test-gate", "0.1", VC_LOG_OFF};
|
||||
cfg.tofu_store_path = tofu_path.c_str();
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
ev.client = c;
|
||||
|
||||
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(c, "Gated") == VC_OK);
|
||||
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
|
||||
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); }
|
||||
|
||||
// No confirmation yet — auth must NOT complete within a short window.
|
||||
CHECK(!wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 1000));
|
||||
|
||||
CHECK(vc_confirm_server_identity(c, 1) == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000));
|
||||
|
||||
vc_disconnect(c);
|
||||
vc_client_destroy(c);
|
||||
std::printf("test_first_connect_blocks: ok\n");
|
||||
}
|
||||
|
||||
// ── 2. Reject doesn't persist a pin ──────────────────────────────────────────────
|
||||
static void test_reject_does_not_persist(uint16_t port, const std::string& tofu_path) {
|
||||
{
|
||||
GatedEventStore ev;
|
||||
vc_callbacks cb{on_event_gated, nullptr, &ev};
|
||||
vc_config cfg{"test-reject", "0.1", VC_LOG_OFF};
|
||||
cfg.tofu_store_path = tofu_path.c_str();
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
ev.client = c;
|
||||
|
||||
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
|
||||
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); }
|
||||
|
||||
CHECK(vc_confirm_server_identity(c, 0) == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.disconnected; }, 5000));
|
||||
{ std::lock_guard lk(ev.mu); CHECK(ev.disconnect_result == VC_ERR_CRYPTO); }
|
||||
|
||||
vc_client_destroy(c);
|
||||
}
|
||||
// Second attempt to the SAME server, SAME pin file: still FIRST_CONNECT — the rejected
|
||||
// pin from above must not have been written to disk.
|
||||
{
|
||||
GatedEventStore ev;
|
||||
vc_callbacks cb{on_event_gated, nullptr, &ev};
|
||||
vc_config cfg{"test-reject2", "0.1", VC_LOG_OFF};
|
||||
cfg.tofu_store_path = tofu_path.c_str();
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
ev.client = c;
|
||||
|
||||
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
|
||||
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); }
|
||||
|
||||
vc_disconnect(c);
|
||||
vc_client_destroy(c);
|
||||
}
|
||||
std::printf("test_reject_does_not_persist: ok\n");
|
||||
}
|
||||
|
||||
// ── 3/4. MATCHED on identity reuse, MISMATCH on identity rotation ──────────────
|
||||
static void test_matched_and_mismatch(const std::string& tofu_path) {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_tofu_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
auto data_dir_1 = (tmp / "server1").string(); // identity A
|
||||
auto data_dir_2 = (tmp / "server2").string(); // identity B (different)
|
||||
|
||||
// ── Server 1 (identity A), first connect: accept + pin ──────────────────────
|
||||
RunningServer server1;
|
||||
CHECK(server1.start(data_dir_1, 0, "VoiceCat-TofuA"));
|
||||
uint16_t port = server1.port;
|
||||
std::printf("test_matched_and_mismatch: server1 ready on :%u\n", port);
|
||||
|
||||
{
|
||||
GatedEventStore ev;
|
||||
vc_callbacks cb{on_event_gated, nullptr, &ev};
|
||||
vc_config cfg{"test-pin", "0.1", VC_LOG_OFF};
|
||||
cfg.tofu_store_path = tofu_path.c_str();
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
ev.client = c;
|
||||
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(c, "Pin") == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
|
||||
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); }
|
||||
CHECK(vc_confirm_server_identity(c, 1) == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000));
|
||||
vc_disconnect(c);
|
||||
vc_client_destroy(c);
|
||||
}
|
||||
server1.stop_and_join();
|
||||
|
||||
// ── Server 1 restarted on the SAME port, SAME data_dir (identity A reloaded from disk
|
||||
// — ServerIdentityManager::init's load-existing-files path) — expect MATCHED. ──────────
|
||||
RunningServer server1_restarted;
|
||||
CHECK(server1_restarted.start(data_dir_1, port, "VoiceCat-TofuA"));
|
||||
{
|
||||
GatedEventStore ev;
|
||||
vc_callbacks cb{on_event_gated, nullptr, &ev};
|
||||
vc_config cfg{"test-matched", "0.1", VC_LOG_OFF};
|
||||
cfg.tofu_store_path = tofu_path.c_str();
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
ev.client = c;
|
||||
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(c, "Matched") == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
|
||||
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MATCHED); }
|
||||
CHECK(vc_confirm_server_identity(c, 1) == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000));
|
||||
vc_disconnect(c);
|
||||
vc_client_destroy(c);
|
||||
}
|
||||
server1_restarted.stop_and_join();
|
||||
|
||||
// ── A DIFFERENT server (identity B, fresh data_dir) on the SAME port — expect
|
||||
// MISMATCH. Reject it, and confirm the pin file still reflects identity A afterwards. ───
|
||||
RunningServer server2;
|
||||
CHECK(server2.start(data_dir_2, port, "VoiceCat-TofuB"));
|
||||
{
|
||||
GatedEventStore ev;
|
||||
vc_callbacks cb{on_event_gated, nullptr, &ev};
|
||||
vc_config cfg{"test-mismatch", "0.1", VC_LOG_OFF};
|
||||
cfg.tofu_store_path = tofu_path.c_str();
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
ev.client = c;
|
||||
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
|
||||
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MISMATCH); }
|
||||
CHECK(vc_confirm_server_identity(c, 0) == VC_OK); // reject the rotated identity
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.disconnected; }, 5000));
|
||||
vc_client_destroy(c);
|
||||
}
|
||||
server2.stop_and_join();
|
||||
|
||||
// ── Server 1 (identity A) once more — rejecting the mismatch above must not have
|
||||
// clobbered the original pin. ───────────────────────────────────────────────────────────
|
||||
RunningServer server1_again;
|
||||
CHECK(server1_again.start(data_dir_1, port, "VoiceCat-TofuA"));
|
||||
{
|
||||
GatedEventStore ev;
|
||||
vc_callbacks cb{on_event_gated, nullptr, &ev};
|
||||
vc_config cfg{"test-still-matched", "0.1", VC_LOG_OFF};
|
||||
cfg.tofu_store_path = tofu_path.c_str();
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
ev.client = c;
|
||||
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
|
||||
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MATCHED); }
|
||||
vc_disconnect(c);
|
||||
vc_client_destroy(c);
|
||||
}
|
||||
server1_again.stop_and_join();
|
||||
|
||||
std::filesystem::remove_all(tmp);
|
||||
std::printf("test_matched_and_mismatch: ok\n");
|
||||
}
|
||||
|
||||
// ── 5. confirm_server_identity with nothing pending ─────────────────────────────
|
||||
static void test_confirm_with_nothing_pending() {
|
||||
vc_config cfg{"test-nopending", "0.1", VC_LOG_OFF};
|
||||
vc_callbacks cb{};
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
CHECK(vc_confirm_server_identity(c, 1) == VC_ERR_INVALID_ARG);
|
||||
vc_client_destroy(c);
|
||||
std::printf("test_confirm_with_nothing_pending: ok\n");
|
||||
}
|
||||
|
||||
// ── 6. vc_get_server_identity_display ───────────────────────────────────────────
|
||||
static void test_get_server_identity_display(uint16_t port, const std::string& tofu_path) {
|
||||
vc_config cfg{"test-display", "0.1", VC_LOG_OFF};
|
||||
cfg.tofu_store_path = tofu_path.c_str();
|
||||
|
||||
GatedEventStore ev;
|
||||
vc_callbacks cb{on_event_gated, nullptr, &ev};
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
ev.client = c;
|
||||
|
||||
// Pre-connect: empty.
|
||||
size_t len = 12345;
|
||||
CHECK(vc_get_server_identity_display(c, nullptr, 0, &len) == VC_OK);
|
||||
CHECK(len == 0);
|
||||
|
||||
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(c, "Display") == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
|
||||
CHECK(vc_confirm_server_identity(c, 1) == VC_OK);
|
||||
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000));
|
||||
|
||||
char buf[256] = {};
|
||||
CHECK(vc_get_server_identity_display(c, buf, sizeof(buf), &len) == VC_OK);
|
||||
CHECK(len == 64); // 32-byte Ed25519 fingerprint, raw hex, no colons
|
||||
CHECK(std::strlen(buf) == 64);
|
||||
|
||||
vc_disconnect(c);
|
||||
vc_client_destroy(c);
|
||||
std::printf("test_get_server_identity_display: ok\n");
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_confirm_with_nothing_pending();
|
||||
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_tofu_main_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
|
||||
{
|
||||
RunningServer server;
|
||||
CHECK(server.start((tmp / "srv").string(), 0, "VoiceCat-TofuFlow"));
|
||||
uint16_t port = server.port;
|
||||
std::printf("test_tofu_flow: server ready on :%u\n", port);
|
||||
|
||||
test_first_connect_blocks(port, (tmp / "pins_blocks.txt").string());
|
||||
test_reject_does_not_persist(port, (tmp / "pins_reject.txt").string());
|
||||
test_get_server_identity_display(port, (tmp / "pins_display.txt").string());
|
||||
|
||||
server.stop_and_join();
|
||||
}
|
||||
|
||||
test_matched_and_mismatch((tmp / "pins_matched.txt").string());
|
||||
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("tofu_flow: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("tofu_flow: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,732 +0,0 @@
|
||||
/*
|
||||
* test_vad_ptt_devices — closes M3's "explicitly out of scope" gaps (PROGRESS.md): device
|
||||
* enumeration, the VAD/PTT send-side input gate, and true stereo playback mixing.
|
||||
*
|
||||
* Mirrors test_m3_multistream.cpp's approach (real vc_client instances against a real
|
||||
* in-process server, not raw sockets) for the ABI-level pieces, plus a white-box AudioEngine
|
||||
* test for the stereo mixer (no audio hardware needed — see AudioEngine::mix_for_test).
|
||||
*
|
||||
* 1. Device enumeration (vc_list_devices) works pre-connect, for both kinds, and tolerates
|
||||
* an empty list (headless CI build agents may have zero audio devices) — VC_OK is the
|
||||
* only thing asserted, never count > 0.
|
||||
* 2. VAD gate: under VC_INPUT_VOICE_ACTIVATION (the default), silent PCM never reaches the
|
||||
* peer (no talking edge); loud PCM does.
|
||||
* 3. PTT gate: under VC_INPUT_PUSH_TO_TALK, loud PCM is gated closed until
|
||||
* vc_set_push_to_talk(1); then it reaches the peer.
|
||||
* 4. Stereo playback mixer: white-box (AudioEngine directly) — a genuinely stereo decoded
|
||||
* stream survives into the mix without being downmixed to mono.
|
||||
*/
|
||||
#include <cstdio>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
#include "audio/audio_engine.h"
|
||||
#include "codec/opus_codec.h"
|
||||
|
||||
// ── Event tracking (same shape as test_m3_multistream.cpp) ──────────────────────
|
||||
|
||||
struct TalkEvent {
|
||||
uint32_t user_id;
|
||||
uint32_t stream_id;
|
||||
bool talking;
|
||||
};
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool saw_stream_started{false};
|
||||
std::vector<TalkEvent> talk_events;
|
||||
bool voice_subscribed{false};
|
||||
bool disconnected{false};
|
||||
|
||||
const char* label{nullptr};
|
||||
|
||||
// Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the
|
||||
// M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test.
|
||||
vc_client* client{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
// No human to ask in a headless test — trust on first connect unconditionally.
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_VOICE_STATE:
|
||||
s->voice_subscribed = (ev->u32a == 1);
|
||||
break;
|
||||
case VC_EVENT_STREAM_STARTED:
|
||||
s->saw_stream_started = true;
|
||||
break;
|
||||
case VC_EVENT_TALK_STATE:
|
||||
s->talk_events.push_back({ev->user_id, ev->stream_id, ev->u32a != 0});
|
||||
break;
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template <typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static std::vector<int16_t> make_sine_frame(int frame_idx, float freq_hz,
|
||||
int frame_samples = 960) {
|
||||
std::vector<int16_t> pcm(frame_samples);
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(frame_idx * frame_samples + i) / 48000.0f;
|
||||
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * freq_hz * t) * 16000.0f);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
static std::vector<int16_t> make_silence_frame(int frame_samples = 960) {
|
||||
return std::vector<int16_t>(frame_samples, 0);
|
||||
}
|
||||
|
||||
// Did `talking==true` ever fire for (user_id, stream_id) at index >= `from`?
|
||||
static bool saw_talking_true(EventStore& s, uint32_t user_id, uint32_t stream_id, size_t from) {
|
||||
std::lock_guard lk(s.mu);
|
||||
for (size_t i = from; i < s.talk_events.size(); ++i) {
|
||||
auto& e = s.talk_events[i];
|
||||
if (e.user_id == user_id && e.stream_id == stream_id && e.talking) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static size_t talk_event_count(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
return s.talk_events.size();
|
||||
}
|
||||
|
||||
// ── Test harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// ── 1. Device enumeration (no server needed) ────────────────────────────────────
|
||||
static void test_device_enumeration() {
|
||||
vc_config cfg{"test-devices", "0.1", VC_LOG_OFF};
|
||||
vc_callbacks cb{};
|
||||
vc_client* c = vc_client_create(&cfg, cb);
|
||||
CHECK(c != nullptr);
|
||||
|
||||
for (vc_device_kind kind : {VC_DEVICE_INPUT, VC_DEVICE_OUTPUT}) {
|
||||
vc_device_list dl{};
|
||||
vc_result r = vc_list_devices(c, kind, &dl);
|
||||
CHECK(r == VC_OK);
|
||||
// Headless CI build agents may legitimately report zero devices — never assert
|
||||
// count > 0, only that the call itself succeeded and the list is well-formed.
|
||||
for (size_t i = 0; i < dl.count; ++i) {
|
||||
CHECK(dl.items[i].id != nullptr);
|
||||
CHECK(dl.items[i].name != nullptr);
|
||||
}
|
||||
vc_free_device_list(&dl);
|
||||
vc_free_device_list(&dl); // idempotent — must not crash on a second call
|
||||
}
|
||||
|
||||
vc_client_destroy(c);
|
||||
std::printf("test_device_enumeration: ok\n");
|
||||
}
|
||||
|
||||
// ── 4. Stereo playback mixer (white-box, no audio hardware needed) ──────────────
|
||||
static void test_stereo_mix() {
|
||||
voicecat::audio::AudioEngine engine;
|
||||
voicecat::audio::AudioParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.capture_channels = 1;
|
||||
p.playback_channels = 2;
|
||||
p.frame_ms = 20;
|
||||
CHECK(engine.start(p)); // capture_cb intentionally omitted — not exercised here
|
||||
|
||||
voicecat::codec::OpusParams stereo_params;
|
||||
stereo_params.stereo = true;
|
||||
int frame_samples = voicecat::codec::opus_frame_samples(stereo_params);
|
||||
|
||||
voicecat::codec::OpusEncoder enc;
|
||||
CHECK(enc.init(stereo_params));
|
||||
|
||||
// Loud left channel, silent right channel — a real downmix would average them into a
|
||||
// single audible-but-quieter centered sample; true stereo should keep them distinct.
|
||||
std::vector<int16_t> interleaved(static_cast<size_t>(frame_samples) * 2);
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
interleaved[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
|
||||
interleaved[i * 2 + 1] = 0;
|
||||
}
|
||||
|
||||
uint8_t opus_buf[1500];
|
||||
int opus_len = enc.encode(interleaved.data(), frame_samples, opus_buf, sizeof(opus_buf));
|
||||
CHECK(opus_len > 0);
|
||||
|
||||
engine.init_recv_stream(/*ssrc=*/1, stereo_params, /*user_id=*/0, /*stream_id=*/0,
|
||||
/*is_voice=*/false);
|
||||
|
||||
voicecat::audio::JitterBuffer::Frame f;
|
||||
f.seq = 0;
|
||||
f.timestamp = 0;
|
||||
f.fec_present = false;
|
||||
f.payload.assign(opus_buf, opus_buf + opus_len);
|
||||
engine.push_recv_frame(1, std::move(f));
|
||||
|
||||
std::vector<int16_t> out(static_cast<size_t>(frame_samples) * 2, 0);
|
||||
engine.mix_for_test(out.data(), static_cast<uint32_t>(frame_samples));
|
||||
|
||||
// If the engine downmixed (old M3 behavior), every L/R pair would be identical (the
|
||||
// average of a loud sample and 0). True stereo should show a clear, consistent L != R
|
||||
// difference across the frame.
|
||||
int64_t total_diff = 0;
|
||||
for (int i = 0; i < frame_samples; ++i)
|
||||
total_diff += std::abs(static_cast<int>(out[i * 2]) - static_cast<int>(out[i * 2 + 1]));
|
||||
CHECK(total_diff > static_cast<int64_t>(frame_samples) * 1000); // well above decode noise
|
||||
|
||||
engine.remove_stream(1);
|
||||
engine.stop();
|
||||
std::printf("test_stereo_mix: ok (total_diff=%lld)\n", static_cast<long long>(total_diff));
|
||||
}
|
||||
|
||||
// ── 4a-2. Stereo screen-audio loopback capture (white-box, no audio hardware needed) ──
|
||||
// Regression for the mono-loopback bug: start_loopback_capture used to hardcode channels=1,
|
||||
// downmixing the system's stereo mix to mono before the encoder ever saw it (and on_capture_frame
|
||||
// then upmixed L=R to produce a fake-stereo bitstream). Now the loopback device opens in the
|
||||
// channel's mode (stereo when the channel is stereo), so the encoder receives real interleaved
|
||||
// L/R PCM and encodes it directly. This test drives feed_loopback_for_test with a loud-L /
|
||||
// silent-R stereo signal, encodes it (as on_capture_frame now does for channels==2), decodes,
|
||||
// and mixes — asserting L != R across the frame. A mono-downmixed-then-upmixed bitstream would
|
||||
// have L == R. Mirrors test_stereo_mix but routes the encode side through the loopback
|
||||
// accumulator path that the fix touches (feed_loopback_for_test → on_loopback's accumulator).
|
||||
#if defined(VOICECAT_HAS_LOOPBACK)
|
||||
static void test_loopback_stereo_capture() {
|
||||
voicecat::audio::AudioEngine engine;
|
||||
voicecat::audio::AudioParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.capture_channels = 1; // mic path — irrelevant here; loopback has its own channel count
|
||||
p.playback_channels = 2; // stereo mix output (for mix_for_test below)
|
||||
p.frame_ms = 20;
|
||||
CHECK(engine.start(p)); // no capture_cb — the real mic (if any) won't touch capture_accum_
|
||||
|
||||
voicecat::codec::OpusParams stereo_params;
|
||||
stereo_params.stereo = true;
|
||||
stereo_params.application = voicecat::codec::OpusApplication::Audio; // screen-audio channel
|
||||
stereo_params.bitrate_bps = 128000; // music/screen-audio channel default
|
||||
int frame_samples = voicecat::codec::opus_frame_samples(stereo_params);
|
||||
|
||||
voicecat::codec::OpusEncoder enc;
|
||||
CHECK(enc.init(stereo_params));
|
||||
|
||||
// Loud left channel, silent right — a real mono downmix would average them into a single
|
||||
// audible-but-quieter centered sample; true stereo keeps them distinct.
|
||||
std::vector<int16_t> interleaved(static_cast<size_t>(frame_samples) * 2);
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
interleaved[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
|
||||
interleaved[i * 2 + 1] = 0;
|
||||
}
|
||||
|
||||
// Encode via the loopback accumulator path: feed_loopback_for_test drives on_loopback's
|
||||
// accumulator and invokes the callback with channels=2 (the fix). The callback encodes
|
||||
// exactly as on_capture_frame does for real-stereo SCREEN_AUDIO PCM — no upmix.
|
||||
uint8_t opus_buf[1500];
|
||||
int opus_len = 0;
|
||||
int seen_channels = 0;
|
||||
auto cb = [&](int /*kind*/, const int16_t* pcm, int /*samples*/, int channels) {
|
||||
seen_channels = channels;
|
||||
if (channels == 2) {
|
||||
// The loopback accumulator must have preserved L/R distinctness pre-encode.
|
||||
int64_t pre_diff = 0;
|
||||
for (int i = 0; i < frame_samples; ++i)
|
||||
pre_diff += std::abs(static_cast<int>(pcm[i * 2]) - static_cast<int>(pcm[i * 2 + 1]));
|
||||
CHECK(pre_diff > static_cast<int64_t>(frame_samples) * 1000);
|
||||
}
|
||||
opus_len = enc.encode(pcm, frame_samples, opus_buf, sizeof(opus_buf));
|
||||
};
|
||||
engine.feed_loopback_for_test(interleaved.data(), frame_samples, 2, cb);
|
||||
CHECK(seen_channels == 2); // the loopback path reported stereo, not downmixed mono
|
||||
CHECK(opus_len > 0);
|
||||
|
||||
// Decode + mix — same recv path as test_stereo_mix. A real stereo bitstream should
|
||||
// survive with L != R; a mono-downmixed-then-upmixed bitstream would have L == R.
|
||||
engine.init_recv_stream(/*ssrc=*/3, stereo_params, /*user_id=*/0, /*stream_id=*/0,
|
||||
/*is_voice=*/false);
|
||||
voicecat::audio::JitterBuffer::Frame f;
|
||||
f.seq = 0;
|
||||
f.timestamp = 0;
|
||||
f.fec_present = false;
|
||||
f.payload.assign(opus_buf, opus_buf + opus_len);
|
||||
engine.push_recv_frame(3, std::move(f));
|
||||
|
||||
std::vector<int16_t> out(static_cast<size_t>(frame_samples) * 2, 0);
|
||||
engine.mix_for_test(out.data(), static_cast<uint32_t>(frame_samples));
|
||||
|
||||
int64_t total_diff = 0;
|
||||
for (int i = 0; i < frame_samples; ++i)
|
||||
total_diff += std::abs(static_cast<int>(out[i * 2]) - static_cast<int>(out[i * 2 + 1]));
|
||||
CHECK(total_diff > static_cast<int64_t>(frame_samples) * 1000);
|
||||
|
||||
engine.remove_stream(3);
|
||||
engine.stop();
|
||||
std::printf("test_loopback_stereo_capture: ok (total_diff=%lld, seen_channels=%d)\n",
|
||||
static_cast<long long>(total_diff), seen_channels);
|
||||
}
|
||||
#endif
|
||||
|
||||
// ── 4b. Playout-clock re-sync after a late join / silence gap ────────────────────
|
||||
// Regression for the "talk indicator lit, no audio" bug: the playout clock free-runs (it
|
||||
// advances every callback via PLC), while the sender's frame timestamps only advance while it
|
||||
// is actually transmitting. After a silence gap or a late join the clock drifts past the jitter
|
||||
// buffer's 500 ms late-drop window, so every real frame is dropped-as-late and the stream is
|
||||
// permanently silent. on_playback must re-seed the clock to the earliest buffered frame.
|
||||
static void test_playout_resync() {
|
||||
voicecat::audio::AudioEngine engine;
|
||||
voicecat::audio::AudioParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.capture_channels = 1;
|
||||
p.playback_channels = 2;
|
||||
p.frame_ms = 20;
|
||||
CHECK(engine.start(p));
|
||||
|
||||
voicecat::codec::OpusParams mono_params; // mono = the mic path
|
||||
mono_params.stereo = false;
|
||||
int frame_samples = voicecat::codec::opus_frame_samples(mono_params);
|
||||
|
||||
voicecat::codec::OpusEncoder enc;
|
||||
CHECK(enc.init(mono_params));
|
||||
|
||||
std::vector<int16_t> sine(static_cast<size_t>(frame_samples));
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
sine[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
|
||||
}
|
||||
uint8_t opus_buf[1500];
|
||||
int opus_len = enc.encode(sine.data(), frame_samples, opus_buf, sizeof(opus_buf));
|
||||
CHECK(opus_len > 0);
|
||||
|
||||
engine.init_recv_stream(/*ssrc=*/2, mono_params, /*user_id=*/0, /*stream_id=*/0,
|
||||
/*is_voice=*/false);
|
||||
|
||||
std::vector<int16_t> out(static_cast<size_t>(frame_samples) * 2, 0);
|
||||
|
||||
// Free-run the playout clock with an empty jitter buffer (PLC every callback) far past the
|
||||
// 500 ms late-drop window — this is what a silence gap / late join does in the field.
|
||||
for (int i = 0; i < 100; ++i) // ~100 frames @ 20 ms = ~2 s, well past 500 ms
|
||||
engine.mix_for_test(out.data(), static_cast<uint32_t>(frame_samples));
|
||||
|
||||
// Now a real frame arrives carrying a timestamp far behind the free-run clock. Without the
|
||||
// re-sync it is dropped-as-late and playback stays silent; with it the clock snaps back and
|
||||
// the frame is decoded and mixed.
|
||||
voicecat::audio::JitterBuffer::Frame f;
|
||||
f.seq = 0;
|
||||
f.timestamp = 0; // stream-relative start, now far behind the drifted playout clock
|
||||
f.fec_present = false;
|
||||
f.payload.assign(opus_buf, opus_buf + opus_len);
|
||||
engine.push_recv_frame(2, std::move(f));
|
||||
|
||||
std::fill(out.begin(), out.end(), 0);
|
||||
engine.mix_for_test(out.data(), static_cast<uint32_t>(frame_samples));
|
||||
|
||||
int64_t energy = 0;
|
||||
for (int16_t s : out) energy += std::abs(static_cast<int>(s));
|
||||
CHECK(energy > static_cast<int64_t>(frame_samples) * 1000); // audible, not PLC silence
|
||||
|
||||
engine.remove_stream(2);
|
||||
engine.stop();
|
||||
std::printf("test_playout_resync: ok (energy=%lld)\n", static_cast<long long>(energy));
|
||||
}
|
||||
|
||||
// ── 5. Capture-frame accumulation (white-box, no audio hardware needed) ──────────
|
||||
// Regression for the capture-side analogue of the playback ring fix: miniaudio's capture
|
||||
// callback fires at the hardware period (commonly 480 samples on WASAPI shared mode), while
|
||||
// opus_encode() requires exactly frame_samples_ (960). Sub-frame chunks must be accumulated;
|
||||
// the callback must receive exactly 960-sample frames regardless of input chunk size.
|
||||
static void test_capture_frame_accumulation() {
|
||||
voicecat::audio::AudioEngine engine;
|
||||
voicecat::audio::AudioParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.capture_channels = 1;
|
||||
p.frame_ms = 20; // frame_samples_ = 960
|
||||
|
||||
std::atomic<int> call_count{0};
|
||||
std::atomic<bool> wrong_size{false};
|
||||
constexpr int kExpected = 960;
|
||||
|
||||
// Start WITHOUT a capture callback: the real mic (if any) fires on_capture(), but
|
||||
// on_capture() returns immediately when capture_cb_ is null, so capture_accum_ is
|
||||
// never touched by the hardware thread. feed_capture_for_test() bypasses capture_cb_
|
||||
// and drives the same accumulator directly with the explicit `cb` below — no races.
|
||||
CHECK(engine.start(p));
|
||||
|
||||
auto cb = [&](int /*kind*/, const int16_t* /*pcm*/, int samples, int /*channels*/) {
|
||||
++call_count;
|
||||
if (samples != kExpected) wrong_size.store(true);
|
||||
};
|
||||
|
||||
// 480-sample (10 ms) input — WASAPI's common hardware period on modern Windows.
|
||||
// Two 480-chunk inputs → exactly one callback at 960.
|
||||
std::vector<int16_t> h(480, 1000);
|
||||
engine.feed_capture_for_test(h.data(), 480, cb);
|
||||
CHECK(call_count.load() == 0); // half a frame — no callback yet
|
||||
engine.feed_capture_for_test(h.data(), 480, cb);
|
||||
CHECK(call_count.load() == 1); // one full frame — callback fired once
|
||||
|
||||
// Mis-aligned split: 240 then 720 → still exactly one callback.
|
||||
std::vector<int16_t> s(240, 500), l(720, 500);
|
||||
engine.feed_capture_for_test(s.data(), 240, cb);
|
||||
CHECK(call_count.load() == 1);
|
||||
engine.feed_capture_for_test(l.data(), 720, cb);
|
||||
CHECK(call_count.load() == 2);
|
||||
|
||||
// 1920-sample input (two Opus frames) → exactly two callbacks.
|
||||
std::vector<int16_t> d(1920, 800);
|
||||
engine.feed_capture_for_test(d.data(), 1920, cb);
|
||||
CHECK(call_count.load() == 4);
|
||||
|
||||
CHECK(!wrong_size.load());
|
||||
engine.stop();
|
||||
std::printf("test_capture_frame_accumulation: ok (callbacks=%d)\n", call_count.load());
|
||||
}
|
||||
|
||||
// ── 2/3. VAD + PTT gate, through the real ABI against a real server ─────────────
|
||||
static void test_vad_and_ptt_gate() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_vadptt_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.media_port = 0;
|
||||
cfg.server_name = "VoiceCat-VadPttTest";
|
||||
cfg.allow_guests = true;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
|
||||
if (!ok) {
|
||||
std::printf("FAIL: server did not become ready within 10s\n");
|
||||
++g_failures;
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t port = bound_port.load();
|
||||
std::printf("test_vad_and_ptt_gate: server ready on :%u\n", port);
|
||||
|
||||
EventStore evA;
|
||||
evA.label = "A";
|
||||
vc_callbacks cbA{on_event, nullptr, &evA};
|
||||
vc_config cfgA{"test-A", "0.1", VC_LOG_OFF};
|
||||
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
||||
CHECK(clientA != nullptr);
|
||||
evA.client = clientA;
|
||||
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(clientA, "VP-A") == VC_OK);
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
|
||||
CHECK(vc_join_voice(clientA) == VC_OK);
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000));
|
||||
|
||||
EventStore evB;
|
||||
evB.label = "B";
|
||||
vc_callbacks cbB{on_event, nullptr, &evB};
|
||||
vc_config cfgB{"test-B", "0.1", VC_LOG_OFF};
|
||||
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
||||
CHECK(clientB != nullptr);
|
||||
evB.client = clientB;
|
||||
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(clientB, "VP-B") == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
|
||||
CHECK(vc_join_voice(clientB) == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000));
|
||||
|
||||
uint32_t a_uid = 0;
|
||||
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
|
||||
vc_stream_desc mic_desc{};
|
||||
mic_desc.kind = VC_STREAM_MIC;
|
||||
mic_desc.label = "mic";
|
||||
uint32_t mic_sid = 0;
|
||||
CHECK(vc_stream_start(clientA, &mic_desc, &mic_sid) == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.saw_stream_started; }, 5000));
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.saw_stream_started; }, 5000));
|
||||
|
||||
// ── 2a. VAD mode (default), silent PCM: must NOT reach B as a talking edge ──
|
||||
CHECK(vc_set_input_mode(clientA, VC_INPUT_VOICE_ACTIVATION) == VC_OK);
|
||||
for (int i = 0; i < 15; ++i) {
|
||||
auto silence = make_silence_frame();
|
||||
CHECK(vc_test_inject_capture(clientA, mic_sid, silence.data(), silence.size()) == VC_OK);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
CHECK(!saw_talking_true(evB, a_uid, mic_sid, 0));
|
||||
|
||||
// ── 2b. VAD mode, loud PCM: must reach B as a talking edge ──────────────────
|
||||
size_t mark = talk_event_count(evB);
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
auto loud = make_sine_frame(i, 440.0f);
|
||||
CHECK(vc_test_inject_capture(clientA, mic_sid, loud.data(), loud.size()) == VC_OK);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
CHECK(wait_for(evB, [&](EventStore& s) {
|
||||
for (size_t i = mark; i < s.talk_events.size(); ++i) {
|
||||
auto& e = s.talk_events[i];
|
||||
if (e.user_id == a_uid && e.stream_id == mic_sid && e.talking) return true;
|
||||
}
|
||||
return false;
|
||||
}, 3000));
|
||||
|
||||
// ── 3a. PTT mode, key up: loud PCM must NOT reach B as a new talking edge ───
|
||||
CHECK(vc_set_input_mode(clientA, VC_INPUT_PUSH_TO_TALK) == VC_OK);
|
||||
CHECK(vc_set_push_to_talk(clientA, 0) == VC_OK);
|
||||
// Let any in-flight VAD-driven talking state lapse (hang-time ~300ms) before measuring.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
mark = talk_event_count(evB);
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
auto loud = make_sine_frame(i, 440.0f);
|
||||
CHECK(vc_test_inject_capture(clientA, mic_sid, loud.data(), loud.size()) == VC_OK);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
CHECK(!saw_talking_true(evB, a_uid, mic_sid, mark));
|
||||
|
||||
// ── 3b. PTT mode, key down: loud PCM must reach B as a talking edge ─────────
|
||||
CHECK(vc_set_push_to_talk(clientA, 1) == VC_OK);
|
||||
mark = talk_event_count(evB);
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
auto loud = make_sine_frame(i, 440.0f);
|
||||
CHECK(vc_test_inject_capture(clientA, mic_sid, loud.data(), loud.size()) == VC_OK);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
CHECK(wait_for(evB, [&](EventStore& s) {
|
||||
for (size_t i = mark; i < s.talk_events.size(); ++i) {
|
||||
auto& e = s.talk_events[i];
|
||||
if (e.user_id == a_uid && e.stream_id == mic_sid && e.talking) return true;
|
||||
}
|
||||
return false;
|
||||
}, 3000));
|
||||
|
||||
{ std::lock_guard lk(evA.mu); CHECK(!evA.disconnected); }
|
||||
{ std::lock_guard lk(evB.mu); CHECK(!evB.disconnected); }
|
||||
|
||||
vc_disconnect(clientA);
|
||||
vc_disconnect(clientB);
|
||||
vc_client_destroy(clientA);
|
||||
vc_client_destroy(clientB);
|
||||
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
std::printf("test_vad_and_ptt_gate: done\n");
|
||||
}
|
||||
|
||||
// ── 5. Stereo mic capture (vc_set_capture_channels) ───────────────────────────
|
||||
// Verifies that the mic capture accumulator path handles stereo (channels=2) correctly:
|
||||
// the accumulator is sized to frame_samples_*capture_channels, on_capture forwards the
|
||||
// correct channel count, and the encoder receives real interleaved L/R PCM (not a mono
|
||||
// downmix). Mirrors test_loopback_stereo_capture but routes through the mic capture
|
||||
// accumulator (feed_capture_for_test with channels=2) instead of the loopback path.
|
||||
// This is the headless CI test for the iOS stereo built-in mic feature (Part D).
|
||||
static void test_stereo_mic_capture() {
|
||||
voicecat::audio::AudioEngine engine;
|
||||
voicecat::audio::AudioParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.capture_channels = 2; // stereo mic capture (vc_set_capture_channels path)
|
||||
p.playback_channels = 2; // stereo mix output (for mix_for_test below)
|
||||
p.frame_ms = 20;
|
||||
CHECK(engine.start(p)); // no capture_cb — the real mic (if any) won't touch capture_accum_
|
||||
|
||||
voicecat::codec::OpusParams stereo_params;
|
||||
stereo_params.stereo = true;
|
||||
stereo_params.application = voicecat::codec::OpusApplication::Voip; // mic stream
|
||||
stereo_params.bitrate_bps = 64000; // mic default
|
||||
int frame_samples = voicecat::codec::opus_frame_samples(stereo_params);
|
||||
|
||||
voicecat::codec::OpusEncoder enc;
|
||||
CHECK(enc.init(stereo_params));
|
||||
|
||||
// Loud left channel, silent right — a mono downmix would average them; true stereo
|
||||
// keeps them distinct (same signal as test_loopback_stereo_capture).
|
||||
std::vector<int16_t> interleaved(static_cast<size_t>(frame_samples) * 2);
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
interleaved[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
|
||||
interleaved[i * 2 + 1] = 0;
|
||||
}
|
||||
|
||||
// Encode via the mic capture accumulator path: feed_capture_for_test with channels=2
|
||||
// drives on_capture's accumulator and invokes the callback with channels=2. The callback
|
||||
// encodes exactly as on_capture_frame does for channels==2 — direct stereo, no upmix.
|
||||
uint8_t opus_buf[1500];
|
||||
int opus_len = 0;
|
||||
int seen_channels = 0;
|
||||
auto cb = [&](int /*kind*/, const int16_t* pcm, int /*samples*/, int channels) {
|
||||
seen_channels = channels;
|
||||
if (channels == 2) {
|
||||
// The capture accumulator must have preserved L/R distinctness pre-encode.
|
||||
int64_t pre_diff = 0;
|
||||
for (int i = 0; i < frame_samples; ++i)
|
||||
pre_diff += std::abs(static_cast<int>(pcm[i * 2]) - static_cast<int>(pcm[i * 2 + 1]));
|
||||
CHECK(pre_diff > static_cast<int64_t>(frame_samples) * 1000);
|
||||
}
|
||||
opus_len = enc.encode(pcm, frame_samples, opus_buf, sizeof(opus_buf));
|
||||
};
|
||||
engine.feed_capture_for_test(interleaved.data(), frame_samples, 2, cb);
|
||||
CHECK(seen_channels == 2); // the mic capture path reported stereo, not mono
|
||||
CHECK(opus_len > 0);
|
||||
|
||||
// Decode + mix — same recv path as test_stereo_mix. A real stereo bitstream should
|
||||
// survive with L != R; a mono-downmixed-then-upmixed bitstream would have L == R.
|
||||
engine.init_recv_stream(/*ssrc=*/5, stereo_params, /*user_id=*/0, /*stream_id=*/0,
|
||||
/*is_voice=*/false);
|
||||
voicecat::audio::JitterBuffer::Frame f;
|
||||
f.seq = 0;
|
||||
f.timestamp = 0;
|
||||
f.fec_present = false;
|
||||
f.payload.assign(opus_buf, opus_buf + opus_len);
|
||||
engine.push_recv_frame(5, std::move(f));
|
||||
|
||||
std::vector<int16_t> out(static_cast<size_t>(frame_samples) * 2, 0);
|
||||
engine.mix_for_test(out.data(), static_cast<uint32_t>(frame_samples));
|
||||
|
||||
int64_t total_diff = 0;
|
||||
for (int i = 0; i < frame_samples; ++i)
|
||||
total_diff += std::abs(static_cast<int>(out[i * 2]) - static_cast<int>(out[i * 2 + 1]));
|
||||
CHECK(total_diff > static_cast<int64_t>(frame_samples) * 1000);
|
||||
|
||||
engine.remove_stream(5);
|
||||
engine.stop();
|
||||
std::printf("test_stereo_mic_capture: ok (total_diff=%lld, seen_channels=%d)\n",
|
||||
static_cast<long long>(total_diff), seen_channels);
|
||||
}
|
||||
|
||||
// ── 6. Stereo mic capture on a MONO channel (downmix safety) ──────────────────
|
||||
// A stereo mic (vc_set_capture_channels=2) can be enabled while on a mono channel. The mic
|
||||
// then delivers interleaved L/R, but the channel's Opus encoder is mono. encode_and_send_frame
|
||||
// must fold L/R to mono before encoding — handing interleaved pairs straight to a mono
|
||||
// opus_encode makes it read 2× the samples it should (wrong pitch / garbage). This mirrors that
|
||||
// fold and proves the result is a valid mono bitstream that decodes to the expected averaged
|
||||
// signal, rather than half-length junk.
|
||||
static void test_stereo_mic_mono_channel() {
|
||||
voicecat::codec::OpusParams mono_params;
|
||||
mono_params.stereo = false; // mono channel — encoder is mono
|
||||
mono_params.application = voicecat::codec::OpusApplication::Voip;
|
||||
mono_params.bitrate_bps = 64000;
|
||||
const int frame_samples = voicecat::codec::opus_frame_samples(mono_params);
|
||||
|
||||
voicecat::codec::OpusEncoder enc;
|
||||
CHECK(enc.init(mono_params));
|
||||
|
||||
// Loud left, silent right — folding (L+R)/2 yields a half-amplitude tone on every sample.
|
||||
std::vector<int16_t> interleaved(static_cast<size_t>(frame_samples) * 2);
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
interleaved[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
|
||||
interleaved[i * 2 + 1] = 0;
|
||||
}
|
||||
|
||||
// Fold exactly as encode_and_send_frame does for a stereo frame on a mono channel.
|
||||
std::vector<int16_t> folded(frame_samples);
|
||||
for (int i = 0; i < frame_samples; ++i)
|
||||
folded[i] = static_cast<int16_t>(
|
||||
(static_cast<int32_t>(interleaved[i * 2]) + static_cast<int32_t>(interleaved[i * 2 + 1])) / 2);
|
||||
|
||||
uint8_t opus_buf[1500];
|
||||
int opus_len = enc.encode(folded.data(), frame_samples, opus_buf, sizeof(opus_buf));
|
||||
CHECK(opus_len > 0);
|
||||
|
||||
// Decode mono and verify a full-length frame with real energy survived (a garbage half-read
|
||||
// would either fail to decode the full frame_samples or come back near-silent / wrong length).
|
||||
voicecat::codec::OpusDecoder dec;
|
||||
CHECK(dec.init(mono_params));
|
||||
std::vector<int16_t> decoded(frame_samples, 0);
|
||||
int dec_samples = dec.decode(opus_buf, opus_len, decoded.data(), frame_samples);
|
||||
CHECK(dec_samples == frame_samples);
|
||||
|
||||
int64_t energy = 0;
|
||||
for (int i = 0; i < frame_samples; ++i) energy += std::abs(static_cast<int>(decoded[i]));
|
||||
CHECK(energy > static_cast<int64_t>(frame_samples) * 500); // clearly audible, not silence
|
||||
|
||||
std::printf("test_stereo_mic_mono_channel: ok (opus_len=%d, energy=%lld)\n",
|
||||
opus_len, static_cast<long long>(energy));
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_device_enumeration();
|
||||
test_stereo_mix();
|
||||
#if defined(VOICECAT_HAS_LOOPBACK)
|
||||
test_loopback_stereo_capture();
|
||||
#endif
|
||||
test_stereo_mic_capture();
|
||||
test_stereo_mic_mono_channel();
|
||||
test_playout_resync();
|
||||
test_capture_frame_accumulation();
|
||||
test_vad_and_ptt_gate();
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("vad_ptt_devices: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("vad_ptt_devices: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
/*
|
||||
* test_voice_client_abi — M2 exit criterion, exercised through the real C ABI.
|
||||
*
|
||||
* Unlike test_m2_voice.cpp (which drives raw BSD sockets to exercise the wire format),
|
||||
* this test drives two actual vc_client instances end-to-end: vc_connect, vc_authenticate_guest,
|
||||
* vc_stream_start/stop. It proves the client-side voice plane in core/src/core/client.cpp
|
||||
* (UDP binding, StreamAnnounce, SessionModel propagation) is wired up for real — not just
|
||||
* the raw-socket test harness.
|
||||
*/
|
||||
#include <cstdio>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
// ── Event tracking ────────────────────────────────────────────────────────────
|
||||
|
||||
struct StreamEvent {
|
||||
bool started; // true = STARTED, false = STOPPED
|
||||
uint32_t user_id;
|
||||
uint32_t stream_id;
|
||||
};
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
std::vector<StreamEvent> stream_events;
|
||||
bool voice_subscribed{false};
|
||||
|
||||
const char* label{nullptr};
|
||||
bool disconnected{false};
|
||||
|
||||
// Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the
|
||||
// M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test.
|
||||
vc_client* client{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
// No human to ask in a headless test — trust on first connect unconditionally.
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
if (!s->auth_ok) std::fprintf(stderr, "[%s] AUTH FAILED: %s\n",
|
||||
s->label ? s->label : "?", ev->text ? ev->text : "(no msg)");
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_VOICE_STATE:
|
||||
s->voice_subscribed = (ev->u32a == 1);
|
||||
break;
|
||||
case VC_EVENT_STREAM_STARTED:
|
||||
s->stream_events.push_back({true, ev->user_id, ev->stream_id});
|
||||
break;
|
||||
case VC_EVENT_STREAM_STOPPED:
|
||||
s->stream_events.push_back({false, ev->user_id, ev->stream_id});
|
||||
break;
|
||||
case VC_EVENT_ERROR:
|
||||
std::fprintf(stderr, "[%s] ERROR rc=%d: %s\n",
|
||||
s->label ? s->label : "?", ev->result, ev->text ? ev->text : "");
|
||||
break;
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template <typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
// ── Test harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0; // OS picks the TCP port
|
||||
cfg.media_port = 0; // OS picks the UDP port
|
||||
cfg.server_name = "VoiceCat-VoiceAbiTest";
|
||||
cfg.allow_guests = true;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
|
||||
if (!ok) {
|
||||
std::printf("FAIL: server did not become ready within 10s\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t port = bound_port.load();
|
||||
std::printf("voice_client_abi: server ready on :%u\n", port);
|
||||
|
||||
// ── Client A: guest "VoiceA" ───────────────────────────────────────────────
|
||||
EventStore evA;
|
||||
evA.label = "clientA";
|
||||
vc_callbacks cbA{on_event, nullptr, &evA};
|
||||
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
|
||||
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
||||
CHECK(clientA != nullptr);
|
||||
evA.client = clientA;
|
||||
|
||||
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(clientA, "VoiceA") == VC_OK);
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
|
||||
CHECK(vc_join_voice(clientA) == VC_OK);
|
||||
CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000));
|
||||
|
||||
// ── Client B: guest "VoiceB" ───────────────────────────────────────────────
|
||||
EventStore evB;
|
||||
evB.label = "clientB";
|
||||
vc_callbacks cbB{on_event, nullptr, &evB};
|
||||
vc_config cfgB{"test-clientB", "0.1", VC_LOG_OFF};
|
||||
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
||||
CHECK(clientB != nullptr);
|
||||
evB.client = clientB;
|
||||
|
||||
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_guest(clientB, "VoiceB") == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
||||
|
||||
CHECK(vc_join_voice(clientB) == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000));
|
||||
|
||||
uint32_t a_uid = 0;
|
||||
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
||||
|
||||
// Both guests land in channel 1 (Lobby) automatically; give the async UDP
|
||||
// binding handshake (TCP UdpBinding -> ack -> plaintext bootstrap packet) a
|
||||
// moment to complete on both clients before announcing a stream.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
|
||||
// ── A announces a MIC stream via the real C ABI ──────────────────────────
|
||||
vc_stream_desc desc{};
|
||||
desc.kind = VC_STREAM_MIC;
|
||||
desc.device_id = nullptr;
|
||||
desc.label = "mic";
|
||||
|
||||
uint32_t a_stream_id = 0;
|
||||
CHECK(vc_stream_start(clientA, &desc, &a_stream_id) == VC_OK);
|
||||
|
||||
// A learns its own stream started.
|
||||
bool a_self_started = wait_for(evA, [&](EventStore& s) {
|
||||
for (auto& e : s.stream_events)
|
||||
if (e.started && e.user_id == a_uid) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(a_self_started);
|
||||
|
||||
// B learns (via UserEvent::UPDATED -> SessionModel -> sync_remote_streams)
|
||||
// that A started a stream — this is the cross-client signaling path that
|
||||
// was missing while stream_start()/stream_announce_result were stubs.
|
||||
bool b_saw_a_started = wait_for(evB, [&](EventStore& s) {
|
||||
for (auto& e : s.stream_events)
|
||||
if (e.started && e.user_id == a_uid) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(b_saw_a_started);
|
||||
|
||||
// ── A stops the stream ────────────────────────────────────────────────────
|
||||
CHECK(vc_stream_stop(clientA, a_stream_id) == VC_OK);
|
||||
|
||||
bool a_self_stopped = wait_for(evA, [&](EventStore& s) {
|
||||
for (auto& e : s.stream_events)
|
||||
if (!e.started && e.user_id == a_uid) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(a_self_stopped);
|
||||
|
||||
bool b_saw_a_stopped = wait_for(evB, [&](EventStore& s) {
|
||||
for (auto& e : s.stream_events)
|
||||
if (!e.started && e.user_id == a_uid) return true;
|
||||
return false;
|
||||
}, 5000);
|
||||
CHECK(b_saw_a_stopped);
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
vc_disconnect(clientA);
|
||||
vc_disconnect(clientB);
|
||||
vc_client_destroy(clientA);
|
||||
vc_client_destroy(clientB);
|
||||
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("voice_client_abi: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("voice_client_abi: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* test_voice_frame — serialize/parse round-trips for the 20-byte UDP media header.
|
||||
*/
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "net/voice_frame.h"
|
||||
|
||||
using namespace voicecat::net;
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { if (!(cond)) { \
|
||||
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
}} while (0)
|
||||
|
||||
static void test_header_round_trip() {
|
||||
VoiceFrame f;
|
||||
f.type = kFrameVoice;
|
||||
f.flags = kFlagMarker | kFlagFecPresent;
|
||||
f.codec = kCodecOpus;
|
||||
f.ssrc = 0xDEADBEEF;
|
||||
f.seq = 0x0123456789ABCDEFULL; // full 64-bit range (protocol v2)
|
||||
f.timestamp = 0x12345678;
|
||||
|
||||
uint8_t buf[kVoiceHeaderSize];
|
||||
serialize_header(f, buf);
|
||||
|
||||
VoiceFrame out{};
|
||||
CHECK(parse_header(buf, kVoiceHeaderSize, out));
|
||||
CHECK(out.type == f.type);
|
||||
CHECK(out.flags == f.flags);
|
||||
CHECK(out.codec == f.codec);
|
||||
CHECK(out.ssrc == f.ssrc);
|
||||
CHECK(out.seq == f.seq);
|
||||
CHECK(out.timestamp == f.timestamp);
|
||||
}
|
||||
|
||||
static void test_empty_payload_packet() {
|
||||
VoiceFrame f;
|
||||
f.type = kFrameKeepalive;
|
||||
f.ssrc = 42;
|
||||
|
||||
uint8_t buf[kVoiceHeaderSize];
|
||||
serialize_header(f, buf);
|
||||
|
||||
VoiceFrame out{};
|
||||
CHECK(parse_header(buf, kVoiceHeaderSize, out));
|
||||
CHECK(out.type == kFrameKeepalive);
|
||||
CHECK(out.ssrc == 42);
|
||||
}
|
||||
|
||||
static void test_payload_packet() {
|
||||
std::vector<uint8_t> payload(60, 0xAB);
|
||||
VoiceFrame f;
|
||||
f.ssrc = 0x00000001;
|
||||
f.seq = 0x0001;
|
||||
f.timestamp = 960;
|
||||
f.payload = payload;
|
||||
|
||||
// Serialize full wire packet
|
||||
std::vector<uint8_t> wire(kVoiceHeaderSize + payload.size());
|
||||
serialize_header(f, wire.data());
|
||||
std::memcpy(wire.data() + kVoiceHeaderSize, payload.data(), payload.size());
|
||||
|
||||
VoiceFrame out{};
|
||||
CHECK(parse_header(wire.data(), wire.size(), out));
|
||||
CHECK(out.ssrc == f.ssrc);
|
||||
CHECK(out.seq == f.seq);
|
||||
CHECK(out.timestamp == f.timestamp);
|
||||
|
||||
// Payload starts at kVoiceHeaderSize
|
||||
CHECK(wire.size() - kVoiceHeaderSize == 60);
|
||||
CHECK(wire[kVoiceHeaderSize] == 0xAB);
|
||||
}
|
||||
|
||||
static void test_udp_binding_packet() {
|
||||
uint8_t token[16];
|
||||
for (int i = 0; i < 16; ++i) token[i] = static_cast<uint8_t>(i);
|
||||
|
||||
auto pkt = make_udp_binding_packet(token, 16);
|
||||
CHECK(pkt.size() == kVoiceHeaderSize + 16);
|
||||
CHECK(pkt[0] == kFrameUdpBinding);
|
||||
CHECK(std::memcmp(pkt.data() + kVoiceHeaderSize, token, 16) == 0);
|
||||
}
|
||||
|
||||
static void test_parse_too_short() {
|
||||
uint8_t buf[10] = {};
|
||||
VoiceFrame out{};
|
||||
CHECK(!parse_header(buf, 10, out));
|
||||
}
|
||||
|
||||
static void test_big_endian_layout() {
|
||||
VoiceFrame f;
|
||||
f.ssrc = 0x01020304;
|
||||
f.seq = 0x05060708090A0B0CULL;
|
||||
f.timestamp = 0x0D0E0F10;
|
||||
|
||||
uint8_t buf[kVoiceHeaderSize];
|
||||
serialize_header(f, buf);
|
||||
|
||||
// ssrc at [4..7]
|
||||
CHECK(buf[4] == 0x01 && buf[5] == 0x02 && buf[6] == 0x03 && buf[7] == 0x04);
|
||||
// seq (u64) at [8..15]
|
||||
CHECK(buf[8] == 0x05 && buf[9] == 0x06 && buf[10] == 0x07 && buf[11] == 0x08 &&
|
||||
buf[12] == 0x09 && buf[13] == 0x0A && buf[14] == 0x0B && buf[15] == 0x0C);
|
||||
// timestamp at [16..19]
|
||||
CHECK(buf[16] == 0x0D && buf[17] == 0x0E && buf[18] == 0x0F && buf[19] == 0x10);
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_header_round_trip();
|
||||
test_empty_payload_packet();
|
||||
test_payload_packet();
|
||||
test_udp_binding_packet();
|
||||
test_parse_too_short();
|
||||
test_big_endian_layout();
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("voice_frame: all tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("voice_frame: %d test(s) FAILED\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user