feat: fix voice join/leave, channel edit defaults, channel-update stream restart
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

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).
This commit is contained in:
2026-06-24 14:29:39 +02:00
parent 2baefddbe4
commit 6fe7bf0158
40 changed files with 701 additions and 71 deletions

View File

@@ -46,6 +46,7 @@ struct EventStore {
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};
@@ -61,6 +62,7 @@ static void on_event(void* user, const vc_event* ev) {
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);
@@ -90,6 +92,8 @@ static bool connect_guest(vc_client*& client, const char* name, const char* labe
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;
}

View File

@@ -59,6 +59,7 @@ struct EventStore {
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};
@@ -78,6 +79,9 @@ static void on_event(void* user, const vc_event* ev) {
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;
@@ -195,6 +199,9 @@ static void test_live_channel_user_list() {
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};
@@ -207,6 +214,9 @@ static void test_live_channel_user_list() {
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; }

View File

@@ -35,6 +35,7 @@ struct EventStore {
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};
@@ -54,6 +55,9 @@ static void on_event(void* user, const vc_event* ev) {
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);
@@ -199,6 +203,9 @@ int main() {
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; }

View File

@@ -47,6 +47,7 @@ struct EventStore {
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};
@@ -66,6 +67,9 @@ static void on_event(void* user, const vc_event* ev) {
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;
@@ -96,6 +100,8 @@ static bool connect_guest(vc_client*& client, const char* name, const char* labe
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;
}

View File

@@ -51,6 +51,7 @@ struct EventStore {
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};
@@ -66,6 +67,7 @@ static void on_event(void* user, const vc_event* ev) {
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);
@@ -95,6 +97,8 @@ static bool connect_guest(vc_client*& client, const char* name, const char* labe
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;
}

View File

@@ -291,6 +291,23 @@ struct TestClient {
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.
{
@@ -396,6 +413,7 @@ int main() {
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);
@@ -408,6 +426,7 @@ int main() {
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) {

View File

@@ -59,6 +59,7 @@ struct EventStore {
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};
@@ -85,6 +86,9 @@ static void on_event(void* user, const vc_event* ev) {
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;
@@ -191,6 +195,9 @@ int main() {
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";
@@ -205,6 +212,9 @@ int main() {
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; }

View File

@@ -59,6 +59,7 @@ struct EventStore {
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};
@@ -83,6 +84,9 @@ static void on_event(void* user, const vc_event* ev) {
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;
@@ -490,6 +494,9 @@ static void test_vad_and_ptt_gate() {
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};
@@ -502,6 +509,9 @@ static void test_vad_and_ptt_gate() {
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; }

View File

@@ -40,6 +40,7 @@ struct EventStore {
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};
@@ -66,6 +67,9 @@ static void on_event(void* user, const vc_event* ev) {
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;
@@ -159,6 +163,9 @@ int main() {
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";
@@ -173,6 +180,9 @@ int main() {
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; }