2026-06-20 13:40:47 +02:00
|
|
|
/*
|
|
|
|
|
* 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};
|
feat: fix voice join/leave, channel edit defaults, channel-update stream restart
Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS):
1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane.
Previously the button only toggled the local mic — receiving was always on
(gated by channel membership alone). Added a protocol-level voice subscription
concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult
proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI
functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked
by the SFU relay recipient filter, and core-client gating of remote-stream
decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe
on Leave. Text chat works regardless of voice subscription.
2. Channel edit dialog now shows the channel's actual current settings. The read
struct vc_channel was missing sort_order and audio fields — only the write
struct vc_channel_info had them. Extended vc_channel with both (additive, no
ABI break), updated the session model and list_channels marshaling to populate
them, and updated all three clients' edit callers to use actual channel info
instead of hardcoded defaults.
3. Channel parameter updates now automatically restart everyone's streams.
Previously editing a channel's audio config persisted and broadcast a
ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are
frozen at announce time. handle_channel_event now detects audio-config changes
on the user's current channel and stop->starts each active local stream. The
server reads the updated config on re-announce; peers wire up fresh decoders
at the new ssrc.
All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not
yet compile-verified (Windows environment).
2026-06-24 14:29:39 +02:00
|
|
|
bool voice_subscribed{false};
|
2026-06-20 13:40:47 +02:00
|
|
|
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;
|
feat: fix voice join/leave, channel edit defaults, channel-update stream restart
Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS):
1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane.
Previously the button only toggled the local mic — receiving was always on
(gated by channel membership alone). Added a protocol-level voice subscription
concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult
proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI
functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked
by the SFU relay recipient filter, and core-client gating of remote-stream
decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe
on Leave. Text chat works regardless of voice subscription.
2. Channel edit dialog now shows the channel's actual current settings. The read
struct vc_channel was missing sort_order and audio fields — only the write
struct vc_channel_info had them. Extended vc_channel with both (additive, no
ABI break), updated the session model and list_channels marshaling to populate
them, and updated all three clients' edit callers to use actual channel info
instead of hardcoded defaults.
3. Channel parameter updates now automatically restart everyone's streams.
Previously editing a channel's audio config persisted and broadcast a
ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are
frozen at announce time. handle_channel_event now detects audio-config changes
on the user's current channel and stop->starts each active local stream. The
server reads the updated config on re-announce; peers wire up fresh decoders
at the new ssrc.
All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not
yet compile-verified (Windows environment).
2026-06-24 14:29:39 +02:00
|
|
|
case VC_EVENT_VOICE_STATE:
|
|
|
|
|
s->voice_subscribed = (ev->u32a == 1);
|
|
|
|
|
break;
|
2026-06-20 13:40:47 +02:00
|
|
|
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));
|
|
|
|
|
|
feat: fix voice join/leave, channel edit defaults, channel-update stream restart
Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS):
1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane.
Previously the button only toggled the local mic — receiving was always on
(gated by channel membership alone). Added a protocol-level voice subscription
concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult
proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI
functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked
by the SFU relay recipient filter, and core-client gating of remote-stream
decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe
on Leave. Text chat works regardless of voice subscription.
2. Channel edit dialog now shows the channel's actual current settings. The read
struct vc_channel was missing sort_order and audio fields — only the write
struct vc_channel_info had them. Extended vc_channel with both (additive, no
ABI break), updated the session model and list_channels marshaling to populate
them, and updated all three clients' edit callers to use actual channel info
instead of hardcoded defaults.
3. Channel parameter updates now automatically restart everyone's streams.
Previously editing a channel's audio config persisted and broadcast a
ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are
frozen at announce time. handle_channel_event now detects audio-config changes
on the user's current channel and stop->starts each active local stream. The
server reads the updated config on re-announce; peers wire up fresh decoders
at the new ssrc.
All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not
yet compile-verified (Windows environment).
2026-06-24 14:29:39 +02:00
|
|
|
CHECK(vc_join_voice(guest) == VC_OK);
|
|
|
|
|
CHECK(wait_for(evGuest, [](EventStore& s){ return s.voice_subscribed; }, 5000));
|
|
|
|
|
|
2026-06-20 13:40:47 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|