Cleanup client and worker pool comments
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

This commit is contained in:
2026-07-03 15:29:09 +01:00
parent cc81d19d02
commit 826b3bfb86
3 changed files with 65 additions and 120 deletions

View File

@@ -40,7 +40,7 @@ std::vector<uint8_t> make_frame(const voicecat::v1::Envelope& env) {
} // namespace } // namespace
// ── vc_client implementation ───────────────────────────────────────────────────
vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) { vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {
// TOFU: NULL/empty tofu_store_path falls back to a relative default so existing tests // TOFU: NULL/empty tofu_store_path falls back to a relative default so existing tests
@@ -82,7 +82,7 @@ void vc_client::emit_disconnected(vc_result r, const char* reason) {
set_state(VC_STATE_DISCONNECTED); set_state(VC_STATE_DISCONNECTED);
} }
// ── Connection ────────────────────────────────────────────────────────────────
vc_result vc_client::connect(const char* host, uint16_t port) { vc_result vc_client::connect(const char* host, uint16_t port) {
auto cur = state_net_.load(std::memory_order_acquire); auto cur = state_net_.load(std::memory_order_acquire);
@@ -102,15 +102,6 @@ vc_result vc_client::disconnect() {
auto cur = state_net_.load(std::memory_order_acquire); auto cur = state_net_.load(std::memory_order_acquire);
if (cur == VC_STATE_DISCONNECTED && !io_thread_.joinable()) return VC_ERR_NOT_CONNECTED; if (cur == VC_STATE_DISCONNECTED && !io_thread_.joinable()) return VC_ERR_NOT_CONNECTED;
// Graceful disconnect (Tier 5): queue Disconnect{code=0}, set a flag, and let the io
// thread drain the queue + send it + exit naturally. The io thread's cleanup handles
// teardown_voice() and socket close — the main thread just joins. This avoids the
// double-close race that a send_cv_ wait + main-thread socket close would create
// (the server closes the connection on receipt of Disconnect, so the io thread exits
// while the main thread is still waiting, and both try to close the same socket).
// Only when fully authenticated: the server gates Disconnect handling on Authenticated,
// and in earlier states (TLS handshake, TOFU gate, auth pending) the io thread may be
// blocked outside the read loop (e.g. tofu_cv_) where the flag would never be checked.
if (cur == VC_STATE_CONNECTED && io_thread_.joinable()) { if (cur == VC_STATE_CONNECTED && io_thread_.joinable()) {
voicecat::v1::Envelope env; voicecat::v1::Envelope env;
env.set_request_id(next_req_id_++); env.set_request_id(next_req_id_++);
@@ -129,7 +120,7 @@ vc_result vc_client::disconnect() {
return VC_OK; return VC_OK;
} }
// Non-graceful path: force-close (original logic). Used when the io thread is already // Non-graceful path: force-close Used when the io thread is already
// gone, the connection is in an early state, or as a fallback. // gone, the connection is in an early state, or as a fallback.
io_stop_.store(true, std::memory_order_release); io_stop_.store(true, std::memory_order_release);
@@ -162,13 +153,13 @@ vc_result vc_client::disconnect() {
return VC_OK; return VC_OK;
} }
// ── 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; udp_host_ = host;
set_state(VC_STATE_CONNECTING); set_state(VC_STATE_CONNECTING);
// ── TCP connect ────────────────────────────────────────────────────────── // TCP connect
#ifdef _WIN32 #ifdef _WIN32
WSADATA wsa{}; WSADATA wsa{};
WSAStartup(MAKEWORD(2, 2), &wsa); WSAStartup(MAKEWORD(2, 2), &wsa);
@@ -211,7 +202,7 @@ void vc_client::run_io(std::string host, uint16_t port) {
res = nullptr; res = nullptr;
io_fd_.store(static_cast<int>(sock), std::memory_order_release); io_fd_.store(static_cast<int>(sock), std::memory_order_release);
// ── TLS handshake ──────────────────────────────────────────────────── // TLS handshake
set_state(VC_STATE_TLS_HANDSHAKE); set_state(VC_STATE_TLS_HANDSHAKE);
tls_ = std::make_unique<voicecat::crypto::TlsContext>( tls_ = std::make_unique<voicecat::crypto::TlsContext>(
@@ -228,12 +219,8 @@ void vc_client::run_io(std::string host, uint16_t port) {
} }
} }
// ── TOFU server-identity gate ──────────────────────────────────────────── // TOFU server-identity gate
// Pins the TLS leaf cert's own fingerprint (real, verifiable right here from the
// handshake) — NOT the declared Ed25519 server_identity_fingerprint from ServerHello,
// which hasn't even arrived yet at this point (it's sent *inside* this now-established
// tunnel) and isn't cryptographically bound to this cert anyway (docs/security.md
// §1.1). See voicecat.h's vc_tofu_status doc comment.
set_state(VC_STATE_VERIFYING_IDENTITY); set_state(VC_STATE_VERIFYING_IDENTITY);
{ {
std::array<uint8_t, 32> peer_fp{}; std::array<uint8_t, 32> peer_fp{};
@@ -271,7 +258,7 @@ void vc_client::run_io(std::string host, uint16_t port) {
return !tofu_decision_pending_ || io_stop_.load(std::memory_order_acquire); return !tofu_decision_pending_ || io_stop_.load(std::memory_order_acquire);
}); });
// Timeout or an external stop (disconnect() during the wait) both leave // Timeout or an external stop (disconnect() during the wait) both leave
// tofu_decision_pending_ true here — treated as a reject, per voicecat.h. // tofu_decision_pending_ true here — treated as a reject
accepted = tofu_decision_pending_ ? false : tofu_accept_; accepted = tofu_decision_pending_ ? false : tofu_accept_;
tofu_decision_pending_ = false; tofu_decision_pending_ = false;
} }
@@ -315,7 +302,7 @@ void vc_client::run_io(std::string host, uint16_t port) {
} }
} }
// ── Read loop ──────────────────────────────────────────────────────── // Read loop
{ {
voicecat::protocol::FrameCodec codec; voicecat::protocol::FrameCodec codec;
std::vector<uint8_t> buf(16384); std::vector<uint8_t> buf(16384);
@@ -340,8 +327,7 @@ void vc_client::run_io(std::string host, uint16_t port) {
} }
// Keepalive: send a Ping every ~15s so the server's reaper doesn't drop us // Keepalive: send a Ping every ~15s so the server's reaper doesn't drop us
// (docs/protocol.md §7). The 50ms read timeout means this loop spins ~20×/s,
// plenty of resolution for a 15s interval.
{ {
auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>( auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count(); std::chrono::steady_clock::now().time_since_epoch()).count();
@@ -464,7 +450,7 @@ void vc_client::send_ping() {
queue_envelope(env); queue_envelope(env);
} }
// ── Protocol dispatch ───────────────────────────────────────────────────────── // Protocol dispatch
void vc_client::handle_envelope(const voicecat::v1::Envelope& env) { void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
switch (env.body_case()) { switch (env.body_case()) {
@@ -502,7 +488,7 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
handle_voice_subscription_result(env.voice_subscription_result()); handle_voice_subscription_result(env.voice_subscription_result());
break; break;
case voicecat::v1::Envelope::kPong: { case voicecat::v1::Envelope::kPong: {
// Correlate the echoed nonce to measure RTT (docs/protocol.md §7). // Correlate the echoed nonce to measure RTT
uint64_t nonce = env.pong().nonce(); uint64_t nonce = env.pong().nonce();
auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>( auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count(); std::chrono::steady_clock::now().time_since_epoch()).count();
@@ -626,7 +612,7 @@ void vc_client::handle_channel_event(const voicecat::v1::ChannelEvent& ce) {
// Detect audio-config changes on our current channel BEFORE apply_channel_event // Detect audio-config changes on our current channel BEFORE apply_channel_event
// overwrites the stored config. Encoders/decoders are frozen at StreamAnnounceResult // overwrites the stored config. Encoders/decoders are frozen at StreamAnnounceResult
// time (docs/voice.md §3), so a channel audio change requires restarting active // time , so a channel audio change requires restarting active
// local streams to pick up the new params. // local streams to pick up the new params.
if (ce.kind() == voicecat::v1::ChannelEvent::UPDATED) { if (ce.kind() == voicecat::v1::ChannelEvent::UPDATED) {
updated_channel_id = ce.channel().id(); updated_channel_id = ce.channel().id();
@@ -657,17 +643,12 @@ void vc_client::handle_channel_event(const voicecat::v1::ChannelEvent& ce) {
restart_active_streams_for_channel(updated_channel_id); restart_active_streams_for_channel(updated_channel_id);
} }
// Same "go look" signal vc_list_channels' callers already poll on after the initial
// snapshot — see voicecat.h's VC_EVENT_CHANNEL_LIST doc comment.
vc_event ev{}; vc_event ev{};
ev.type = VC_EVENT_CHANNEL_LIST; ev.type = VC_EVENT_CHANNEL_LIST;
emit(ev); emit(ev);
} }
void vc_client::handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg) { void vc_client::handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg) {
// This response only acks the request + carries our private audio config. The
// authoritative channel change reaches us as a UserEvent::UPDATED broadcast (the server
// no longer excludes the mover), which keeps session_model_ in sync via apply_user_event.
vc_event ev{}; vc_event ev{};
ev.type = VC_EVENT_JOIN_RESULT; ev.type = VC_EVENT_JOIN_RESULT;
ev.result = msg.ok() ? VC_OK : VC_ERR_PROTOCOL; ev.result = msg.ok() ? VC_OK : VC_ERR_PROTOCOL;
@@ -748,7 +729,7 @@ void vc_client::handle_disconnect(const voicecat::v1::Disconnect& msg) {
emit_disconnected(VC_ERR_IO, msg.reason().c_str()); emit_disconnected(VC_ERR_IO, msg.reason().c_str());
} }
// ── Auth / channel / text commands ─────────────────────────────────────────── // Auth / channel / text commands
vc_result vc_client::authenticate_guest(const char* nickname) { vc_result vc_client::authenticate_guest(const char* nickname) {
auto cur = state_net_.load(std::memory_order_acquire); auto cur = state_net_.load(std::memory_order_acquire);
@@ -797,7 +778,6 @@ vc_result vc_client::join_channel(uint32_t channel_id, const char* password) {
req.set_request_id(next_req_id_++); req.set_request_id(next_req_id_++);
auto* jc = req.mutable_join_channel(); auto* jc = req.mutable_join_channel();
jc->set_channel_id(channel_id); jc->set_channel_id(channel_id);
// See voicecat.h's vc_join_channel doc comment.
if (password) jc->set_password(password); if (password) jc->set_password(password);
queue_envelope(req); queue_envelope(req);
return VC_OK; return VC_OK;
@@ -824,7 +804,7 @@ vc_result vc_client::send_text(vc_text_scope scope, uint32_t target_id, const ch
return VC_OK; return VC_OK;
} }
// ── UDP binding ──────────────────────────────────────────────────────────────── // UDP binding
void vc_client::start_udp_binding() { void vc_client::start_udp_binding() {
voicecat::v1::Envelope req; voicecat::v1::Envelope req;
@@ -909,7 +889,7 @@ void vc_client::run_udp_recv() {
int n = static_cast<int>(::recv(static_cast<sock_t>(fd), reinterpret_cast<char*>(buf.data()), int n = static_cast<int>(::recv(static_cast<sock_t>(fd), reinterpret_cast<char*>(buf.data()),
static_cast<int>(buf.size()), 0)); static_cast<int>(buf.size()), 0));
if (n < static_cast<int>(voicecat::net::kVoiceHeaderSize)) { if (n < static_cast<int>(voicecat::net::kVoiceHeaderSize)) {
// Timeout or short packet send a KEEPALIVE if the interval has elapsed. // Timeout or short packet, send a KEEPALIVE if the interval has elapsed.
send_udp_keepalive(); send_udp_keepalive();
continue; continue;
} }
@@ -917,8 +897,6 @@ void vc_client::run_udp_recv() {
voicecat::net::VoiceFrame hdr{}; voicecat::net::VoiceFrame hdr{};
if (!voicecat::net::parse_header(buf.data(), static_cast<size_t>(n), hdr)) continue; if (!voicecat::net::parse_header(buf.data(), static_cast<size_t>(n), hdr)) continue;
if (hdr.type == voicecat::net::kFrameKeepalive) { if (hdr.type == voicecat::net::kFrameKeepalive) {
// Echoed keepalive from the server — media path is alive. (RTT measurement
// could be added here later by correlating a nonce; not needed for NAT/timeout.)
continue; continue;
} }
if (hdr.type != voicecat::net::kFrameVoice) continue; if (hdr.type != voicecat::net::kFrameVoice) continue;
@@ -953,7 +931,7 @@ void vc_client::send_udp_keepalive() {
last_udp_keepalive_ms_.store(now_ms, std::memory_order_release); last_udp_keepalive_ms_.store(now_ms, std::memory_order_release);
// Plaintext KEEPALIVE: 14-byte header, type=2, no payload, no AEAD. The server // Plaintext KEEPALIVE: 14-byte header, type=2, no payload, no AEAD. The server
// identifies us by the verified UDP endpoint (set during the UdpBinding handshake). // identifies by the verified UDP endpoint (set during the UdpBinding handshake).
uint8_t pkt[voicecat::net::kVoiceHeaderSize] = {0}; uint8_t pkt[voicecat::net::kVoiceHeaderSize] = {0};
pkt[0] = voicecat::net::kFrameKeepalive; pkt[0] = voicecat::net::kFrameKeepalive;
@@ -977,7 +955,7 @@ int64_t client_now_ms() {
// gap where mode/dtx/complexity/application were silently dropped. // gap where mode/dtx/complexity/application were silently dropped.
voicecat::codec::OpusParams opus_params_from_audio_config(const voicecat::v1::AudioConfig& a) { voicecat::codec::OpusParams opus_params_from_audio_config(const voicecat::v1::AudioConfig& a) {
voicecat::codec::OpusParams p; voicecat::codec::OpusParams p;
// Opus always runs at 48 kHz internally (docs/voice.md §3): the whole AudioEngine clock is // Opus always runs at 48 kHz internally: the whole AudioEngine clock is
// 48 kHz and external PCM is fed at 48 kHz, so the codec must match regardless of what a // 48 kHz and external PCM is fed at 48 kHz, so the codec must match regardless of what a
// channel advertises. Honoring a non-48k effective sample_rate as the codec rate would // channel advertises. Honoring a non-48k effective sample_rate as the codec rate would
// create an encoder expecting e.g. 16k PCM while being fed 48k frames — wrong pitch/duration. // create an encoder expecting e.g. 16k PCM while being fed 48k frames — wrong pitch/duration.
@@ -1034,7 +1012,7 @@ void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples, int
auto it = local_streams_.find(kind); auto it = local_streams_.find(kind);
if (it == local_streams_.end() || !it->second.active.load(std::memory_order_acquire)) return; if (it == local_streams_.end() || !it->second.active.load(std::memory_order_acquire)) return;
// "Mic muted" only gates the MIC stream — a concurrently-running SCREEN_AUDIO share keeps // "Mic muted" only gates the MIC stream — a concurrently-running SCREEN_AUDIO share keeps
// playing while the user's mic is muted (scope decision — see docs/voice.md). // playing while the user's mic is muted
// Server-mute is also a hard gate on MIC transmission. // Server-mute is also a hard gate on MIC transmission.
if (kind == static_cast<int>(VC_STREAM_MIC) && if (kind == static_cast<int>(VC_STREAM_MIC) &&
(self_mic_muted_.load(std::memory_order_acquire) || (self_mic_muted_.load(std::memory_order_acquire) ||
@@ -1073,9 +1051,7 @@ void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples, int
} }
} }
// Send-side input gate (docs/voice.md §11) — MIC only. SCREEN_AUDIO/AUX_DEVICE always
// bypass this: gating a screen-share on the user's own voice activity would silently drop
// shared music/video audio whenever the user isn't talking, which defeats the feature.
if (kind == static_cast<int>(VC_STREAM_MIC)) { if (kind == static_cast<int>(VC_STREAM_MIC)) {
auto mode = current_input_mode_.load(std::memory_order_acquire); auto mode = current_input_mode_.load(std::memory_order_acquire);
if (mode == VC_INPUT_PUSH_TO_TALK) { if (mode == VC_INPUT_PUSH_TO_TALK) {
@@ -1101,7 +1077,7 @@ void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples, int
// (480 @10ms) or larger (1920 @40ms). Reframe to that size before encoding (docs/voice.md §3). // (480 @10ms) or larger (1920 @40ms). Reframe to that size before encoding (docs/voice.md §3).
const int target = ls.frame_samples; const int target = ls.frame_samples;
if (samples == target) { if (samples == target) {
// Fast path the common 20 ms channel: encode the engine frame directly, no buffering. // Fast path, the common 20 ms channel: encode the engine frame directly, no buffering.
encode_and_send_frame(ls, pcm, samples, channels, fd); encode_and_send_frame(ls, pcm, samples, channels, fd);
return; return;
} }
@@ -1226,18 +1202,11 @@ void vc_client::ensure_audio_running() {
p.external_capture = it->second.external_feed; p.external_capture = it->second.external_feed;
} }
} }
// iOS unified mode (external_playback): capture is ALWAYS external — the Swift AVAudioEngine
// owns the only mic path and feeds via vc_stream_feed_pcm. Force external_capture so start()
// never opens a hardware mic device, even when ensure_audio_running runs before a MIC stream
// exists (a remote stream arriving first — e.g. the post-auth ServerStateSnapshot — starts
// the engine for playback). Without this the core opens a miniaudio capture device that races
// the AVAudioEngine tap → dual mic capture → duplicated, crackly audio on the remote end.
// No-op on desktop, where external_playback_ is never set.
if (external_playback_.load(std::memory_order_acquire)) { if (external_playback_.load(std::memory_order_acquire)) {
p.external_capture = true; p.external_capture = true;
} }
// iOS VPIO: skip the hardware playback device and drive the mixer on a timer, delivering the
// final mix to the mixed-output sink for the Swift VPIO renderer (vc_set_external_playback).
audio_engine_.set_external_playback(external_playback_.load(std::memory_order_acquire)); audio_engine_.set_external_playback(external_playback_.load(std::memory_order_acquire));
audio_engine_.start(p, [this](int kind, const int16_t* pcm, int samples, int channels) { audio_engine_.start(p, [this](int kind, const int16_t* pcm, int samples, int channels) {
on_capture_frame(kind, pcm, samples, channels); on_capture_frame(kind, pcm, samples, channels);
@@ -1264,7 +1233,7 @@ void vc_client::sync_remote_streams(const voicecat::v1::User& user) {
remote_streams_[ssrc] = {user.id(), si.stream_id()}; remote_streams_[ssrc] = {user.id(), si.stream_id()};
voicecat::codec::OpusParams p = opus_params_from_audio_config(si.audio()); voicecat::codec::OpusParams p = opus_params_from_audio_config(si.audio());
// Only a MIC stream is voice; receive-side NR denoises voice only (docs/voice.md §10). // Only a MIC stream is voice; receive-side NR denoises voice only
bool is_voice = si.kind() == voicecat::v1::STREAM_MIC; bool is_voice = si.kind() == voicecat::v1::STREAM_MIC;
audio_engine_.init_recv_stream(ssrc, p, user.id(), si.stream_id(), is_voice); audio_engine_.init_recv_stream(ssrc, p, user.id(), si.stream_id(), is_voice);
bool muted = self_deafened_.load(std::memory_order_acquire) || bool muted = self_deafened_.load(std::memory_order_acquire) ||
@@ -1304,7 +1273,7 @@ void vc_client::sync_remote_streams(const voicecat::v1::User& user) {
} }
} }
// ── Stream / device control ──────────────────────────────────────────────────── // Stream / device control
vc_result vc_client::stream_start(const vc_stream_desc& desc, uint32_t* out_stream_id) { 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 (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
@@ -1338,9 +1307,7 @@ vc_result vc_client::stream_start(const vc_stream_desc& desc, uint32_t* out_stre
auto* audio = ann->mutable_requested_audio(); auto* audio = ann->mutable_requested_audio();
audio->set_sample_rate(48000); audio->set_sample_rate(48000);
// bitrate_bps intentionally left unset (0 = "no preference"): the channel's AudioConfig // bitrate_bps intentionally left unset (0 = "no preference"): the channel's AudioConfig
// is authoritative when the channel has one (docs/voice.md §3) — a client-side default // is authoritative when the channel has one
// here would otherwise always clamp the channel's bitrate down to this value. frame_ms/
// fec are likewise channel-controlled when a channel config exists; these are only used
// as a fallback when it doesn't. // as a fallback when it doesn't.
audio->set_frame_ms(20); audio->set_frame_ms(20);
audio->set_fec(true); audio->set_fec(true);
@@ -1384,11 +1351,7 @@ void vc_client::handle_stream_announce_result(uint64_t req_id,
} }
// Pre-size the reframe buffers used by on_capture_frame when the channel's frame_ms // Pre-size the reframe buffers used by on_capture_frame when the channel's frame_ms
// differs from the engine's 20 ms (frame_samples != 960). Worst case the accumulator // differs from the engine's 20 ms
// holds one sub-frame remainder (< frame_samples) plus one engine block (960 samples),
// interleaved over up to 2 channels — so (frame_samples + 960) * 2. upmix_scratch sizes
// one frame_samples chunk as stereo. Allocated here (control thread), never on the RT
// audio thread (architecture.md §3).
const size_t fs = ls.frame_samples; const size_t fs = ls.frame_samples;
ls.encode_accum.assign((fs + 960) * 2, 0); ls.encode_accum.assign((fs + 960) * 2, 0);
ls.upmix_scratch.assign(fs * 2, 0); ls.upmix_scratch.assign(fs * 2, 0);
@@ -1478,7 +1441,7 @@ vc_client::LocalStream* vc_client::find_local_stream_by_id(uint32_t stream_id) {
} }
void vc_client::restart_active_streams_for_channel(uint32_t channel_id) { void vc_client::restart_active_streams_for_channel(uint32_t channel_id) {
// Collect active stream info under the lock, then stopstart outside the lock (stream_stop // Collect active stream info under the lock, then stopstart outside the lock (stream_stop
// and stream_start both acquire local_streams_mu_). capture_device_id and capture_channels // and stream_start both acquire local_streams_mu_). capture_device_id and capture_channels
// survive the restart: stream_stop doesn't clear them, and stream_start reuses the existing // survive the restart: stream_stop doesn't clear them, and stream_start reuses the existing
// LocalStream entry (auto& ls = local_streams_[kind]) without resetting them. // LocalStream entry (auto& ls = local_streams_[kind]) without resetting them.
@@ -1509,7 +1472,7 @@ void vc_client::restart_active_streams_for_channel(uint32_t channel_id) {
} }
} }
// ── Voice-plane subscription ────────────────────────────────────────────────── // Voice-plane subscription
vc_result vc_client::join_voice() { vc_result vc_client::join_voice() {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
@@ -1623,11 +1586,7 @@ vc_result vc_client::set_input_device(uint32_t stream_id, const char* device_id)
if (&entry == ls) { kind = k; break; } if (&entry == ls) { kind = k; break; }
} }
} }
// Only the real capture device (MIC) is affected by device selection — SCREEN_AUDIO uses
// loopback capture (no input device to pick) and AUX_DEVICE isn't backed by a real device
// path yet. Restart the engine unconditionally when it's already running so the new device
// id takes effect; AudioEngine doesn't expose a getter for "is this the same device" so we
// don't try to skip the restart when it happens to be a no-op change.
if (kind == static_cast<int>(VC_STREAM_MIC) && audio_engine_.running()) { if (kind == static_cast<int>(VC_STREAM_MIC) && audio_engine_.running()) {
audio_engine_.stop(); audio_engine_.stop();
ensure_audio_running(); ensure_audio_running();
@@ -1735,8 +1694,8 @@ vc_result vc_client::get_remote_stream(uint32_t user_id, uint32_t stream_id,
} }
if (!found) return VC_ERR_INVALID_ARG; if (!found) return VC_ERR_INVALID_ARG;
// The (user, stream) is known. The RemoteStream entry may not exist yet if the listener // The (user, stream) is known. The RemoteStream entry may not exist yet if the listener
// has neither set any control nor received audio for it in that case report the // has neither set any control nor received audio for it, in that case report the
// defaults (docs/voice.md §10) so the UI opens at 100/unmuted/NR-off. // defaults so the UI opens at 100/unmuted/NR-off.
float gain = 1.0f; bool mute = false; bool nr = false; float gain = 1.0f; bool mute = false; bool nr = false;
if (audio_engine_.get_stream_state(ssrc, gain, mute, nr)) { if (audio_engine_.get_stream_state(ssrc, gain, mute, nr)) {
out->gain = gain; out->gain = gain;
@@ -1785,7 +1744,7 @@ vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_i
out->codec = 0; out->codec = 0;
out->mode = p.stereo ? 1u : 0u; out->mode = p.stereo ? 1u : 0u;
// Report the channel's configured sample_rate (carried as max_bandwidth_hz), not the // Report the channel's configured sample_rate (carried as max_bandwidth_hz), not the
// fixed 48 kHz codec clock matches what the remote-stream path below reports. // fixed 48 kHz codec clock, matches what the remote-stream path below reports.
out->sample_rate = p.max_bandwidth_hz ? p.max_bandwidth_hz : p.sample_rate; out->sample_rate = p.max_bandwidth_hz ? p.max_bandwidth_hz : p.sample_rate;
out->bitrate_bps = p.bitrate_bps; out->bitrate_bps = p.bitrate_bps;
out->frame_ms = p.frame_ms; out->frame_ms = p.frame_ms;
@@ -1850,8 +1809,8 @@ vc_result vc_client::set_mixed_output_sink(vc_mixed_output_cb cb, void* user) {
vc_result vc_client::set_external_playback(bool enable) { vc_result vc_client::set_external_playback(bool enable) {
external_playback_.store(enable, std::memory_order_release); external_playback_.store(enable, std::memory_order_release);
// Stored on the engine too; takes effect on the next start()/vc_audio_restart() (matching // Stored on the engine too. takes effect on the next start()/vc_audio_restart() (matching
// the vc_set_capture_channels "apply on next restart" contract). // the vc_set_capture_channels "apply on next restart"
audio_engine_.set_external_playback(enable); audio_engine_.set_external_playback(enable);
return VC_OK; return VC_OK;
} }
@@ -1861,7 +1820,7 @@ vc_result vc_client::test_inject_capture(uint32_t stream_id, const int16_t* pcm,
} }
vc_result vc_client::list_devices(vc_device_kind kind, vc_device_list* out) { vc_result vc_client::list_devices(vc_device_kind kind, vc_device_list* out) {
// Works in any connection state device pickers need to populate pre-connect. // Works in any connection state, device pickers need to populate pre-connect.
auto devices = voicecat::audio::AudioEngine::enumerate_devices(kind == VC_DEVICE_INPUT); auto devices = voicecat::audio::AudioEngine::enumerate_devices(kind == VC_DEVICE_INPUT);
auto* items = new vc_device[devices.size()]; auto* items = new vc_device[devices.size()];
@@ -1880,7 +1839,7 @@ vc_result vc_client::list_devices(vc_device_kind kind, vc_device_list* out) {
return VC_OK; return VC_OK;
} }
// ── Channel/user/stream snapshot getters ──────────────────────────────────────
vc_result vc_client::list_channels(vc_channel_list* out) { vc_result vc_client::list_channels(vc_channel_list* out) {
std::lock_guard<std::mutex> lk(session_model_mu_); std::lock_guard<std::mutex> lk(session_model_mu_);
@@ -1958,7 +1917,7 @@ vc_result vc_client::list_user_streams(uint32_t user_id, vc_stream_summary_list*
return VC_OK; return VC_OK;
} }
// ── TOFU server-identity gate ───────────────────────────────────────────────────
vc_result vc_client::confirm_server_identity(bool accept) { vc_result vc_client::confirm_server_identity(bool accept) {
std::lock_guard<std::mutex> lk(tofu_mu_); std::lock_guard<std::mutex> lk(tofu_mu_);
@@ -1983,7 +1942,7 @@ vc_result vc_client::get_server_identity_display(char* out_buf, size_t buf_cap,
return VC_OK; return VC_OK;
} }
// ── Moderation & admin ─────────────────────────────────────────────────────────
vc_result vc_client::kick_user(uint32_t user_id, const char* reason) { vc_result vc_client::kick_user(uint32_t user_id, const char* reason) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;

View File

@@ -1,5 +1,5 @@
/* /*
* client.h the implementation type behind the opaque `vc_client*` handle. * client.h: the implementation type behind the opaque `vc_client*` handle.
*/ */
#ifndef VOICECAT_CORE_CLIENT_H #ifndef VOICECAT_CORE_CLIENT_H
#define VOICECAT_CORE_CLIENT_H #define VOICECAT_CORE_CLIENT_H
@@ -73,7 +73,7 @@ struct vc_client {
vc_result list_users(vc_user_list* out); vc_result list_users(vc_user_list* out);
vc_result list_user_streams(uint32_t user_id, vc_stream_summary_list* out); vc_result list_user_streams(uint32_t user_id, vc_stream_summary_list* out);
// TOFU server-identity gate (see voicecat.h's VC_EVENT_SERVER_IDENTITY doc comment). // TOFU server-identity gate
vc_result confirm_server_identity(bool accept); vc_result confirm_server_identity(bool accept);
vc_result get_server_identity_display(char* out_buf, size_t buf_cap, size_t* out_len); vc_result get_server_identity_display(char* out_buf, size_t buf_cap, size_t* out_len);
@@ -82,8 +82,7 @@ struct vc_client {
vc_result get_stream_audio_config(uint32_t user_id, uint32_t stream_id, vc_result get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
vc_audio_config* out); vc_audio_config* out);
// External PCM feed (see voicecat.h: vc_stream_feed_pcm). Production API for driving a
// local stream's encode pipeline without a hardware capture device. channels = 1 or 2.
vc_result stream_feed_pcm(uint32_t stream_id, const int16_t* pcm, vc_result stream_feed_pcm(uint32_t stream_id, const int16_t* pcm,
size_t samples_per_channel, uint32_t channels); size_t samples_per_channel, uint32_t channels);
@@ -94,7 +93,6 @@ struct vc_client {
vc_result set_mixed_output_sink(vc_mixed_output_cb cb, void* user); vc_result set_mixed_output_sink(vc_mixed_output_cb cb, void* user);
vc_result set_external_playback(bool enable); vc_result set_external_playback(bool enable);
// TEST-ONLY (see voicecat.h) — deprecated alias for stream_feed_pcm(..., channels=1).
vc_result test_inject_capture(uint32_t stream_id, const int16_t* pcm, size_t samples); vc_result test_inject_capture(uint32_t stream_id, const int16_t* pcm, size_t samples);
// Moderation & admin. // Moderation & admin.
@@ -123,7 +121,7 @@ struct vc_client {
vc_config cfg_{}; vc_config cfg_{};
vc_callbacks cb_{}; vc_callbacks cb_{};
// ── TCP/TLS control channel ─────────────────────────────────────────────────── // TCP/TLS control channel
std::atomic<vc_connection_state> state_net_{VC_STATE_DISCONNECTED}; std::atomic<vc_connection_state> state_net_{VC_STATE_DISCONNECTED};
// Blocking I/O thread (one per vc_client lifetime) // Blocking I/O thread (one per vc_client lifetime)
@@ -197,7 +195,7 @@ struct vc_client {
std::vector<voicecat::v1::AccountEntry> last_account_list_; std::vector<voicecat::v1::AccountEntry> last_account_list_;
mutable std::mutex account_list_mu_; mutable std::mutex account_list_mu_;
// ── TOFU server-identity gate ───────────────────────────────────────────────── // TOFU server-identity gate
std::unique_ptr<voicecat::crypto::TofuStore> tofu_store_; // owns the pin file std::unique_ptr<voicecat::crypto::TofuStore> tofu_store_; // owns the pin file
std::mutex tofu_mu_; std::mutex tofu_mu_;
std::condition_variable tofu_cv_; std::condition_variable tofu_cv_;
@@ -205,7 +203,7 @@ struct vc_client {
bool tofu_accept_{false}; bool tofu_accept_{false};
std::string pending_identity_fp_hex_; // ServerHello's Ed25519 fp, display-only std::string pending_identity_fp_hex_; // ServerHello's Ed25519 fp, display-only
// ── UDP / media plane ────────────────────────────────────────────────────────── // UDP / media plane
std::array<uint8_t, 16> udp_token_{}; std::array<uint8_t, 16> udp_token_{};
uint16_t server_udp_port_{0}; uint16_t server_udp_port_{0};
std::string udp_host_; std::string udp_host_;
@@ -235,8 +233,7 @@ struct vc_client {
// vc_get_stream_audio_config() has something to read back for our own streams. // vc_get_stream_audio_config() has something to read back for our own streams.
voicecat::codec::OpusParams effective_params; voicecat::codec::OpusParams effective_params;
// Talk-indicator edge detection (docs/voice.md §7) — updated in on_capture_frame, after
// the VAD/PTT gate so a gated-closed frame doesn't show as "talking".
std::atomic<int64_t> last_capture_ms{0}; std::atomic<int64_t> last_capture_ms{0};
bool talking = false; bool talking = false;
@@ -263,9 +260,7 @@ struct vc_client {
// time and checked in handle_stream_announce_result / stream_stop. // time and checked in handle_stream_announce_result / stream_stop.
bool external_feed = false; bool external_feed = false;
// Stream label (from vc_stream_desc.label at stream_start time). Retained so the
// channel-update stream restart (restart_active_streams_for_channel) can re-announce
// with the same label without the caller's involvement.
std::string label; std::string label;
// Reframe buffer: the AudioEngine clock is fixed at 48 kHz / 20 ms, so capture/feed // Reframe buffer: the AudioEngine clock is fixed at 48 kHz / 20 ms, so capture/feed
@@ -273,9 +268,8 @@ struct vc_client {
// can be 2.5…60 ms, so the encoder needs frame_samples per call (480 @10ms, 1920 @40ms, // can be 2.5…60 ms, so the encoder needs frame_samples per call (480 @10ms, 1920 @40ms,
// …). on_capture_frame accumulates the engine's 960-sample frames here and emits // …). on_capture_frame accumulates the engine's 960-sample frames here and emits
// frame_samples-sized chunks. The 20 ms case (frame_samples == 960) bypasses this // frame_samples-sized chunks. The 20 ms case (frame_samples == 960) bypasses this
// entirely (fast path). Pre-sized at announce; never resized on the audio thread // entirely. encode_accum holds interleaved int16
// (architecture.md §3 — no RT-thread allocation). encode_accum holds interleaved int16 // at accum_channels; upmix_scratch is the pre-sized monostereo upmix target.
// at accum_channels; upmix_scratch is the pre-sized mono→stereo upmix target.
std::vector<int16_t> encode_accum; std::vector<int16_t> encode_accum;
size_t accum_count = 0; // flat samples currently buffered size_t accum_count = 0; // flat samples currently buffered
int accum_channels = 0; // channel count of buffered data; resets on change int accum_channels = 0; // channel count of buffered data; resets on change
@@ -286,12 +280,12 @@ struct vc_client {
std::unordered_map<uint64_t, int> pending_announce_kind_; // request_id -> kind std::unordered_map<uint64_t, int> pending_announce_kind_; // request_id -> kind
uint32_t next_local_stream_id_{1}; uint32_t next_local_stream_id_{1};
// ssrc (user_id, stream_id) for remote streams already wired into audio_engine_. // ssrc (user_id, stream_id) for remote streams already wired into audio_engine_.
mutable std::mutex remote_streams_mu_; mutable std::mutex remote_streams_mu_;
std::unordered_map<uint32_t, std::pair<uint32_t, uint32_t>> remote_streams_; std::unordered_map<uint32_t, std::pair<uint32_t, uint32_t>> remote_streams_;
// Talk-indicator polling thread (separate from udp_thread_ / the miniaudio callback // Talk-indicator polling thread (separate from udp_thread_ / the miniaudio callback
// thread — see architecture.md §3 real-time rule). // thread.
std::thread talk_timer_thread_; std::thread talk_timer_thread_;
std::atomic<bool> talk_timer_stop_{false}; std::atomic<bool> talk_timer_stop_{false};
static constexpr int64_t kTalkPollMs = 100; static constexpr int64_t kTalkPollMs = 100;
@@ -309,19 +303,15 @@ struct vc_client {
// Permissions from last AuthResult. // Permissions from last AuthResult.
vc_permissions own_permissions_{}; vc_permissions own_permissions_{};
// Send-side input gate (docs/voice.md §11). MIC-only — SCREEN_AUDIO/ // Send-side input gate MIC-only — SCREEN_AUDIO/
// AUX_DEVICE are never gated (see PROGRESS.md for the rationale). Pure local state, no // AUX_DEVICE are never gated
// protocol traffic. mic_vad_ is constructed once the MIC stream's StreamAnnounceResult
// lands (handle_stream_announce_result, on io_thread_ — not the RT capture callback).
std::atomic<vc_input_mode> current_input_mode_{VC_INPUT_VOICE_ACTIVATION}; std::atomic<vc_input_mode> current_input_mode_{VC_INPUT_VOICE_ACTIVATION};
std::atomic<bool> ptt_active_{false}; std::atomic<bool> ptt_active_{false};
std::atomic<float> vad_threshold_{0.025f}; // remembered across mode switches std::atomic<float> vad_threshold_{0.025f}; // remembered across mode switches
std::atomic<float> input_gain_{1.0f}; // send-side MIC gain (vc_set_input_gain) std::atomic<float> input_gain_{1.0f}; // send-side MIC gain (vc_set_input_gain)
std::atomic<bool> input_noise_reduction_{false}; // send-side MIC NS (vc_set_input_noise_reduction) std::atomic<bool> input_noise_reduction_{false}; // send-side MIC NS (vc_set_input_noise_reduction)
// External-playback mode (iOS VPIO): when true, ensure_audio_running() configures the
// AudioEngine to skip its hardware playback device and drive the mixer on a timer instead,
// delivering the final mix to the mixed-output sink. Set via vc_set_external_playback.
std::atomic<bool> external_playback_{false}; std::atomic<bool> external_playback_{false};
std::unique_ptr<voicecat::audio::ApmProcessor> mic_vad_; std::unique_ptr<voicecat::audio::ApmProcessor> mic_vad_;
// Send-side mic noise suppressor (RNNoise). Constructed once with the MIC stream alongside // Send-side mic noise suppressor (RNNoise). Constructed once with the MIC stream alongside
@@ -333,10 +323,10 @@ struct vc_client {
// makes it idempotent (avoids a double-join race on udp_thread_/talk_timer_thread_). // makes it idempotent (avoids a double-join race on udp_thread_/talk_timer_thread_).
std::mutex teardown_mu_; std::mutex teardown_mu_;
// ── 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);
// ── Protocol dispatch (called on io_thread_) ──────────────────────────────── // Protocol dispatch (called on io_thread_)
void handle_envelope(const voicecat::v1::Envelope& env); void handle_envelope(const voicecat::v1::Envelope& env);
void handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t req_id); void handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t req_id);
void handle_auth_result(const voicecat::v1::AuthResult& msg); void handle_auth_result(const voicecat::v1::AuthResult& msg);
@@ -344,8 +334,8 @@ struct vc_client {
void handle_user_event(const voicecat::v1::UserEvent& ue); void handle_user_event(const voicecat::v1::UserEvent& ue);
void handle_channel_event(const voicecat::v1::ChannelEvent& ce); void handle_channel_event(const voicecat::v1::ChannelEvent& ce);
// Called from handle_channel_event when the current channel's audio config changed: // Called from handle_channel_event when the current channel's audio config changed:
// stopstart every active local stream so the new Opus params take effect (encoders are // stops tart every active local stream so the new Opus params take effect (encoders are
// frozen at StreamAnnounceResult time — docs/voice.md §3). capture_device_id and // frozen at StreamAnnounceResult time . capture_device_id and
// capture_channels survive the restart (stream_stop doesn't clear them; stream_start // capture_channels survive the restart (stream_stop doesn't clear them; stream_start
// reuses the existing LocalStream entry). The server reads the updated channel config // reuses the existing LocalStream entry). The server reads the updated channel config
// on re-announce and returns new effective_audio; peers' sync_remote_streams wire up // on re-announce and returns new effective_audio; peers' sync_remote_streams wire up
@@ -365,8 +355,8 @@ struct vc_client {
// Stop every active local stream (used on voice leave — emits STREAM_STOPPED for each). // Stop every active local stream (used on voice leave — emits STREAM_STOPPED for each).
void stop_all_local_streams(); void stop_all_local_streams();
// ── UDP / media helpers ──────────────────────────────────────────────────────── // UDP / media helpers
// Kicks off TCP UdpBinding request; called once after a successful AuthResult. // Kicks off TCP UdpBinding request, called once after a successful AuthResult.
void start_udp_binding(); void start_udp_binding();
// Opens the UDP socket, sends the plaintext bootstrap packet, starts udp_thread_. // Opens the UDP socket, sends the plaintext bootstrap packet, starts udp_thread_.
void finish_udp_binding(); void finish_udp_binding();
@@ -374,12 +364,11 @@ struct vc_client {
void run_udp_recv(); void run_udp_recv();
// Send a plaintext KEEPALIVE frame to the server media endpoint. Called from run_udp_recv // Send a plaintext KEEPALIVE frame to the server media endpoint. Called from run_udp_recv
// every kUdpKeepaliveIntervalMs to hold NAT bindings + bump the server's last_seen // every kUdpKeepaliveIntervalMs to hold NAT bindings + bump the server's last_seen
// (docs/voice.md §6). Plaintext — no AEAD — to avoid racing the audio thread's seal().
void send_udp_keepalive(); void send_udp_keepalive();
// capture_cb passed to audio_engine_.start(): encode + seal + send one frame for the // capture_cb passed to audio_engine_.start(): encode + seal + send one frame for the
// given local stream `kind` (multiple concurrent local streams are possible). // given local stream `kind` (multiple concurrent local streams are possible).
void on_capture_frame(int kind, const int16_t* pcm, int samples, int channels); void on_capture_frame(int kind, const int16_t* pcm, int samples, int channels);
// Encode one frame of exactly ls.frame_samples samples-per-channel (upmixing monostereo // Encode one frame of exactly ls.frame_samples samples-per-channel (upmixing mono to stereo
// for a stereo channel as needed), seal it, and send it over UDP, advancing ls.timestamp. // for a stereo channel as needed), seal it, and send it over UDP, advancing ls.timestamp.
// Called by on_capture_frame for each frame_samples-sized chunk. Assumes local_streams_mu_ // Called by on_capture_frame for each frame_samples-sized chunk. Assumes local_streams_mu_
// is held and the send gate/crypto checks have already passed. // is held and the send gate/crypto checks have already passed.
@@ -400,22 +389,19 @@ struct vc_client {
// the caller, or taken internally). Returns nullptr if not found/not active. // the caller, or taken internally). Returns nullptr if not found/not active.
LocalStream* find_local_stream_by_id(uint32_t stream_id); LocalStream* find_local_stream_by_id(uint32_t stream_id);
// ── Helpers (io_thread_ and caller threads) ─────────────────────────────────
// Queue an encoded envelope to be sent on io_thread_.
void queue_envelope(const voicecat::v1::Envelope& env); void queue_envelope(const voicecat::v1::Envelope& env);
// Keepalive: send a Ping envelope with a fresh nonce and record the sent time for // Keepalive: send a Ping envelope with a fresh nonce and record the sent time for
// RTT measurement when the Pong arrives. Called from the read loop when kPingIntervalMs // RTT measurement when the Pong arrives. Called from the read loop when kPingIntervalMs
// has elapsed. (docs/protocol.md §7) // has elapsed.
void send_ping(); void send_ping();
// Drain send_queue_ by doing blocking TLS writes (called on io_thread_).
void drain_sends(); void drain_sends();
// Transition state + emit VC_EVENT_CONNECTION_STATE. // Transition state + emit VC_EVENT_CONNECTION_STATE.
void set_state(vc_connection_state s); void set_state(vc_connection_state s);
// Convenience event emitters.
void emit_error(vc_result r, const char* text); void emit_error(vc_result r, const char* text);
void emit_disconnected(vc_result r, const char* reason); void emit_disconnected(vc_result r, const char* reason);
}; };

View File

@@ -1,2 +1,2 @@
#include "core/worker_pool.h" #include "core/worker_pool.h"
// WorkerPool is header-only via asio::thread_pool; nothing to define here. // WorkerPool is header-only via asio::thread_pool. nothing to define here.