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:
@@ -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
|
||||||
|
|||||||
54
PROGRESS.md
54
PROGRESS.md
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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_.
|
||||||
|
|||||||
@@ -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; });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
244
tests/test_voice_client_abi.cpp
Normal file
244
tests/test_voice_client_abi.cpp
Normal 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
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user