fix(M2): wire vc_client's real voice plane through the C ABI, not just raw sockets

test_m2_voice passed against raw BSD sockets, but vc_client::stream_start/stop,
UDP binding, and capture/recv were still VC_ERR_NOT_IMPLEMENTED stubs -- meaning
vccli and any GUI client still couldn't actually talk. Implements the real
client-side UDP-binding handshake, media key derivation, capture->encode->seal->
send and recv->open->decode->playback paths, plus server-side StreamInfo
broadcast so peers learn about each other's streams via sync_remote_streams().

Adds test_voice_client_abi (two real vc_client instances, not raw sockets) and
vccli --voice/--mute/--text flags, manually verified live between two instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 02:12:50 +02:00
parent 694494a5be
commit c693cab35c
13 changed files with 1017 additions and 30 deletions

View File

@@ -4,10 +4,11 @@ Auto-loaded each session. This is the **map**: build commands, architecture at a
where everything is. For the *working method* read [`AGENTS.md`](AGENTS.md); for *what's done where everything is. For the *working method* read [`AGENTS.md`](AGENTS.md); for *what's done
and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/). and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/).
> **One-line status:** M2 voice/media plane is complete and verified (`ctest --preset m1-dev` > **One-line status:** M2 voice/media plane is complete and verified through the real client
> green — 9/9 tests including UDP relay, AEAD, Opus round-trip, and the M2 exit criterion > library, not just a raw-socket harness (`ctest --test-dir build/m1-dev` green — 10/10 tests,
> `test_m2_voice`). Next up is **M3** (multi-stream, per-channel tuning, listener-side NR). > including `test_voice_client_abi` driving two real `vc_client`s end-to-end, and `vccli
> See [`PROGRESS.md`](PROGRESS.md). > --voice` manually verified live). Next up is **M3** (multi-stream, per-channel tuning,
> listener-side NR). See [`PROGRESS.md`](PROGRESS.md).
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control) VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control)
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives + UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives

View File

@@ -10,12 +10,25 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ Where we left off / next action
- **Done:** **M2 — voice, single stream** ✓ complete (2026-06-16). - **Done:** **M2 — voice, single stream** ✓ complete (2026-06-16), now genuinely satisfied
`ctest --preset m1-dev` — all **9/9 tests** green including the new M2 exit criterion through the real client library, not just `test_m2_voice`'s raw-socket harness.
(`test_m2_voice`): two headless clients encrypt Opus frames via ChaCha20-Poly1305, bind `vc_stream_start/stop`, UDP binding, capture→encode→seal→send, and recv→open→decode→
UDP sockets, and the server SFU relay re-encrypts + forwards frames. playback were all `VC_ERR_NOT_IMPLEMENTED` stubs in `core/src/core/client.cpp` even after
`test_m2_voice` went green — meaning `vccli`/any GUI client still couldn't actually talk.
Implemented for real this session, plus server-side `StreamInfo` broadcast
(`SessionRegistry::set_user_stream/clear_user_stream``UserEvent::UPDATED`) so a second
client's `sync_remote_streams()` learns about a peer's stream without polling.
`ctest --test-dir build/m1-dev`**10/10 tests** green, including the new
`test_voice_client_abi` (two real `vc_client` instances, not raw sockets, drive the full
UDP-binding → stream-announce → cross-client `STREAM_STARTED`/`STOPPED` event path).
`vccli --voice` (new flag, alongside `--host/--port/--nick/--channel/--mute/--text`) was
manually verified live: two instances see each other's mic-stream start/stop in real time.
Note: `test_m1_integration` and `test_m2_voice` have a pre-existing intermittent flake on
Windows in their cleanup paths (thread-join / SQLite-file-handle release race, unrelated to
this session's changes) — rerun in isolation if one fails standalone in the full suite.
- **Next:** **M3 — multi-stream & per-channel tuning** (screen audio, listener-side per-user - **Next:** **M3 — multi-stream & per-channel tuning** (screen audio, listener-side per-user
NR, jitter buffer stats API). See `docs/roadmap.md §M3`. NR, jitter buffer stats API — note `voicecat.h` currently exposes no stats getter beyond
`on_level`'s RMS meter, so a stats API needs new C ABI surface). See `docs/roadmap.md §M3`.
--- ---
@@ -94,7 +107,36 @@ to the second client; B receives ≥ 25 frames and all decrypt correctly. Passes
- [x] `test_voice_frame` — header round-trip, big-endian layout, binding packet format. - [x] `test_voice_frame` — header round-trip, big-endian layout, binding packet format.
- [x] `test_media_aead` — seal/open round-trip, anti-replay, tamper detection, multi-packet sequence. - [x] `test_media_aead` — seal/open round-trip, anti-replay, tamper detection, multi-packet sequence.
- [x] `test_opus_codec` — encode/decode round-trip energy check (within 3 dB), PLC, frame-samples helper. - [x] `test_opus_codec` — encode/decode round-trip energy check (within 3 dB), PLC, frame-samples helper.
- [x] `test_m2_voice` — M2 exit criterion. Verified green 2026-06-16. - [x] `test_m2_voice` — M2 exit criterion (raw-socket harness). Verified green 2026-06-16.
**Follow-up (same day):** the above made `test_m2_voice` pass, but `vc_client`'s public voice
methods were still stubs — the *actual* M2 exit criterion ("two vccli/early-GUI clients talk")
wasn't met. Closed the gap:
- [x] `core/src/core/client.cpp` — real `stream_start`/`stream_stop`/`set_self_mute`/
`set_remote_stream`; UDP-binding handshake (`start_udp_binding`/`handle_udp_binding_ack`/
`finish_udp_binding`); media key derivation from `tls_` (RFC 5705 exporter); `run_udp_recv`
(AEAD-open → `JitterBuffer::Frame``audio_engine_.push_recv_frame`); `on_capture_frame`
(encode → seal → `sendto`); `sync_remote_streams` (diffs a `User` proto's `streams` against
`remote_streams_`, wiring up `OpusDecoder`s and emitting `STREAM_STARTED`/`STOPPED`).
`set_input_device`/`set_input_mode`/`set_push_to_talk`/`list_devices` remain
`VC_ERR_NOT_IMPLEMENTED` — no device-enumeration backend yet; scoped to M3 (VAD/PTT).
- [x] `core/src/session/session.cpp/h``SessionModel::find_user`, `find_user_by_ssrc`,
`Stream{stream_id, ssrc, kind, label, sample_rate, frame_ms}`.
- [x] `server/src/conn_session.cpp/h``handle_stream_announce`/`handle_stream_stop` now
broadcast via `SessionRegistry::set_user_stream`/`clear_user_stream``UserEvent::UPDATED`.
- [x] `server/src/session_registry.cpp/h``set_user_stream`/`clear_user_stream` (mutate a
user's `StreamInfo` list, return the updated `User` proto for broadcast).
- [x] `tests/test_voice_client_abi.cpp` — drives two real `vc_client` instances through
`vc_connect`/`vc_authenticate_guest`/`vc_stream_start`/`vc_stream_stop`; asserts client B
observes client A's `STREAM_STARTED`/`STOPPED` events. Verified green 2026-06-16.
- [x] `tools/vccli/src/main.cpp` — argv parsing (`--host/--port/--nick/--channel/--voice/
--mute/--text`); `--voice` starts a MIC stream and blocks on SIGINT, printing `on_event`
callbacks live (unbuffered stdout — MinGW/MSVCRT treat `_IOLBF` as full buffering for
non-console streams). Dropped the originally-planned `--voice-loopback` and the
`tx=N rx=M lost=K jitter=J` stats line: `voicecat.h` exposes no PCM-injection hook or
jitter/loss stats getter publicly, only `on_event` + `on_level` (RMS). Manually verified:
two `vccli --voice` instances see each other's stream start in real time.
--- ---

View File

@@ -22,7 +22,9 @@
static void close_sock(sock_t s) { ::close(s); } static void close_sock(sock_t s) { ::close(s); }
#endif #endif
#include <algorithm>
#include <chrono> #include <chrono>
#include <cstring>
#include "protocol/protocol.h" #include "protocol/protocol.h"
@@ -107,6 +109,8 @@ vc_result vc_client::disconnect() {
io_fd_.store(-1, std::memory_order_release); io_fd_.store(-1, std::memory_order_release);
} }
teardown_voice();
if (io_thread_.joinable()) io_thread_.join(); if (io_thread_.joinable()) io_thread_.join();
return VC_OK; return VC_OK;
} }
@@ -114,6 +118,7 @@ vc_result vc_client::disconnect() {
// ── io_thread_ entry point ──────────────────────────────────────────────────── // ── io_thread_ entry point ────────────────────────────────────────────────────
void vc_client::run_io(std::string host, uint16_t port) { void vc_client::run_io(std::string host, uint16_t port) {
udp_host_ = host;
set_state(VC_STATE_CONNECTING); set_state(VC_STATE_CONNECTING);
// ── TCP connect ────────────────────────────────────────────────────────── // ── TCP connect ──────────────────────────────────────────────────────────
@@ -220,6 +225,7 @@ void vc_client::run_io(std::string host, uint16_t port) {
} }
} }
teardown_voice();
tls_.reset(); tls_.reset();
close_sock(sock); close_sock(sock);
io_fd_.store(-1); io_fd_.store(-1);
@@ -234,6 +240,28 @@ cleanup:
return; return;
} }
void vc_client::teardown_voice() {
udp_stop_.store(true, std::memory_order_release);
int ufd = udp_fd_.load(std::memory_order_acquire);
if (ufd != -1) {
close_sock(static_cast<sock_t>(ufd));
udp_fd_.store(-1, std::memory_order_release);
}
if (udp_thread_.joinable()) udp_thread_.join();
udp_ready_.store(false, std::memory_order_release);
audio_engine_.stop();
local_stream_active_.store(false, std::memory_order_release);
local_stream_pending_ = false;
local_encoder_.destroy();
media_send_crypto_.reset();
media_recv_crypto_.reset();
std::lock_guard lk(remote_streams_mu_);
remote_streams_.clear();
}
void vc_client::drain_sends() { void vc_client::drain_sends() {
while (true) { while (true) {
std::vector<uint8_t> frame; std::vector<uint8_t> frame;
@@ -282,6 +310,12 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
case voicecat::v1::Envelope::kDisconnect: case voicecat::v1::Envelope::kDisconnect:
handle_disconnect(env.disconnect()); handle_disconnect(env.disconnect());
break; break;
case voicecat::v1::Envelope::kUdpBinding:
handle_udp_binding_ack(env.udp_binding());
break;
case voicecat::v1::Envelope::kStreamAnnounceResult:
handle_stream_announce_result(env.stream_announce_result());
break;
case voicecat::v1::Envelope::kPong: case voicecat::v1::Envelope::kPong:
break; // ignore keepalive responses break; // ignore keepalive responses
default: default:
@@ -290,6 +324,8 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
} }
void vc_client::handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t /*req_id*/) { void vc_client::handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t /*req_id*/) {
server_udp_port_ = static_cast<uint16_t>(msg.udp_port());
// Server acknowledged our ClientHello. Now send AuthRequest (or queue it). // Server acknowledged our ClientHello. Now send AuthRequest (or queue it).
std::optional<PendingAuth> auth; std::optional<PendingAuth> auth;
{ {
@@ -321,6 +357,12 @@ void vc_client::handle_auth_result(const voicecat::v1::AuthResult& msg) {
server_session_id_ = msg.session_id(); server_session_id_ = msg.session_id();
ev.user_id = self_user_id_; ev.user_id = self_user_id_;
set_state(VC_STATE_CONNECTED); set_state(VC_STATE_CONNECTED);
const std::string& tok = msg.udp_token();
if (tok.size() == udp_token_.size()) {
std::memcpy(udp_token_.data(), tok.data(), udp_token_.size());
start_udp_binding();
}
} else { } else {
ev.text = msg.error().c_str(); ev.text = msg.error().c_str();
} }
@@ -329,6 +371,7 @@ void vc_client::handle_auth_result(const voicecat::v1::AuthResult& msg) {
void vc_client::handle_server_state(const voicecat::v1::ServerStateSnapshot& snap) { void vc_client::handle_server_state(const voicecat::v1::ServerStateSnapshot& snap) {
session_model_.apply_snapshot(snap); session_model_.apply_snapshot(snap);
for (const auto& u : snap.users()) sync_remote_streams(u);
vc_event ev{}; vc_event ev{};
ev.type = VC_EVENT_CHANNEL_LIST; ev.type = VC_EVENT_CHANNEL_LIST;
emit(ev); emit(ev);
@@ -350,14 +393,26 @@ void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) {
ev.type = VC_EVENT_USER_JOINED; ev.type = VC_EVENT_USER_JOINED;
ev.text = nick.c_str(); ev.text = nick.c_str();
emit(ev); emit(ev);
sync_remote_streams(user);
break; break;
case voicecat::v1::UserEvent::LEFT: case voicecat::v1::UserEvent::LEFT: {
ev.type = VC_EVENT_USER_LEFT; ev.type = VC_EVENT_USER_LEFT;
emit(ev); emit(ev);
std::lock_guard lk(remote_streams_mu_);
for (auto it = remote_streams_.begin(); it != remote_streams_.end();) {
if (it->second.first == user.id()) {
audio_engine_.remove_stream(it->first);
it = remote_streams_.erase(it);
} else {
++it;
}
}
break; break;
}
case voicecat::v1::UserEvent::UPDATED: case voicecat::v1::UserEvent::UPDATED:
ev.type = VC_EVENT_USER_UPDATED; ev.type = VC_EVENT_USER_UPDATED;
emit(ev); emit(ev);
sync_remote_streams(user);
break; break;
default: default:
break; break;
@@ -454,19 +509,323 @@ vc_result vc_client::send_text(vc_text_scope scope, uint32_t target_id, const ch
return VC_OK; return VC_OK;
} }
// ── Stubs for audio/device (M2) ─────────────────────────────────────────────── // ── M2: UDP binding ───────────────────────────────────────────────────────────
vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) { void vc_client::start_udp_binding() {
return VC_ERR_NOT_IMPLEMENTED; voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
req.mutable_udp_binding()->set_udp_token(
reinterpret_cast<const char*>(udp_token_.data()), udp_token_.size());
queue_envelope(req);
} }
vc_result vc_client::stream_stop(uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
void vc_client::handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg) {
if (!msg.ack()) return;
finish_udp_binding();
}
void vc_client::finish_udp_binding() {
if (udp_ready_.load(std::memory_order_acquire)) return;
if (!tls_ || server_udp_port_ == 0) return;
media_send_crypto_ = voicecat::crypto::SodiumMediaCrypto::derive_send(*tls_, true);
media_recv_crypto_ = voicecat::crypto::SodiumMediaCrypto::derive_recv(*tls_, true);
if (!media_send_crypto_ || !media_recv_crypto_) {
emit_error(VC_ERR_CRYPTO, "failed to derive media keys");
return;
}
sock_t s = ::socket(AF_INET, SOCK_DGRAM, 0);
if (s == kBadSock) {
emit_error(VC_ERR_IO, "udp socket() failed");
return;
}
struct addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_DGRAM;
struct addrinfo* res = nullptr;
std::string port_str = std::to_string(server_udp_port_);
if (getaddrinfo(udp_host_.c_str(), port_str.c_str(), &hints, &res) != 0 || !res) {
close_sock(s);
emit_error(VC_ERR_IO, "udp hostname resolution failed");
return;
}
auto* sin = reinterpret_cast<sockaddr_in*>(res->ai_addr);
udp_dest_addr_ = sin->sin_addr.s_addr;
udp_dest_port_ = sin->sin_port;
auto pkt = voicecat::net::make_udp_binding_packet(udp_token_.data(), udp_token_.size());
::sendto(s, reinterpret_cast<const char*>(pkt.data()), static_cast<int>(pkt.size()), 0,
res->ai_addr, static_cast<int>(res->ai_addrlen));
freeaddrinfo(res);
udp_fd_.store(static_cast<int>(s), std::memory_order_release);
udp_stop_.store(false, std::memory_order_release);
udp_ready_.store(true, std::memory_order_release);
udp_thread_ = std::thread([this] { run_udp_recv(); });
}
void vc_client::run_udp_recv() {
int fd = udp_fd_.load(std::memory_order_acquire);
if (fd == -1) return;
#ifdef _WIN32
DWORD tv = 200;
setsockopt(static_cast<sock_t>(fd), SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<const char*>(&tv), sizeof(tv));
#else
struct timeval tv{0, 200000};
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
#endif
std::vector<uint8_t> buf(2048);
while (!udp_stop_.load(std::memory_order_acquire)) {
int n = static_cast<int>(::recv(static_cast<sock_t>(fd), reinterpret_cast<char*>(buf.data()),
static_cast<int>(buf.size()), 0));
if (n < static_cast<int>(voicecat::net::kVoiceHeaderSize)) continue;
voicecat::net::VoiceFrame hdr{};
if (!voicecat::net::parse_header(buf.data(), static_cast<size_t>(n), hdr)) continue;
if (hdr.type != voicecat::net::kFrameVoice) continue;
if (!media_recv_crypto_) continue;
size_t sealed_len = static_cast<size_t>(n) - voicecat::net::kVoiceHeaderSize;
std::vector<uint8_t> plain(sealed_len);
long plain_len = media_recv_crypto_->open(
buf.data() + voicecat::net::kVoiceHeaderSize, sealed_len, buf.data(),
voicecat::net::kVoiceHeaderSize, plain.data(), plain.size());
if (plain_len < 0) continue;
plain.resize(static_cast<size_t>(plain_len));
voicecat::audio::JitterBuffer::Frame jf;
jf.seq = hdr.seq;
jf.timestamp = hdr.timestamp;
jf.fec_present = (hdr.flags & voicecat::net::kFlagFecPresent) != 0;
jf.payload = std::move(plain);
audio_engine_.push_recv_frame(hdr.ssrc, std::move(jf));
}
}
void vc_client::on_capture_frame(const int16_t* pcm, int samples) {
if (!local_stream_active_.load(std::memory_order_acquire)) return;
if (self_mic_muted_.load(std::memory_order_acquire)) return;
if (!media_send_crypto_) return;
int fd = udp_fd_.load(std::memory_order_acquire);
if (fd == -1) return;
uint8_t opus_buf[1500];
int opus_len = local_encoder_.encode(pcm, samples, opus_buf, sizeof(opus_buf));
if (opus_len <= 0) return;
voicecat::net::VoiceFrame hdr;
hdr.ssrc = local_ssrc_;
hdr.seq = static_cast<uint16_t>(media_send_crypto_->peek_send_counter());
hdr.timestamp = local_timestamp_;
local_timestamp_ += static_cast<uint32_t>(samples);
uint8_t header_bytes[voicecat::net::kVoiceHeaderSize];
voicecat::net::serialize_header(hdr, header_bytes);
uint8_t sealed[1500];
long sealed_len = media_send_crypto_->seal(opus_buf, static_cast<size_t>(opus_len),
header_bytes, voicecat::net::kVoiceHeaderSize,
sealed, sizeof(sealed));
if (sealed_len < 0) return;
std::vector<uint8_t> pkt(voicecat::net::kVoiceHeaderSize + static_cast<size_t>(sealed_len));
std::memcpy(pkt.data(), header_bytes, voicecat::net::kVoiceHeaderSize);
std::memcpy(pkt.data() + voicecat::net::kVoiceHeaderSize, sealed,
static_cast<size_t>(sealed_len));
sockaddr_in dest{};
dest.sin_family = AF_INET;
dest.sin_addr.s_addr = udp_dest_addr_;
dest.sin_port = udp_dest_port_;
::sendto(static_cast<sock_t>(fd), reinterpret_cast<const char*>(pkt.data()),
static_cast<int>(pkt.size()), 0, reinterpret_cast<sockaddr*>(&dest), sizeof(dest));
}
void vc_client::ensure_audio_running() {
if (audio_engine_.running()) return;
voicecat::audio::AudioParams p;
p.sample_rate = 48000;
p.channels = 1;
p.frame_ms = 20;
audio_engine_.start(p, [this](const int16_t* pcm, int samples) { on_capture_frame(pcm, samples); });
}
void vc_client::sync_remote_streams(const voicecat::v1::User& user) {
if (user.id() == self_user_id_) return;
std::vector<uint32_t> current_ssrcs;
for (const auto& si : user.streams()) current_ssrcs.push_back(si.ssrc());
std::vector<std::pair<uint32_t, uint32_t>> newly_added; // {ssrc, stream_id}
{
std::lock_guard lk(remote_streams_mu_);
for (const auto& si : user.streams()) {
uint32_t ssrc = si.ssrc();
if (remote_streams_.count(ssrc)) continue;
remote_streams_[ssrc] = {user.id(), si.stream_id()};
voicecat::codec::OpusParams p;
p.sample_rate = si.audio().sample_rate() ? si.audio().sample_rate() : 48000;
p.frame_ms = si.audio().frame_ms() ? si.audio().frame_ms() : 20;
audio_engine_.init_recv_stream(ssrc, p);
audio_engine_.set_stream_mute(ssrc, self_deafened_.load(std::memory_order_acquire));
newly_added.emplace_back(ssrc, si.stream_id());
}
for (auto it = remote_streams_.begin(); it != remote_streams_.end();) {
if (it->second.first == user.id() &&
std::find(current_ssrcs.begin(), current_ssrcs.end(), it->first) ==
current_ssrcs.end()) {
uint32_t stream_id = it->second.second;
audio_engine_.remove_stream(it->first);
it = remote_streams_.erase(it);
vc_event ev{};
ev.type = VC_EVENT_STREAM_STOPPED;
ev.user_id = user.id();
ev.stream_id = stream_id;
emit(ev);
} else {
++it;
}
}
}
if (!newly_added.empty()) ensure_audio_running();
for (auto& [ssrc, stream_id] : newly_added) {
(void)ssrc;
vc_event ev{};
ev.type = VC_EVENT_STREAM_STARTED;
ev.user_id = user.id();
ev.stream_id = stream_id;
emit(ev);
}
}
// ── M2: stream / device control ──────────────────────────────────────────────
vc_result vc_client::stream_start(const vc_stream_desc& desc, uint32_t* out_stream_id) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
if (local_stream_active_.load(std::memory_order_acquire) || local_stream_pending_)
return VC_ERR_ALREADY;
uint32_t sid = next_local_stream_id_++;
local_stream_id_ = sid;
local_stream_pending_ = true;
if (out_stream_id) *out_stream_id = sid;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
auto* ann = req.mutable_stream_announce();
ann->set_kind(desc.kind == VC_STREAM_SCREEN_AUDIO ? voicecat::v1::STREAM_SCREEN_AUDIO
: desc.kind == VC_STREAM_AUX_DEVICE ? voicecat::v1::STREAM_AUX_DEVICE
: voicecat::v1::STREAM_MIC);
if (desc.label) ann->set_label(desc.label);
auto* audio = ann->mutable_requested_audio();
audio->set_sample_rate(48000);
audio->set_bitrate_bps(24000);
audio->set_frame_ms(20);
audio->set_fec(true);
queue_envelope(req);
return VC_OK;
}
void vc_client::handle_stream_announce_result(const voicecat::v1::StreamAnnounceResult& msg) {
if (!local_stream_pending_) return;
local_stream_pending_ = false;
if (!msg.ok()) {
emit_error(VC_ERR_PROTOCOL, msg.error().c_str());
return;
}
local_ssrc_ = msg.ssrc();
local_timestamp_ = 0;
voicecat::codec::OpusParams p;
const auto& eff = msg.effective_audio();
p.sample_rate = eff.sample_rate() ? eff.sample_rate() : 48000;
p.bitrate_bps = eff.bitrate_bps() ? eff.bitrate_bps() : 24000;
p.frame_ms = eff.frame_ms() ? eff.frame_ms() : 20;
p.fec = eff.fec();
local_frame_samples_ = static_cast<uint16_t>(voicecat::codec::opus_frame_samples(p));
if (!local_encoder_.init(p)) {
emit_error(VC_ERR_AUDIO, "opus encoder init failed");
return;
}
ensure_audio_running();
local_stream_active_.store(true, std::memory_order_release);
vc_event ev{};
ev.type = VC_EVENT_STREAM_STARTED;
ev.user_id = self_user_id_;
ev.stream_id = local_stream_id_;
emit(ev);
}
vc_result vc_client::stream_stop(uint32_t stream_id) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
if (!local_stream_active_.load(std::memory_order_acquire) || stream_id != local_stream_id_)
return VC_ERR_INVALID_ARG;
local_stream_active_.store(false, std::memory_order_release);
local_encoder_.destroy();
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
req.mutable_stream_stop()->set_stream_id(stream_id);
queue_envelope(req);
vc_event ev{};
ev.type = VC_EVENT_STREAM_STOPPED;
ev.user_id = self_user_id_;
ev.stream_id = stream_id;
emit(ev);
return VC_OK;
}
vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_input_mode(vc_input_mode) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_input_mode(vc_input_mode) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_push_to_talk(bool) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_push_to_talk(bool) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_self_mute(bool, bool) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) { vc_result vc_client::set_self_mute(bool mic_muted, bool deafened) {
return VC_ERR_NOT_IMPLEMENTED; if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
self_mic_muted_.store(mic_muted, std::memory_order_release);
self_deafened_.store(deafened, std::memory_order_release);
std::lock_guard lk(remote_streams_mu_);
for (auto& [ssrc, info] : remote_streams_) {
(void)info;
audio_engine_.set_stream_mute(ssrc, deafened);
} }
return VC_OK;
}
vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain,
bool muted, bool /*noise_reduction*/) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
const auto* user = session_model_.find_user(user_id);
if (!user) return VC_ERR_INVALID_ARG;
for (const auto& s : user->streams) {
if (s.stream_id == stream_id) {
audio_engine_.set_stream_gain(s.ssrc, gain);
audio_engine_.set_stream_mute(s.ssrc, muted);
return VC_OK;
}
}
return VC_ERR_INVALID_ARG;
}
vc_result vc_client::list_devices(vc_device_kind, vc_device_list* out) { vc_result vc_client::list_devices(vc_device_kind, vc_device_list* out) {
out->items = nullptr; out->items = nullptr;
out->count = 0; out->count = 0;

View File

@@ -15,9 +15,14 @@
#include <optional> #include <optional>
#include <string> #include <string>
#include <thread> #include <thread>
#include <unordered_map>
#include <utility>
#include <vector> #include <vector>
#include "audio/audio_engine.h"
#include "codec/opus_codec.h"
#include "crypto/crypto.h" #include "crypto/crypto.h"
#include "net/voice_frame.h"
#include "protocol/envelope.h" #include "protocol/envelope.h"
#include "protocol/protocol.h" #include "protocol/protocol.h"
#include "session/session.h" #include "session/session.h"
@@ -103,6 +108,41 @@ struct vc_client {
// Client-side session model // Client-side session model
voicecat::session::SessionModel session_model_; voicecat::session::SessionModel session_model_;
// ── M2: UDP / media plane ────────────────────────────────────────────────────
std::array<uint8_t, 16> udp_token_{};
uint16_t server_udp_port_{0};
std::string udp_host_;
std::atomic<int> udp_fd_{-1};
std::thread udp_thread_;
std::atomic<bool> udp_stop_{false};
std::atomic<bool> udp_ready_{false};
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> media_send_crypto_;
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> media_recv_crypto_;
voicecat::audio::AudioEngine audio_engine_;
voicecat::codec::OpusEncoder local_encoder_;
std::atomic<bool> local_stream_active_{false};
bool local_stream_pending_{false}; // announced, waiting for result
uint32_t local_stream_id_{0};
uint32_t local_ssrc_{0};
uint32_t next_local_stream_id_{1};
uint32_t local_timestamp_{0};
uint16_t local_frame_samples_{960};
// ssrc → (user_id, stream_id) for remote streams already wired into audio_engine_.
mutable std::mutex remote_streams_mu_;
std::unordered_map<uint32_t, std::pair<uint32_t, uint32_t>> remote_streams_;
// Local UDP destination (server media endpoint), resolved once during binding.
uint32_t udp_dest_addr_{0}; // network byte order
uint16_t udp_dest_port_{0}; // network byte order
std::atomic<bool> self_mic_muted_{false};
std::atomic<bool> self_deafened_{false};
// ── io_thread_ entry point ────────────────────────────────────────────────── // ── io_thread_ entry point ──────────────────────────────────────────────────
void run_io(std::string host, uint16_t port); void run_io(std::string host, uint16_t port);
@@ -114,6 +154,26 @@ struct vc_client {
void handle_user_event(const voicecat::v1::UserEvent& ue); void handle_user_event(const voicecat::v1::UserEvent& ue);
void handle_text_message(const voicecat::v1::TextMessage& msg); void handle_text_message(const voicecat::v1::TextMessage& msg);
void handle_disconnect(const voicecat::v1::Disconnect& msg); void handle_disconnect(const voicecat::v1::Disconnect& msg);
void handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg);
void handle_stream_announce_result(const voicecat::v1::StreamAnnounceResult& msg);
// ── M2: UDP / media helpers ──────────────────────────────────────────────────
// Kicks off TCP UdpBinding request; called once after a successful AuthResult.
void start_udp_binding();
// Opens the UDP socket, sends the plaintext bootstrap packet, starts udp_thread_.
void finish_udp_binding();
// udp_thread_ entry point: recv loop, AEAD-open, decode, push to audio_engine_.
void run_udp_recv();
// capture_cb passed to audio_engine_.start(): encode + seal + send one frame.
void on_capture_frame(const int16_t* pcm, int samples);
// Inspect a User proto's streams and wire up any new remote ssrc into audio_engine_,
// emitting VC_EVENT_STREAM_STARTED/STOPPED as streams appear/disappear.
void sync_remote_streams(const voicecat::v1::User& user);
// Starts audio_engine_ (capture+playback) if not already running.
void ensure_audio_running();
// Joins udp_thread_, stops audio_engine_, clears media crypto/remote-stream state.
// Safe to call multiple times. Called both from run_io()'s cleanup and disconnect().
void teardown_voice();
// ── Helpers (io_thread_ and caller threads) ───────────────────────────────── // ── Helpers (io_thread_ and caller threads) ─────────────────────────────────
// Queue an encoded envelope to be sent on io_thread_. // Queue an encoded envelope to be sent on io_thread_.

View File

@@ -14,8 +14,36 @@ const User* SessionModel::find_user(uint32_t id) const {
return nullptr; return nullptr;
} }
std::pair<const User*, const Stream*> SessionModel::find_user_by_ssrc(uint32_t ssrc) const {
for (auto& u : users_) {
for (auto& s : u.streams) {
if (s.ssrc == ssrc) return {&u, &s};
}
}
return {nullptr, nullptr};
}
#ifdef VOICECAT_HAS_NET #ifdef VOICECAT_HAS_NET
namespace {
std::vector<Stream> copy_streams(
const google::protobuf::RepeatedPtrField<voicecat::v1::StreamInfo>& src) {
std::vector<Stream> out;
out.reserve(src.size());
for (const auto& pb : src) {
Stream s;
s.stream_id = pb.stream_id();
s.ssrc = pb.ssrc();
s.kind = static_cast<int>(pb.kind());
s.label = pb.label();
s.sample_rate = pb.audio().sample_rate() ? pb.audio().sample_rate() : 48000;
s.frame_ms = pb.audio().frame_ms() ? pb.audio().frame_ms() : 20;
out.push_back(std::move(s));
}
return out;
}
} // namespace
void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap) { void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap) {
channels_.clear(); channels_.clear();
for (const auto& pb : snap.channels()) { for (const auto& pb : snap.channels()) {
@@ -32,6 +60,7 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap)
u.nickname = pb.nickname(); u.nickname = pb.nickname();
u.is_guest = pb.is_guest(); u.is_guest = pb.is_guest();
u.channel_id = pb.channel_id(); u.channel_id = pb.channel_id();
u.streams = copy_streams(pb.streams());
users_.push_back(std::move(u)); users_.push_back(std::move(u));
} }
} }
@@ -46,6 +75,7 @@ void SessionModel::apply_user_event(const voicecat::v1::UserEvent& ev) {
u.nickname = pb.nickname(); u.nickname = pb.nickname();
u.is_guest = pb.is_guest(); u.is_guest = pb.is_guest();
u.channel_id = pb.channel_id(); u.channel_id = pb.channel_id();
u.streams = copy_streams(pb.streams());
auto it = std::find_if(users_.begin(), users_.end(), auto it = std::find_if(users_.begin(), users_.end(),
[&](const User& x) { return x.id == u.id; }); [&](const User& x) { return x.id == u.id; });

View File

@@ -9,6 +9,7 @@
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
#ifdef VOICECAT_HAS_NET #ifdef VOICECAT_HAS_NET
@@ -30,6 +31,8 @@ struct Stream {
uint32_t ssrc{0}; uint32_t ssrc{0};
int kind{0}; int kind{0};
std::string label; std::string label;
uint32_t sample_rate{48000};
uint32_t frame_ms{20};
}; };
struct User { struct User {
@@ -48,6 +51,10 @@ class SessionModel {
const Channel* find_channel(uint32_t id) const; const Channel* find_channel(uint32_t id) const;
const User* find_user(uint32_t id) const; const User* find_user(uint32_t id) const;
// Find the user that owns a given media ssrc, and the matching Stream entry.
// Returns {nullptr, nullptr} if not found.
std::pair<const User*, const Stream*> find_user_by_ssrc(uint32_t ssrc) const;
#ifdef VOICECAT_HAS_NET #ifdef VOICECAT_HAS_NET
void apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap); void apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap);
void apply_user_event(const voicecat::v1::UserEvent& ev); void apply_user_event(const voicecat::v1::UserEvent& ev);

View File

@@ -82,6 +82,10 @@ void ConnSession::on_frame(std::vector<uint8_t> frame) {
if (st == State::Authenticated) if (st == State::Authenticated)
handle_stream_announce(env.request_id(), env.stream_announce()); handle_stream_announce(env.request_id(), env.stream_announce());
break; break;
case voicecat::v1::Envelope::kStreamStop:
if (st == State::Authenticated)
handle_stream_stop(env.stream_stop());
break;
default: default:
break; break;
} }
@@ -346,7 +350,37 @@ void ConnSession::handle_stream_announce(uint64_t req_id,
if (eff->bitrate_bps() == 0) eff->set_bitrate_bps(24000); if (eff->bitrate_bps() == 0) eff->set_bitrate_bps(24000);
if (eff->frame_ms() == 0) eff->set_frame_ms(20); if (eff->frame_ms() == 0) eff->set_frame_ms(20);
announced_stream_id_ = res->stream_id();
voicecat::v1::StreamInfo info;
info.set_stream_id(res->stream_id());
info.set_ssrc(ssrc);
info.set_kind(msg.kind());
*info.mutable_audio() = *eff;
info.set_label(msg.label());
send_envelope(env); send_envelope(env);
auto updated = registry_->set_user_stream(user_id_.load(), info);
if (updated) {
auto bcast = make_env();
auto* ue = bcast.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
*ue->mutable_user() = *updated;
registry_->broadcast(bcast, session_id_);
}
}
void ConnSession::handle_stream_stop(const voicecat::v1::StreamStop& msg) {
auto updated = registry_->clear_user_stream(user_id_.load(), msg.stream_id());
if (msg.stream_id() == announced_stream_id_) announced_stream_id_ = 0;
if (updated) {
auto bcast = make_env();
auto* ue = bcast.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
*ue->mutable_user() = *updated;
registry_->broadcast(bcast, session_id_);
}
} }
void ConnSession::send_disconnect_and_close(uint32_t code, const std::string& reason) { void ConnSession::send_disconnect_and_close(uint32_t code, const std::string& reason) {

View File

@@ -86,6 +86,7 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
void handle_ping(const voicecat::v1::Ping& msg); void handle_ping(const voicecat::v1::Ping& msg);
void handle_udp_binding(uint64_t req_id, const voicecat::v1::UdpBinding& msg); void handle_udp_binding(uint64_t req_id, const voicecat::v1::UdpBinding& msg);
void handle_stream_announce(uint64_t req_id, const voicecat::v1::StreamAnnounce& msg); void handle_stream_announce(uint64_t req_id, const voicecat::v1::StreamAnnounce& msg);
void handle_stream_stop(const voicecat::v1::StreamStop& msg);
void finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64_t req_id); void finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64_t req_id);
void finish_password_auth(const std::string& username, const std::string& password, void finish_password_auth(const std::string& username, const std::string& password,
@@ -119,6 +120,9 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
mutable std::mutex crypto_mu_; mutable std::mutex crypto_mu_;
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send_crypto_; std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send_crypto_;
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv_crypto_; std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv_crypto_;
// M2: locally-announced stream (single MIC stream per session for now)
uint32_t announced_stream_id_{0};
}; };
} // namespace voicecat::server } // namespace voicecat::server

View File

@@ -166,6 +166,37 @@ std::vector<std::shared_ptr<ConnSession>> SessionRegistry::find_channel_sessions
return result; return result;
} }
std::optional<voicecat::v1::User> SessionRegistry::set_user_stream(
uint32_t user_id, const voicecat::v1::StreamInfo& info) {
std::unique_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return std::nullopt;
auto* streams = it->second.proto.mutable_streams();
for (int i = 0; i < streams->size(); ++i) {
if (streams->Get(i).stream_id() == info.stream_id()) {
*streams->Mutable(i) = info;
return it->second.proto;
}
}
*streams->Add() = info;
return it->second.proto;
}
std::optional<voicecat::v1::User> SessionRegistry::clear_user_stream(uint32_t user_id,
uint32_t stream_id) {
std::unique_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return std::nullopt;
auto* streams = it->second.proto.mutable_streams();
for (int i = 0; i < streams->size(); ++i) {
if (streams->Get(i).stream_id() == stream_id) {
streams->erase(streams->begin() + i);
break;
}
}
return it->second.proto;
}
uint32_t SessionRegistry::user_channel(uint32_t user_id) const { uint32_t SessionRegistry::user_channel(uint32_t user_id) const {
std::shared_lock lk(mu_); std::shared_lock lk(mu_);
auto it = users_.find(user_id); auto it = users_.find(user_id);

View File

@@ -14,6 +14,7 @@
#include <atomic> #include <atomic>
#include <cstdint> #include <cstdint>
#include <memory> #include <memory>
#include <optional>
#include <shared_mutex> #include <shared_mutex>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
@@ -95,6 +96,15 @@ class SessionRegistry {
// Assign an SSRC for a new stream. Returns the assigned SSRC. // Assign an SSRC for a new stream. Returns the assigned SSRC.
uint32_t assign_ssrc(uint64_t session_id); uint32_t assign_ssrc(uint64_t session_id);
// Add/replace a stream entry on a user (called when StreamAnnounce succeeds).
// Returns the updated User proto for broadcasting, or nullopt if user not found.
std::optional<voicecat::v1::User> set_user_stream(uint32_t user_id,
const voicecat::v1::StreamInfo& info);
// Remove a stream entry from a user (called on StreamStop). Returns the updated
// User proto for broadcasting, or nullopt if user not found.
std::optional<voicecat::v1::User> clear_user_stream(uint32_t user_id, uint32_t stream_id);
// Get all sessions in a channel except the one excluded (for SFU relay). // Get all sessions in a channel except the one excluded (for SFU relay).
std::vector<std::shared_ptr<ConnSession>> find_channel_sessions( std::vector<std::shared_ptr<ConnSession>> find_channel_sessions(
uint32_t channel_id, uint64_t exclude_session_id = 0) const; uint32_t channel_id, uint64_t exclude_session_id = 0) const;

View File

@@ -67,4 +67,13 @@ if(VOICECAT_USE_VCPKG_DEPS)
target_include_directories(test_m2_voice PRIVATE ${VC_TEST_INTERNAL_INCLUDES}) target_include_directories(test_m2_voice PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m2_voice COMMAND test_m2_voice) add_test(NAME m2_voice COMMAND test_m2_voice)
set_tests_properties(m2_voice PROPERTIES TIMEOUT 120) set_tests_properties(m2_voice PROPERTIES TIMEOUT 120)
# Same exit criterion, but through the real C ABI (vc_client), not raw sockets —
# proves stream_start/stop/UDP-binding in core/src/core/client.cpp actually work.
add_executable(test_voice_client_abi test_voice_client_abi.cpp)
target_link_libraries(test_voice_client_abi PRIVATE voicecat::server)
target_compile_features(test_voice_client_abi PRIVATE cxx_std_20)
target_include_directories(test_voice_client_abi PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME voice_client_abi COMMAND test_voice_client_abi)
set_tests_properties(voice_client_abi PROPERTIES TIMEOUT 60)
endif() endif()

View File

@@ -0,0 +1,244 @@
/*
* test_voice_client_abi — M2 exit criterion, exercised through the real C ABI.
*
* Unlike test_m2_voice.cpp (which drives raw BSD sockets to exercise the wire format),
* this test drives two actual vc_client instances end-to-end: vc_connect, vc_authenticate_guest,
* vc_stream_start/stop. It proves the client-side voice plane in core/src/core/client.cpp
* (UDP binding, StreamAnnounce, SessionModel propagation) is wired up for real — not just
* the raw-socket test harness.
*/
#include <cstdio>
#ifdef VOICECAT_HAS_NET
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
// ── Event tracking ────────────────────────────────────────────────────────────
struct StreamEvent {
bool started; // true = STARTED, false = STOPPED
uint32_t user_id;
uint32_t stream_id;
};
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
std::vector<StreamEvent> stream_events;
const char* label{nullptr};
bool disconnected{false};
};
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_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
if (!s->auth_ok) std::fprintf(stderr, "[%s] AUTH FAILED: %s\n",
s->label ? s->label : "?", ev->text ? ev->text : "(no msg)");
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_STREAM_STARTED:
s->stream_events.push_back({true, ev->user_id, ev->stream_id});
break;
case VC_EVENT_STREAM_STOPPED:
s->stream_events.push_back({false, ev->user_id, ev->stream_id});
break;
case VC_EVENT_ERROR:
std::fprintf(stderr, "[%s] ERROR rc=%d: %s\n",
s->label ? s->label : "?", ev->result, ev->text ? ev->text : "");
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
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); });
}
// ── Test harness ──────────────────────────────────────────────────────────────
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
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; // OS picks the TCP port
cfg.media_port = 0; // OS picks the UDP port
cfg.server_name = "VoiceCat-VoiceAbiTest";
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 become ready within 10s\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
std::printf("voice_client_abi: server ready on :%u\n", port);
// ── Client A: guest "VoiceA" ───────────────────────────────────────────────
EventStore evA;
evA.label = "clientA";
vc_callbacks cbA{on_event, nullptr, &evA};
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
vc_client* clientA = vc_client_create(&cfgA, cbA);
CHECK(clientA != nullptr);
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(clientA, "VoiceA") == VC_OK);
CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
// ── Client B: guest "VoiceB" ───────────────────────────────────────────────
EventStore evB;
evB.label = "clientB";
vc_callbacks cbB{on_event, nullptr, &evB};
vc_config cfgB{"test-clientB", "0.1", VC_LOG_OFF};
vc_client* clientB = vc_client_create(&cfgB, cbB);
CHECK(clientB != nullptr);
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(clientB, "VoiceB") == VC_OK);
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
uint32_t a_uid = 0;
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
// Both guests land in channel 1 (Lobby) automatically; give the async UDP
// binding handshake (TCP UdpBinding -> ack -> plaintext bootstrap packet) a
// moment to complete on both clients before announcing a stream.
std::this_thread::sleep_for(std::chrono::milliseconds(500));
// ── A announces a MIC stream via the real C ABI ──────────────────────────
vc_stream_desc desc{};
desc.kind = VC_STREAM_MIC;
desc.device_id = nullptr;
desc.label = "mic";
uint32_t a_stream_id = 0;
CHECK(vc_stream_start(clientA, &desc, &a_stream_id) == VC_OK);
// A learns its own stream started.
bool a_self_started = wait_for(evA, [&](EventStore& s) {
for (auto& e : s.stream_events)
if (e.started && e.user_id == a_uid) return true;
return false;
}, 5000);
CHECK(a_self_started);
// B learns (via UserEvent::UPDATED -> SessionModel -> sync_remote_streams)
// that A started a stream — this is the cross-client signaling path that
// was missing while stream_start()/stream_announce_result were stubs.
bool b_saw_a_started = wait_for(evB, [&](EventStore& s) {
for (auto& e : s.stream_events)
if (e.started && e.user_id == a_uid) return true;
return false;
}, 5000);
CHECK(b_saw_a_started);
// ── A stops the stream ────────────────────────────────────────────────────
CHECK(vc_stream_stop(clientA, a_stream_id) == VC_OK);
bool a_self_stopped = wait_for(evA, [&](EventStore& s) {
for (auto& e : s.stream_events)
if (!e.started && e.user_id == a_uid) return true;
return false;
}, 5000);
CHECK(a_self_stopped);
bool b_saw_a_stopped = wait_for(evB, [&](EventStore& s) {
for (auto& e : s.stream_events)
if (!e.started && e.user_id == a_uid) return true;
return false;
}, 5000);
CHECK(b_saw_a_stopped);
// ── Cleanup ───────────────────────────────────────────────────────────────
vc_disconnect(clientA);
vc_disconnect(clientB);
vc_client_destroy(clientA);
vc_client_destroy(clientB);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("voice_client_abi: all checks passed\n");
return 0;
}
std::printf("voice_client_abi: %d failure(s)\n", g_failures);
return 1;
}
#else // !VOICECAT_HAS_NET
int main() {
std::printf("voice_client_abi: SKIP (VOICECAT_HAS_NET not defined)\n");
return 0;
}
#endif // VOICECAT_HAS_NET

View File

@@ -3,32 +3,139 @@
* *
* This is the primary way the protocol is exercised and verified from M1 onward (see * 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 * AGENTS.md). Each milestone's exit criterion is demonstrated by driving two vccli
* instances against a real voicecat-server. Today it just shows the C ABI is linkable. * instances against a real voicecat-server.
*/ */
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdio> #include <cstdio>
#include <cstring>
#include <string>
#include <thread>
#include "voicecat.h" #include "voicecat.h"
namespace { namespace {
void on_event(void* /*user*/, const vc_event* ev) { std::atomic<bool> g_stop{false};
std::printf("[event] type=%d state=%d result=%d text=%s\n", ev->type, ev->connection_state, void on_sigint(int) { g_stop.store(true); }
ev->result, ev->text ? ev->text : "");
struct Stats {
std::atomic<bool> auth_done{false};
std::atomic<bool> auth_ok{false};
};
void on_event(void* user, const vc_event* ev) {
auto* st = static_cast<Stats*>(user);
switch (ev->type) {
case VC_EVENT_CONNECTION_STATE:
std::printf("[state] -> %d\n", static_cast<int>(ev->connection_state));
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_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<bool>& 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]\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");
} }
} // namespace } // namespace
int main(int argc, char** argv) { 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;
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<uint16_t>(std::stoi(next()));
else if (a == "--nick") nick = next();
else if (a == "--channel") channel_id = static_cast<uint32_t>(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 == "--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(), std::printf("vccli — VoiceCat test client (core %s, protocol v%d)\n", vc_version_string(),
VOICECAT_PROTOCOL_VERSION); VOICECAT_PROTOCOL_VERSION);
std::signal(SIGINT, on_sigint);
vc_config cfg{}; vc_config cfg{};
cfg.client_name = "vccli"; cfg.client_name = "vccli";
cfg.client_version = vc_version_string(); cfg.client_version = vc_version_string();
cfg.log_level = VC_LOG_INFO; cfg.log_level = VC_LOG_INFO;
Stats st;
vc_callbacks cb{}; vc_callbacks cb{};
cb.on_event = on_event; cb.on_event = on_event;
cb.user = &st;
vc_client* c = vc_client_create(&cfg, cb); vc_client* c = vc_client_create(&cfg, cb);
if (c == nullptr) { if (c == nullptr) {
@@ -36,11 +143,60 @@ int main(int argc, char** argv) {
return 1; return 1;
} }
// M0: demonstrate the call surface. These return VC_ERR_NOT_IMPLEMENTED for now. vc_result r = vc_connect(c, host.c_str(), port);
const char* host = (argc > 1) ? argv[1] : "127.0.0.1"; std::printf("vc_connect(%s:%u) -> %d (%s)\n", host.c_str(), port, r, vc_result_string(r));
vc_result r = vc_connect(c, host, 8384); if (r != VC_OK) {
std::printf("vc_connect(%s:8384) -> %d (%s)\n", host, r, vc_result_string(r)); 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);
vc_stream_desc desc{};
desc.kind = VC_STREAM_MIC;
desc.device_id = 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);
std::printf("voice mode: streaming mic, listening for remote streams. Ctrl+C to stop.\n");
while (!g_stop.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
vc_stream_stop(c, stream_id);
}
vc_disconnect(c);
vc_client_destroy(c); vc_client_destroy(c);
std::printf("ok\n"); std::printf("ok\n");
return 0; return 0;