/* * vccli — headless test client. * * This is the primary way the protocol is exercised and verified (see AGENTS.md): driving * two vccli instances against a real voicecat-server. */ #include #include #include #include #include #include #include #include #include #include "voicecat.h" namespace { std::atomic g_stop{false}; void on_sigint(int) { g_stop.store(true); } struct Stats { std::atomic auth_done{false}; std::atomic auth_ok{false}; // Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the // TOFU gate (VC_EVENT_SERVER_IDENTITY below). vccli has no interactive prompt, so it // trusts-on-first-connect unconditionally (prints the fingerprint for visibility). vc_client* client{nullptr}; // Async result tracking. Generic results are used by every moderation/admin/channel // request; account-list is its own event. We count generic results so callers can wait // for a new one even if several arrived earlier. std::atomic generic_result_count{0}; std::atomic last_result{0}; std::atomic last_code{0}; std::string last_message; std::mutex last_message_mu; std::atomic account_list_received{false}; }; void on_event(void* user, const vc_event* ev) { auto* st = static_cast(user); switch (ev->type) { case VC_EVENT_CONNECTION_STATE: std::printf("[state] -> %d\n", static_cast(ev->connection_state)); break; case VC_EVENT_SERVER_IDENTITY: std::printf("[tofu] status=%u fingerprint=%s (auto-trusting — vccli has no " "interactive prompt)\n", ev->u32a, ev->text ? ev->text : ""); vc_confirm_server_identity(st->client, 1); break; case VC_EVENT_AUTH_RESULT: st->auth_ok = (ev->result == VC_OK); st->auth_done = true; std::printf("[auth] ok=%d user_id=%u %s\n", st->auth_ok.load(), ev->user_id, ev->text ? ev->text : ""); break; case VC_EVENT_CHANNEL_LIST: std::printf("[channel] list updated\n"); break; case VC_EVENT_USER_JOINED: std::printf("[user] joined: %s (id=%u, channel=%u)\n", ev->text ? ev->text : "?", ev->user_id, ev->channel_id); break; case VC_EVENT_USER_LEFT: std::printf("[user] left: id=%u\n", ev->user_id); break; case VC_EVENT_USER_UPDATED: std::printf("[user] updated: id=%u\n", ev->user_id); break; case VC_EVENT_STREAM_STARTED: std::printf("[voice] stream started: user_id=%u stream_id=%u\n", ev->user_id, ev->stream_id); break; case VC_EVENT_STREAM_STOPPED: std::printf("[voice] stream stopped: user_id=%u stream_id=%u\n", ev->user_id, ev->stream_id); break; case VC_EVENT_TEXT_MESSAGE: std::printf("[text] from=%u: %s\n", ev->user_id, ev->text ? ev->text : ""); break; case VC_EVENT_TALK_STATE: std::printf("[voice] talk state: user_id=%u stream_id=%u talking=%u\n", ev->user_id, ev->stream_id, ev->u32a); break; case VC_EVENT_JOIN_RESULT: std::printf("[join] result=%d channel=%u %s\n", ev->result, ev->channel_id, ev->text ? ev->text : ""); break; case VC_EVENT_GENERIC_RESULT: { std::lock_guard lk(st->last_message_mu); st->last_result.store(ev->result); st->last_code.store(ev->u32a); st->last_message = ev->text ? ev->text : ""; st->generic_result_count.fetch_add(1); std::printf("[result] rc=%d code=%u: %s\n", ev->result, ev->u32a, ev->text ? ev->text : ""); break; } case VC_EVENT_ACCOUNT_LIST: st->account_list_received.store(true); std::printf("[accounts] list received (use vc_list_accounts in code to inspect)\n"); break; case VC_EVENT_ERROR: std::fprintf(stderr, "[error] rc=%d: %s\n", ev->result, ev->text ? ev->text : ""); break; case VC_EVENT_DISCONNECTED: std::printf("[disconnected] rc=%d: %s\n", ev->result, ev->text ? ev->text : ""); g_stop.store(true); break; default: break; } } bool wait_until(std::atomic& flag, int timeout_ms) { auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); while (!flag.load()) { if (std::chrono::steady_clock::now() >= deadline) return false; std::this_thread::sleep_for(std::chrono::milliseconds(20)); } return true; } vc_result wait_generic_result(Stats& st, int baseline_count, int timeout_ms) { auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); while (st.generic_result_count.load() <= baseline_count) { if (std::chrono::steady_clock::now() >= deadline) return VC_ERR_TIMEOUT; std::this_thread::sleep_for(std::chrono::milliseconds(20)); } return static_cast(st.last_result.load()); } // Parse "true"/"false"/"1"/"0"/"yes"/"no" (case-insensitive). bool parse_bool(const char* s, bool* out) { if (!s || !*s) return false; std::string v = s; for (auto& ch : v) ch = static_cast(std::tolower(static_cast(ch))); if (v == "1" || v == "true" || v == "yes" || v == "on") { *out = true; return true; } if (v == "0" || v == "false" || v == "no" || v == "off") { *out = false; return true; } return false; } bool parse_u32(const char* s, uint32_t* out, const char* label) { if (!s || !*s) { std::fprintf(stderr, "missing value for %s\n", label); return false; } try { *out = static_cast(std::stoul(s)); return true; } catch (...) { std::fprintf(stderr, "invalid value for %s: %s\n", label, s); return false; } } bool parse_u16(const char* s, uint16_t* out, const char* label) { if (!s || !*s) { std::fprintf(stderr, "missing value for %s\n", label); return false; } try { int v = std::stoi(s); if (v < 0 || v > 65535) throw std::out_of_range("port"); *out = static_cast(v); return true; } catch (...) { std::fprintf(stderr, "invalid value for %s: %s\n", label, s); return false; } } void print_usage() { std::printf( "usage: vccli [--host H] [--port P] [--nick NAME | --username U --password P]\n" " [--channel ID] [--voice] [--mute] [--text MSG] [--list-devices]\n" " [--input-device ID] [--input-mode vad|ptt] [--share-screen-audio]\n" " [--wait-ms N]\n" " [--kick USER_ID [--reason REASON]]\n" " [--ban USER_ID [--reason REASON] [--ban-expires-ms MS]]\n" " [--move USER_ID --to-channel ID]\n" " [--server-mute USER_ID] [--server-unmute USER_ID]\n" " [--server-deafen USER_ID] [--server-undeafen USER_ID]\n" " [--set-permission USER_ID --perm-admin B --perm-kick B --perm-ban B\n" " --perm-move B --perm-create-temp B --perm-admin-accounts B]\n" " [--create-channel --new-channel-name NAME [--new-channel-topic TOPIC]\n" " [--new-channel-parent ID] [--new-channel-password PASS]\n" " [--new-channel-max-users N]]\n" " [--edit-channel --channel-id ID --new-channel-name NAME\n" " [--new-channel-topic TOPIC] [--new-channel-parent ID]\n" " [--new-channel-password PASS] [--new-channel-max-users N]]\n" " [--delete-channel --channel-id ID]\n" " [--create-account USER PASS] [--reset-password USER PASS]\n" " [--delete-account USER] [--list-accounts]\n" " [--self-mute] [--self-deafen]\n" "\n" "Connection / identity:\n" " --host H server host (default 127.0.0.1)\n" " --port P server TCP port (default 8384)\n" " --nick NAME guest nickname (default vccli-test)\n" " --username U authenticate as registered user U\n" " --password P password for --username\n" " --channel ID channel to join after auth (default 1, Lobby)\n" " --wait-ms N timeout for async result events (default 5000)\n" "\n" "Voice / devices:\n" " --voice start a MIC stream and stay connected until Ctrl+C\n" " --mute start with the mic muted (only meaningful with --voice)\n" " --list-devices print input/output devices (vc_list_devices) and exit\n" " --input-device ID use device ID (from --list-devices) for the MIC stream\n" " --input-mode vad|ptt send-side input gate mode (default vad)\n" " --share-screen-audio also start a SCREEN_AUDIO stream (WASAPI loopback on Windows)\n" " --self-mute mute own mic before/without voice mode\n" " --self-deafen deafen self before/without voice mode\n" "\n" "Text:\n" " --text MSG send MSG to the channel, then exit\n" "\n" "Moderation (require permission):\n" " --kick USER_ID [--reason REASON]\n" " --ban USER_ID [--reason REASON] [--ban-expires-ms MS] (0 = permanent)\n" " --move USER_ID --to-channel ID\n" " --server-mute USER_ID, --server-unmute USER_ID\n" " --server-deafen USER_ID, --server-undeafen USER_ID\n" "\n" "Permissions (require permission):\n" " --set-permission USER_ID --perm-admin B --perm-kick B --perm-ban B\n" " --perm-move B --perm-create-temp B --perm-admin-accounts B\n" " B = 0|1|true|false|yes|no\n" "\n" "Channel management (require permission):\n" " --create-channel --new-channel-name NAME ...\n" " --edit-channel --channel-id ID --new-channel-name NAME ...\n" " --delete-channel --channel-id ID\n" "\n" "Account management (require permission):\n" " --create-account USER PASS\n" " --reset-password USER PASS\n" " --delete-account USER\n" " --list-accounts\n" "\n" "While --voice is running, stdin accepts: \"ptt on\", \"ptt off\", \"mode vad\",\n" "\"mode ptt\" (PTT key state can't be held interactively in a headless CLI, so it's\n" "toggled via these commands instead).\n"); } void print_device_list(vc_client* c, vc_device_kind kind, const char* label) { vc_device_list dl{}; vc_result r = vc_list_devices(c, kind, &dl); std::printf("%s devices: vc_list_devices -> %d (%s), count=%zu\n", label, r, vc_result_string(r), dl.count); for (size_t i = 0; i < dl.count; ++i) { std::printf(" [%s] %s%s\n", dl.items[i].id, dl.items[i].name, dl.items[i].is_default ? " (default)" : ""); } vc_free_device_list(&dl); } // Background stdin reader for --voice mode: the most portable way to drive PTT/mode toggles // interactively from a headless CLI (no SIGUSR1 equivalent on Windows). void run_stdin_commands(vc_client* c, std::atomic& stop) { std::string line; while (!stop.load() && std::getline(std::cin, line)) { if (line == "ptt on") { vc_set_push_to_talk(c, 1); std::printf("[ptt] on\n"); } else if (line == "ptt off") { vc_set_push_to_talk(c, 0); std::printf("[ptt] off\n"); } else if (line == "mode vad") { vc_set_input_mode(c, VC_INPUT_VOICE_ACTIVATION); std::printf("[mode] vad\n"); } else if (line == "mode ptt") { vc_set_input_mode(c, VC_INPUT_PUSH_TO_TALK); std::printf("[mode] ptt\n"); } else if (!line.empty()) { std::fprintf(stderr, "unknown command: %s\n", line.c_str()); } } } // Issue a request that produces VC_EVENT_GENERIC_RESULT and wait for it. // Returns the result code from the event. using RequestFn = std::function; vc_result run_generic_request(Stats& st, int timeout_ms, RequestFn fn, const char* label) { int before = st.generic_result_count.load(); vc_result r = fn(); std::printf("%s -> %d (%s)\n", label, r, vc_result_string(r)); if (r != VC_OK) return r; vc_result event_rc = wait_generic_result(st, before, timeout_ms); if (event_rc == VC_ERR_TIMEOUT) { std::fprintf(stderr, "%s: timed out waiting for result event\n", label); return VC_ERR_TIMEOUT; } return event_rc; } } // namespace int main(int argc, char** argv) { // MSVCRT/MinGW treat _IOLBF as full buffering for non-console streams, so go unbuffered // to keep output visible immediately when piped to a file or another process. std::setvbuf(stdout, nullptr, _IONBF, 0); std::string host = "127.0.0.1"; uint16_t port = 8384; std::string nick = "vccli-test"; std::string username; std::string password; bool have_username = false; bool have_password = false; uint32_t channel_id = 1; bool voice_mode = false; bool start_muted = false; bool self_mute = false; bool self_deafen = false; std::string text_msg; bool have_text = false; bool list_devices = false; std::string input_device; bool have_input_device = false; vc_input_mode input_mode = VC_INPUT_VOICE_ACTIVATION; bool share_screen_audio = false; int wait_ms = 5000; // Moderation bool do_kick = false; uint32_t kick_user_id = 0; std::string kick_reason; bool do_ban = false; uint32_t ban_user_id = 0; std::string ban_reason; uint64_t ban_expires_ms = 0; bool do_move = false; uint32_t move_user_id = 0; uint32_t move_channel_id = 0; bool do_server_mute = false; bool do_server_unmute = false; bool do_server_deafen = false; bool do_server_undeafen = false; uint32_t server_mute_user_id = 0; int server_mute_muted = 0; int server_mute_deafened = 0; // Permissions bool do_set_permission = false; uint32_t perm_user_id = 0; vc_permissions perms{}; // Channel CRUD bool do_create_channel = false; bool do_edit_channel = false; bool do_delete_channel = false; uint32_t delete_channel_id = 0; vc_channel_info channel_info{}; // Account management bool do_create_account = false; bool do_reset_password = false; bool do_delete_account = false; bool do_list_accounts = false; std::string acct_user; std::string acct_pass; for (int i = 1; i < argc; ++i) { std::string a = argv[i]; auto next = [&]() -> std::string { return (i + 1 < argc) ? argv[++i] : std::string(); }; if (a == "--host") host = next(); else if (a == "--port") { if (!parse_u16(next().c_str(), &port, "--port")) return 1; } else if (a == "--nick") nick = next(); else if (a == "--username") { username = next(); have_username = true; } else if (a == "--password") { password = next(); have_password = true; } else if (a == "--channel") { if (!parse_u32(next().c_str(), &channel_id, "--channel")) return 1; } else if (a == "--voice") voice_mode = true; else if (a == "--mute") start_muted = true; else if (a == "--self-mute") self_mute = true; else if (a == "--self-deafen") self_deafen = true; else if (a == "--text") { text_msg = next(); have_text = true; } else if (a == "--list-devices") list_devices = true; else if (a == "--input-device") { input_device = next(); have_input_device = true; } else if (a == "--input-mode") { std::string m = next(); if (m == "ptt") input_mode = VC_INPUT_PUSH_TO_TALK; else if (m != "vad") { std::fprintf(stderr, "--input-mode must be vad|ptt\n"); return 1; } } else if (a == "--share-screen-audio") share_screen_audio = true; else if (a == "--wait-ms") { std::string v = next(); try { wait_ms = std::stoi(v); } catch (...) { std::fprintf(stderr, "invalid --wait-ms: %s\n", v.c_str()); return 1; } if (wait_ms < 0) wait_ms = 0; } // Moderation else if (a == "--kick") { do_kick = true; if (!parse_u32(next().c_str(), &kick_user_id, "--kick")) return 1; } else if (a == "--reason") { std::string r = next(); if (do_kick) kick_reason = r; else if (do_ban) ban_reason = r; else { std::fprintf(stderr, "--reason without --kick or --ban\n"); return 1; } } else if (a == "--ban") { do_ban = true; if (!parse_u32(next().c_str(), &ban_user_id, "--ban")) return 1; } else if (a == "--ban-expires-ms") { std::string v = next(); try { ban_expires_ms = static_cast(std::stoull(v)); } catch (...) { std::fprintf(stderr, "invalid --ban-expires-ms: %s\n", v.c_str()); return 1; } } else if (a == "--move") { do_move = true; if (!parse_u32(next().c_str(), &move_user_id, "--move")) return 1; } else if (a == "--to-channel") { if (!parse_u32(next().c_str(), &move_channel_id, "--to-channel")) return 1; } else if (a == "--server-mute") { do_server_mute = true; server_mute_muted = 1; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-mute")) return 1; } else if (a == "--server-unmute") { do_server_unmute = true; server_mute_muted = 0; server_mute_deafened = 0; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-unmute")) return 1; } else if (a == "--server-deafen") { do_server_deafen = true; server_mute_muted = 1; server_mute_deafened = 1; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-deafen")) return 1; } else if (a == "--server-undeafen") { do_server_undeafen = true; server_mute_muted = 0; server_mute_deafened = 0; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-undeafen")) return 1; } // Permissions else if (a == "--set-permission") { do_set_permission = true; if (!parse_u32(next().c_str(), &perm_user_id, "--set-permission")) return 1; } else if (a == "--perm-admin") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.is_admin = b; } else if (a == "--perm-kick") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_kick = b; } else if (a == "--perm-ban") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_ban = b; } else if (a == "--perm-move") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_move_users = b; } else if (a == "--perm-create-temp") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_create_temp_channel = b; } else if (a == "--perm-admin-accounts") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_admin_accounts = b; } // Channel CRUD else if (a == "--create-channel") do_create_channel = true; else if (a == "--edit-channel") do_edit_channel = true; else if (a == "--delete-channel") { do_delete_channel = true; if (!parse_u32(next().c_str(), &delete_channel_id, "--delete-channel")) return 1; } else if (a == "--channel-id") { if (!parse_u32(next().c_str(), &channel_info.id, "--channel-id")) return 1; } else if (a == "--new-channel-name") channel_info.name = next().c_str(); else if (a == "--new-channel-topic") channel_info.topic = next().c_str(); else if (a == "--new-channel-parent") { if (!parse_u32(next().c_str(), &channel_info.parent_id, "--new-channel-parent")) return 1; } else if (a == "--new-channel-password") { channel_info.password = next().c_str(); channel_info.password_protected = 1; } else if (a == "--new-channel-max-users") { if (!parse_u32(next().c_str(), &channel_info.max_users, "--new-channel-max-users")) return 1; } // Account management else if (a == "--create-account") { do_create_account = true; acct_user = next(); acct_pass = next(); } else if (a == "--reset-password") { do_reset_password = true; acct_user = next(); acct_pass = next(); } else if (a == "--delete-account") { do_delete_account = true; acct_user = next(); } else if (a == "--list-accounts") do_list_accounts = true; else if (a == "--help" || a == "-h") { print_usage(); return 0; } else { std::fprintf(stderr, "unknown flag: %s\n", a.c_str()); print_usage(); return 1; } } // Validate auth mode. if (have_username != have_password) { std::fprintf(stderr, "--username and --password must be used together\n"); return 1; } // Validate moderation flags that need extra args. if (do_move && move_channel_id == 0) { std::fprintf(stderr, "--move requires --to-channel\n"); return 1; } if ((do_server_mute || do_server_unmute || do_server_deafen || do_server_undeafen) && server_mute_user_id == 0) { std::fprintf(stderr, "server mute/deafen requires a user id\n"); return 1; } // Normalize server mute/deafen into a single request. bool do_server_mute_request = do_server_mute || do_server_unmute || do_server_deafen || do_server_undeafen; // Validate channel CRUD. if (do_create_channel && (!channel_info.name || !*channel_info.name)) { std::fprintf(stderr, "--create-channel requires --new-channel-name\n"); return 1; } if (do_edit_channel && (channel_info.id == 0 || !channel_info.name || !*channel_info.name)) { std::fprintf(stderr, "--edit-channel requires --channel-id and --new-channel-name\n"); return 1; } std::printf("vccli — VoiceCat test client (core %s, protocol v%d)\n", vc_version_string(), VOICECAT_PROTOCOL_VERSION); std::signal(SIGINT, on_sigint); vc_config cfg{}; cfg.client_name = "vccli"; cfg.client_version = vc_version_string(); cfg.log_level = VC_LOG_INFO; Stats st; vc_callbacks cb{}; cb.on_event = on_event; cb.user = &st; vc_client* c = vc_client_create(&cfg, cb); if (c == nullptr) { std::fprintf(stderr, "failed to create client\n"); return 1; } st.client = c; if (list_devices) { // Device enumeration works pre-connect (no server needed) — see docs/voice.md. print_device_list(c, VC_DEVICE_INPUT, "input"); print_device_list(c, VC_DEVICE_OUTPUT, "output"); vc_client_destroy(c); return 0; } vc_result r = vc_connect(c, host.c_str(), port); std::printf("vc_connect(%s:%u) -> %d (%s)\n", host.c_str(), port, r, vc_result_string(r)); if (r != VC_OK) { vc_client_destroy(c); return 1; } if (have_username) { r = vc_authenticate_user(c, username.c_str(), password.c_str()); std::printf("vc_authenticate_user(%s) -> %d (%s)\n", username.c_str(), r, vc_result_string(r)); } else { r = vc_authenticate_guest(c, nick.c_str()); std::printf("vc_authenticate_guest(%s) -> %d (%s)\n", nick.c_str(), r, vc_result_string(r)); } if (!wait_until(st.auth_done, 8000) || !st.auth_ok.load()) { std::fprintf(stderr, "authentication failed or timed out\n"); vc_disconnect(c); vc_client_destroy(c); return 1; } if (channel_id != 1) { r = vc_join_channel(c, channel_id, nullptr); std::printf("vc_join_channel(%u) -> %d (%s)\n", channel_id, r, vc_result_string(r)); } if (have_text) { r = vc_send_text(c, VC_TEXT_CHANNEL, channel_id, text_msg.c_str()); std::printf("vc_send_text -> %d (%s)\n", r, vc_result_string(r)); std::this_thread::sleep_for(std::chrono::milliseconds(300)); // let the relay land } // Moderation / admin requests (executed in a sensible order if multiple are given). bool mod_request_error = false; if (self_mute || self_deafen) { r = vc_set_self_mute(c, self_mute ? 1 : 0, self_deafen ? 1 : 0); std::printf("vc_set_self_mute -> %d (%s)\n", r, vc_result_string(r)); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_kick) { r = run_generic_request(st, wait_ms, [&]() { return vc_kick_user(c, kick_user_id, kick_reason.c_str()); }, "vc_kick_user"); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_ban) { r = run_generic_request(st, wait_ms, [&]() { return vc_ban_user(c, ban_user_id, ban_reason.c_str(), ban_expires_ms); }, "vc_ban_user"); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_move) { r = run_generic_request(st, wait_ms, [&]() { return vc_move_user(c, move_user_id, move_channel_id); }, "vc_move_user"); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_server_mute_request) { r = run_generic_request(st, wait_ms, [&]() { return vc_set_server_mute(c, server_mute_user_id, server_mute_muted, server_mute_deafened); }, "vc_set_server_mute"); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_set_permission) { r = run_generic_request(st, wait_ms, [&]() { return vc_set_permission(c, perm_user_id, &perms); }, "vc_set_permission"); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_create_channel) { r = run_generic_request(st, wait_ms, [&]() { return vc_create_channel(c, &channel_info); }, "vc_create_channel"); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_edit_channel) { r = run_generic_request(st, wait_ms, [&]() { return vc_edit_channel(c, &channel_info); }, "vc_edit_channel"); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_delete_channel) { r = run_generic_request(st, wait_ms, [&]() { return vc_delete_channel(c, delete_channel_id); }, "vc_delete_channel"); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_create_account) { r = run_generic_request(st, wait_ms, [&]() { return vc_create_account(c, acct_user.c_str(), acct_pass.c_str()); }, "vc_create_account"); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_reset_password) { r = run_generic_request(st, wait_ms, [&]() { return vc_reset_password(c, acct_user.c_str(), acct_pass.c_str()); }, "vc_reset_password"); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_delete_account) { r = run_generic_request(st, wait_ms, [&]() { return vc_delete_account(c, acct_user.c_str()); }, "vc_delete_account"); if (r != VC_OK) mod_request_error = true; } if (!mod_request_error && do_list_accounts) { st.account_list_received.store(false); r = vc_list_accounts(c); std::printf("vc_list_accounts -> %d (%s)\n", r, vc_result_string(r)); if (r != VC_OK) { mod_request_error = true; } else { if (!wait_until(st.account_list_received, wait_ms)) { std::fprintf(stderr, "vc_list_accounts: timed out waiting for list event\n"); mod_request_error = true; } } } if (mod_request_error && !voice_mode) { vc_disconnect(c); vc_client_destroy(c); return 1; } if (voice_mode) { // Give the async UDP binding handshake (TCP UdpBinding -> ack -> plaintext // bootstrap packet) a moment to land before announcing a stream. std::this_thread::sleep_for(std::chrono::milliseconds(500)); if (start_muted) vc_set_self_mute(c, 1, 0); r = vc_set_input_mode(c, input_mode); std::printf("vc_set_input_mode(%s) -> %d (%s)\n", input_mode == VC_INPUT_PUSH_TO_TALK ? "ptt" : "vad", r, vc_result_string(r)); vc_stream_desc desc{}; desc.kind = VC_STREAM_MIC; desc.device_id = have_input_device ? input_device.c_str() : nullptr; desc.label = "Microphone"; uint32_t stream_id = 0; r = vc_stream_start(c, &desc, &stream_id); std::printf("vc_stream_start -> %d (%s), stream_id=%u\n", r, vc_result_string(r), stream_id); if (have_input_device && r == VC_OK) { r = vc_set_input_device(c, stream_id, input_device.c_str()); std::printf("vc_set_input_device -> %d (%s)\n", r, vc_result_string(r)); } uint32_t screen_stream_id = 0; if (share_screen_audio) { vc_stream_desc sdesc{}; sdesc.kind = VC_STREAM_SCREEN_AUDIO; sdesc.label = "Desktop audio"; r = vc_stream_start(c, &sdesc, &screen_stream_id); std::printf("vc_stream_start(SCREEN_AUDIO) -> %d (%s), stream_id=%u\n", r, vc_result_string(r), screen_stream_id); } std::thread stdin_thread(run_stdin_commands, c, std::ref(g_stop)); std::printf("voice mode: streaming mic, listening for remote streams. Ctrl+C to stop.\n" "(type \"ptt on\"/\"ptt off\"/\"mode vad\"/\"mode ptt\" to drive the input gate)\n"); while (!g_stop.load()) { std::this_thread::sleep_for(std::chrono::milliseconds(200)); } if (share_screen_audio) vc_stream_stop(c, screen_stream_id); vc_stream_stop(c, stream_id); // Unblock the stdin reader and let it exit before the client is torn down. if (stdin_thread.joinable()) { #if defined(_WIN32) // std::getline on a console stdin blocks indefinitely; on Windows there's no // portable way to interrupt it from another thread, so detach rather than join — // process exit reclaims the thread. stdin_thread.detach(); #else stdin_thread.join(); #endif } } vc_disconnect(c); vc_client_destroy(c); std::printf("ok\n"); return mod_request_error ? 1 : 0; }