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).
This commit is contained in:
@@ -500,6 +500,9 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
|
||||
case voicecat::v1::Envelope::kStreamAnnounceResult:
|
||||
handle_stream_announce_result(env.request_id(), env.stream_announce_result());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kVoiceSubscriptionResult:
|
||||
handle_voice_subscription_result(env.voice_subscription_result());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kPong: {
|
||||
// Correlate the echoed nonce to measure RTT (docs/protocol.md §7).
|
||||
uint64_t nonce = env.pong().nonce();
|
||||
@@ -618,10 +621,44 @@ void vc_client::handle_server_state(const voicecat::v1::ServerStateSnapshot& sna
|
||||
}
|
||||
|
||||
void vc_client::handle_channel_event(const voicecat::v1::ChannelEvent& ce) {
|
||||
bool audio_changed = false;
|
||||
uint32_t updated_channel_id = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||
|
||||
// Detect audio-config changes on our current channel BEFORE apply_channel_event
|
||||
// overwrites the stored config. Encoders/decoders are frozen at StreamAnnounceResult
|
||||
// time (docs/voice.md §3), so a channel audio change requires restarting active
|
||||
// local streams to pick up the new params.
|
||||
if (ce.kind() == voicecat::v1::ChannelEvent::UPDATED) {
|
||||
updated_channel_id = ce.channel().id();
|
||||
const auto* self_user = session_model_.find_user(self_user_id_);
|
||||
if (self_user && self_user->channel_id == updated_channel_id) {
|
||||
const auto* old_ch = session_model_.find_channel(updated_channel_id);
|
||||
const auto& na = ce.channel().audio();
|
||||
if (old_ch) {
|
||||
audio_changed =
|
||||
old_ch->audio_sample_rate != (na.sample_rate() ? na.sample_rate() : 48000) ||
|
||||
old_ch->audio_frame_ms != (na.frame_ms() ? na.frame_ms() : 20) ||
|
||||
old_ch->audio_mode != static_cast<uint32_t>(na.mode()) ||
|
||||
old_ch->audio_bitrate_bps != na.bitrate_bps() ||
|
||||
old_ch->audio_application != static_cast<uint32_t>(na.application()) ||
|
||||
old_ch->audio_fec != na.fec() ||
|
||||
old_ch->audio_expected_packet_loss != na.expected_packet_loss() ||
|
||||
old_ch->audio_dtx != na.dtx() ||
|
||||
old_ch->audio_complexity != (na.complexity() ? na.complexity() : 10) ||
|
||||
old_ch->audio_dred != na.dred();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session_model_.apply_channel_event(ce);
|
||||
}
|
||||
|
||||
if (audio_changed) {
|
||||
restart_active_streams_for_channel(updated_channel_id);
|
||||
}
|
||||
|
||||
// Same "go look" signal vc_list_channels' callers already poll on after the initial
|
||||
// snapshot — see voicecat.h's VC_EVENT_CHANNEL_LIST doc comment.
|
||||
vc_event ev{};
|
||||
@@ -1215,6 +1252,10 @@ void vc_client::ensure_audio_running() {
|
||||
|
||||
void vc_client::sync_remote_streams(const voicecat::v1::User& user) {
|
||||
if (user.id() == self_user_id_) return;
|
||||
// Don't wire up remote decoders when not on the voice plane — the server isn't relaying
|
||||
// voice to us, and decoders would sit idle consuming memory. resync_remote_streams()
|
||||
// wires them up on voice join.
|
||||
if (!voice_subscribed_.load(std::memory_order_acquire)) return;
|
||||
|
||||
std::vector<uint32_t> current_ssrcs;
|
||||
for (const auto& si : user.streams()) current_ssrcs.push_back(si.ssrc());
|
||||
@@ -1285,6 +1326,7 @@ vc_result vc_client::stream_start(const vc_stream_desc& desc, uint32_t* out_stre
|
||||
ls.stream_id = sid;
|
||||
ls.pending = true;
|
||||
ls.external_feed = (desc.external_feed != 0);
|
||||
ls.label = desc.label ? desc.label : "";
|
||||
if (out_stream_id) *out_stream_id = sid;
|
||||
|
||||
req_id = next_req_id_++;
|
||||
@@ -1441,6 +1483,141 @@ vc_client::LocalStream* vc_client::find_local_stream_by_id(uint32_t stream_id) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void vc_client::restart_active_streams_for_channel(uint32_t channel_id) {
|
||||
// Collect active stream info under the lock, then stop→start outside the lock (stream_stop
|
||||
// and stream_start both acquire local_streams_mu_). capture_device_id and capture_channels
|
||||
// survive the restart: stream_stop doesn't clear them, and stream_start reuses the existing
|
||||
// LocalStream entry (auto& ls = local_streams_[kind]) without resetting them.
|
||||
struct ActiveStreamInfo {
|
||||
int kind;
|
||||
std::string label;
|
||||
int external_feed;
|
||||
uint32_t stream_id;
|
||||
};
|
||||
std::vector<ActiveStreamInfo> active;
|
||||
{
|
||||
std::lock_guard lk(local_streams_mu_);
|
||||
for (auto& [kind, ls] : local_streams_) {
|
||||
if (!ls.active.load(std::memory_order_acquire)) continue;
|
||||
active.push_back({kind, ls.label, ls.external_feed ? 1 : 0, ls.stream_id});
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& info : active) {
|
||||
stream_stop(info.stream_id);
|
||||
vc_stream_desc desc{};
|
||||
desc.kind = static_cast<vc_stream_kind>(info.kind);
|
||||
desc.device_id = nullptr; // default — capture_device_id is retained on the LocalStream
|
||||
desc.label = info.label.c_str();
|
||||
desc.external_feed = info.external_feed;
|
||||
uint32_t new_sid = 0;
|
||||
stream_start(desc, &new_sid);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Voice-plane subscription ──────────────────────────────────────────────────
|
||||
|
||||
vc_result vc_client::join_voice() {
|
||||
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||
voicecat::v1::Envelope req;
|
||||
req.set_request_id(next_req_id_++);
|
||||
req.mutable_subscribe_voice();
|
||||
queue_envelope(req);
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::leave_voice() {
|
||||
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||
voicecat::v1::Envelope req;
|
||||
req.set_request_id(next_req_id_++);
|
||||
req.mutable_unsubscribe_voice();
|
||||
queue_envelope(req);
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
void vc_client::handle_voice_subscription_result(
|
||||
const voicecat::v1::VoiceSubscriptionResult& msg) {
|
||||
bool subscribed = msg.subscribed();
|
||||
voice_subscribed_.store(subscribed, std::memory_order_release);
|
||||
|
||||
if (subscribed) {
|
||||
// Wire up remote-stream decoders for all users already in the session model.
|
||||
// While we were unsubscribed, sync_remote_streams was gated off, so no decoders
|
||||
// exist — resync from the current session state.
|
||||
resync_remote_streams();
|
||||
} else {
|
||||
// Stop all active local streams (emits VC_EVENT_STREAM_STOPPED for each) and tear
|
||||
// down every remote decoder so playback ceases. The server has already stopped
|
||||
// relaying voice to us.
|
||||
stop_all_local_streams();
|
||||
clear_all_remote_streams();
|
||||
}
|
||||
|
||||
vc_event ev{};
|
||||
ev.type = VC_EVENT_VOICE_STATE;
|
||||
ev.u32a = subscribed ? 1 : 0;
|
||||
emit(ev);
|
||||
}
|
||||
|
||||
void vc_client::resync_remote_streams() {
|
||||
bool any_added = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> sm_lk(session_model_mu_);
|
||||
std::lock_guard rs_lk(remote_streams_mu_);
|
||||
for (const auto& u : session_model_.users()) {
|
||||
if (u.id == self_user_id_) continue;
|
||||
for (const auto& s : u.streams) {
|
||||
if (remote_streams_.count(s.ssrc)) continue;
|
||||
remote_streams_[s.ssrc] = {u.id, s.stream_id};
|
||||
|
||||
voicecat::v1::AudioConfig audio;
|
||||
audio.set_sample_rate(s.sample_rate);
|
||||
audio.set_frame_ms(s.frame_ms);
|
||||
audio.set_mode(static_cast<voicecat::v1::ChannelMode>(s.mode));
|
||||
audio.set_bitrate_bps(s.bitrate_bps);
|
||||
audio.set_application(static_cast<voicecat::v1::OpusApplication>(s.application));
|
||||
audio.set_fec(s.fec);
|
||||
audio.set_expected_packet_loss(s.expected_packet_loss);
|
||||
audio.set_dtx(s.dtx);
|
||||
audio.set_complexity(s.complexity);
|
||||
audio.set_dred(s.dred);
|
||||
|
||||
voicecat::codec::OpusParams p = opus_params_from_audio_config(audio);
|
||||
bool is_voice = (s.kind == static_cast<int>(voicecat::v1::STREAM_MIC));
|
||||
audio_engine_.init_recv_stream(s.ssrc, p, u.id, s.stream_id, is_voice);
|
||||
bool muted = self_deafened_.load(std::memory_order_acquire) ||
|
||||
server_deafened_.load(std::memory_order_acquire);
|
||||
audio_engine_.set_stream_mute(s.ssrc, muted);
|
||||
any_added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (any_added) ensure_audio_running();
|
||||
}
|
||||
|
||||
void vc_client::clear_all_remote_streams() {
|
||||
std::lock_guard lk(remote_streams_mu_);
|
||||
for (auto& [ssrc, info] : remote_streams_) {
|
||||
audio_engine_.remove_stream(ssrc);
|
||||
}
|
||||
remote_streams_.clear();
|
||||
}
|
||||
|
||||
void vc_client::stop_all_local_streams() {
|
||||
std::vector<uint32_t> active_ids;
|
||||
{
|
||||
std::lock_guard lk(local_streams_mu_);
|
||||
for (auto& [kind, ls] : local_streams_) {
|
||||
if (ls.active.load(std::memory_order_acquire)) {
|
||||
active_ids.push_back(ls.stream_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (uint32_t sid : active_ids) {
|
||||
stream_stop(sid);
|
||||
}
|
||||
}
|
||||
|
||||
vc_result vc_client::set_input_device(uint32_t stream_id, const char* device_id) {
|
||||
int kind = -1;
|
||||
{
|
||||
@@ -1734,6 +1911,19 @@ vc_result vc_client::list_channels(vc_channel_list* out) {
|
||||
items[i].topic = topic;
|
||||
items[i].password_protected = ch.password_protected ? 1 : 0;
|
||||
items[i].max_users = ch.max_users;
|
||||
items[i].sort_order = ch.sort_order;
|
||||
auto& ac = items[i].audio;
|
||||
ac.codec = 0; // OPUS
|
||||
ac.mode = ch.audio_mode;
|
||||
ac.sample_rate = ch.audio_sample_rate;
|
||||
ac.bitrate_bps = ch.audio_bitrate_bps;
|
||||
ac.frame_ms = ch.audio_frame_ms;
|
||||
ac.application = ch.audio_application;
|
||||
ac.fec = ch.audio_fec ? 1 : 0;
|
||||
ac.expected_packet_loss = ch.audio_expected_packet_loss;
|
||||
ac.dtx = ch.audio_dtx ? 1 : 0;
|
||||
ac.complexity = ch.audio_complexity;
|
||||
ac.dred = ch.audio_dred ? 1 : 0;
|
||||
}
|
||||
out->items = items;
|
||||
out->count = channels.size();
|
||||
@@ -1756,6 +1946,7 @@ vc_result vc_client::list_users(vc_user_list* out) {
|
||||
items[i].self_deafened = u.self_deafened ? 1 : 0;
|
||||
items[i].server_muted = u.server_muted ? 1 : 0;
|
||||
items[i].server_deafened = u.server_deafened ? 1 : 0;
|
||||
items[i].voice_subscribed = u.voice_subscribed ? 1 : 0;
|
||||
}
|
||||
out->items = items;
|
||||
out->count = users.size();
|
||||
@@ -2036,6 +2227,8 @@ vc_result vc_client::authenticate_guest(const char*) { return VC_ERR_NOT_
|
||||
vc_result vc_client::authenticate_user(const char*, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::join_channel(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::leave_channel() { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::join_voice() { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::leave_voice() { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::stream_stop(uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
|
||||
@@ -46,6 +46,9 @@ struct vc_client {
|
||||
vc_result join_channel(uint32_t channel_id, const char* password);
|
||||
vc_result leave_channel();
|
||||
|
||||
vc_result join_voice();
|
||||
vc_result leave_voice();
|
||||
|
||||
vc_result stream_start(const vc_stream_desc& desc, uint32_t* out_stream_id);
|
||||
vc_result stream_stop(uint32_t stream_id);
|
||||
vc_result set_input_device(uint32_t stream_id, const char* device_id);
|
||||
@@ -161,6 +164,11 @@ struct vc_client {
|
||||
uint64_t server_session_id_{0};
|
||||
std::atomic<uint64_t> next_req_id_{1};
|
||||
|
||||
// Voice-plane subscription state. When false, the server does not relay voice frames to
|
||||
// us, and we do not wire up remote-stream decoders (sync_remote_streams is gated on this).
|
||||
// Toggled by vc_join_voice / vc_leave_voice; confirmed via VoiceSubscriptionResult.
|
||||
std::atomic<bool> voice_subscribed_{false};
|
||||
|
||||
// Keepalive: client sends a Ping every ~15s (docs/protocol.md §7) so the server's
|
||||
// last_seen stays fresh and the reaper doesn't drop us. Pong echoes the nonce, which
|
||||
// we correlate to measure RTT. The read loop's 50ms TLS timeout means it spins fast
|
||||
@@ -265,6 +273,11 @@ struct vc_client {
|
||||
// time and checked in handle_stream_announce_result / stream_stop.
|
||||
bool external_feed = false;
|
||||
|
||||
// Stream label (from vc_stream_desc.label at stream_start time). Retained so the
|
||||
// channel-update stream restart (restart_active_streams_for_channel) can re-announce
|
||||
// with the same label without the caller's involvement.
|
||||
std::string label;
|
||||
|
||||
// Reframe buffer: the AudioEngine clock is fixed at 48 kHz / 20 ms, so capture/feed
|
||||
// always delivers 960-sample frames — but the channel's frame_ms (docs/voice.md §3)
|
||||
// can be 2.5…60 ms, so the encoder needs frame_samples per call (480 @10ms, 1920 @40ms,
|
||||
@@ -344,12 +357,27 @@ struct vc_client {
|
||||
void handle_server_state(const voicecat::v1::ServerStateSnapshot& snap);
|
||||
void handle_user_event(const voicecat::v1::UserEvent& ue);
|
||||
void handle_channel_event(const voicecat::v1::ChannelEvent& ce);
|
||||
// Called from handle_channel_event when the current channel's audio config changed:
|
||||
// stop→start every active local stream so the new Opus params take effect (encoders are
|
||||
// frozen at StreamAnnounceResult time — docs/voice.md §3). capture_device_id and
|
||||
// capture_channels survive the restart (stream_stop doesn't clear them; stream_start
|
||||
// reuses the existing LocalStream entry). The server reads the updated channel config
|
||||
// on re-announce and returns new effective_audio; peers' sync_remote_streams wire up
|
||||
// fresh decoders at the new ssrc.
|
||||
void restart_active_streams_for_channel(uint32_t channel_id);
|
||||
void handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg);
|
||||
void handle_text_message(const voicecat::v1::TextMessage& msg);
|
||||
void handle_disconnect(const voicecat::v1::Disconnect& msg);
|
||||
void handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg);
|
||||
void handle_stream_announce_result(uint64_t req_id,
|
||||
const voicecat::v1::StreamAnnounceResult& msg);
|
||||
void handle_voice_subscription_result(const voicecat::v1::VoiceSubscriptionResult& msg);
|
||||
// Wire up remote-stream decoders for all users in the session model (used on voice join).
|
||||
void resync_remote_streams();
|
||||
// Tear down all remote-stream decoders and clear remote_streams_ (used on voice leave).
|
||||
void clear_all_remote_streams();
|
||||
// Stop every active local stream (used on voice leave — emits STREAM_STOPPED for each).
|
||||
void stop_all_local_streams();
|
||||
|
||||
// ── M2: UDP / media helpers ──────────────────────────────────────────────────
|
||||
// Kicks off TCP UdpBinding request; called once after a successful AuthResult.
|
||||
|
||||
@@ -26,6 +26,20 @@ std::pair<const User*, const Stream*> SessionModel::find_user_by_ssrc(uint32_t s
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
namespace {
|
||||
|
||||
void copy_channel_audio(Channel& ch, const voicecat::v1::AudioConfig& a) {
|
||||
ch.audio_sample_rate = a.sample_rate() ? a.sample_rate() : 48000;
|
||||
ch.audio_frame_ms = a.frame_ms() ? a.frame_ms() : 20;
|
||||
ch.audio_mode = static_cast<uint32_t>(a.mode());
|
||||
ch.audio_bitrate_bps = a.bitrate_bps();
|
||||
ch.audio_application = static_cast<uint32_t>(a.application());
|
||||
ch.audio_fec = a.fec();
|
||||
ch.audio_expected_packet_loss = a.expected_packet_loss();
|
||||
ch.audio_dtx = a.dtx();
|
||||
ch.audio_complexity = a.complexity();
|
||||
ch.audio_dred = a.dred();
|
||||
}
|
||||
|
||||
std::vector<Stream> copy_streams(
|
||||
const google::protobuf::RepeatedPtrField<voicecat::v1::StreamInfo>& src) {
|
||||
std::vector<Stream> out;
|
||||
@@ -62,6 +76,8 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap)
|
||||
ch.topic = pb.topic();
|
||||
ch.password_protected = pb.password_protected();
|
||||
ch.max_users = pb.max_users();
|
||||
ch.sort_order = static_cast<uint32_t>(pb.order());
|
||||
copy_channel_audio(ch, pb.audio());
|
||||
channels_.push_back(std::move(ch));
|
||||
}
|
||||
|
||||
@@ -76,6 +92,7 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap)
|
||||
u.self_deafened = pb.self_deafened();
|
||||
u.server_muted = pb.server_muted();
|
||||
u.server_deafened = pb.server_deafened();
|
||||
u.voice_subscribed = pb.voice_subscribed();
|
||||
u.streams = copy_streams(pb.streams());
|
||||
users_.push_back(std::move(u));
|
||||
}
|
||||
@@ -95,6 +112,7 @@ void SessionModel::apply_user_event(const voicecat::v1::UserEvent& ev) {
|
||||
u.self_deafened = pb.self_deafened();
|
||||
u.server_muted = pb.server_muted();
|
||||
u.server_deafened = pb.server_deafened();
|
||||
u.voice_subscribed = pb.voice_subscribed();
|
||||
u.streams = copy_streams(pb.streams());
|
||||
|
||||
auto it = std::find_if(users_.begin(), users_.end(),
|
||||
@@ -122,6 +140,8 @@ void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) {
|
||||
ch.topic = pb.topic();
|
||||
ch.password_protected = pb.password_protected();
|
||||
ch.max_users = pb.max_users();
|
||||
ch.sort_order = static_cast<uint32_t>(pb.order());
|
||||
copy_channel_audio(ch, pb.audio());
|
||||
|
||||
auto it = std::find_if(channels_.begin(), channels_.end(),
|
||||
[&](const Channel& x) { return x.id == ch.id; });
|
||||
|
||||
@@ -25,6 +25,21 @@ struct Channel {
|
||||
std::string topic;
|
||||
bool password_protected{false};
|
||||
uint32_t max_users{0};
|
||||
uint32_t sort_order{0};
|
||||
// Authoritative channel AudioConfig (docs/voice.md §3) — mirrors
|
||||
// voicecat::v1::AudioConfig field-for-field, same flat shape as Stream below. Populated
|
||||
// from ServerStateSnapshot / ChannelEvent so the channel-edit dialog can read back the
|
||||
// current config (the vc_channel read struct now carries these too).
|
||||
uint32_t audio_sample_rate{48000};
|
||||
uint32_t audio_frame_ms{20};
|
||||
uint32_t audio_mode{0}; // 0 = mono, 1 = stereo
|
||||
uint32_t audio_bitrate_bps{0};
|
||||
uint32_t audio_application{0}; // 0=VOIP, 1=AUDIO, 2=LOWDELAY
|
||||
bool audio_fec{false};
|
||||
uint32_t audio_expected_packet_loss{0};
|
||||
bool audio_dtx{false};
|
||||
uint32_t audio_complexity{0};
|
||||
bool audio_dred{false};
|
||||
};
|
||||
|
||||
struct Stream {
|
||||
@@ -57,6 +72,7 @@ struct User {
|
||||
bool self_deafened{false};
|
||||
bool server_muted{false};
|
||||
bool server_deafened{false};
|
||||
bool voice_subscribed{false};
|
||||
std::vector<Stream> streams;
|
||||
};
|
||||
|
||||
|
||||
@@ -82,6 +82,16 @@ vc_result vc_leave_channel(vc_client* c) {
|
||||
return c->leave_channel();
|
||||
}
|
||||
|
||||
vc_result vc_join_voice(vc_client* c) {
|
||||
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||
return c->join_voice();
|
||||
}
|
||||
|
||||
vc_result vc_leave_voice(vc_client* c) {
|
||||
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||
return c->leave_voice();
|
||||
}
|
||||
|
||||
vc_result vc_stream_start(vc_client* c, const vc_stream_desc* desc, uint32_t* out_stream_id) {
|
||||
if (c == nullptr || desc == nullptr) return VC_ERR_INVALID_ARG;
|
||||
return c->stream_start(*desc, out_stream_id);
|
||||
|
||||
Reference in New Issue
Block a user