/* * vccli — headless test client. * * This is the primary way the protocol is exercised and verified from M1 onward (see * AGENTS.md). Each milestone's exit criterion is demonstrated by 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 // M4 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}; }; 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_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_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; } void print_usage() { std::printf( "usage: vccli [--host H] [--port P] [--nick NAME] [--channel ID]\n" " [--voice] [--mute] [--text MSG] [--list-devices]\n" " [--input-device ID] [--input-mode vad|ptt] [--share-screen-audio]\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" " --channel ID channel to join after auth (default 1, Lobby)\n" " --voice start a MIC stream and stay connected until Ctrl+C\n" " --mute start with the mic muted (only meaningful with --voice)\n" " --text MSG send MSG to the channel, then exit\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" "\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()); } } } } // 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"; uint32_t channel_id = 1; bool voice_mode = false; bool start_muted = 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; 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") port = static_cast(std::stoi(next())); else if (a == "--nick") nick = next(); else if (a == "--channel") channel_id = static_cast(std::stoul(next())); else if (a == "--voice") voice_mode = true; else if (a == "--mute") start_muted = 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 == "--help" || a == "-h") { print_usage(); return 0; } else { std::fprintf(stderr, "unknown flag: %s\n", a.c_str()); print_usage(); 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; } 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 } 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 0; }