Files
voice-cat/core/src/core/client.h
Talon bba605401d
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
chore: comment cleanup pass ahead of open-sourcing
Removes leftover debug scaffolding (stray Console.WriteLine/NSLog traces,
dead nick_buf_ptr, a no-op --print-config flag now implemented for real),
fixes stale/misleading comments (channel passwords are no longer a "future
M5+" feature, a wrong cross-reference, a stale TlsContext::close() mention,
an incomplete BanRecord::subject_type doc, and a smoke test pointing at a
build/m1-dev preset that no longer exists), strips internal M1-M5 milestone
jargon from comments now that the roadmap is done, trims comments that just
restated the following line, and consolidates a few "why" explanations that
were duplicated 2-3 times in the same file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:20:18 +01:00

424 lines
23 KiB
C++

/*
* client.h — the implementation type behind the opaque `vc_client*` handle.
*/
#ifndef VOICECAT_CORE_CLIENT_H
#define VOICECAT_CORE_CLIENT_H
#include "voicecat.h"
#include <atomic>
#include <condition_variable>
#include <deque>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <unordered_map>
#include <utility>
#include <vector>
#include "audio/audio_engine.h"
#include "codec/opus_codec.h"
#include "crypto/crypto.h"
#include "crypto/tofu_store.h"
#include "net/voice_frame.h"
#include "protocol/envelope.h"
#include "protocol/protocol.h"
#include "session/session.h"
#include "proto/voicecat.pb.h"
struct vc_client {
vc_client(const vc_config& cfg, vc_callbacks cb);
~vc_client();
vc_client(const vc_client&) = delete;
vc_client& operator=(const vc_client&) = delete;
vc_result connect(const char* host, uint16_t port);
vc_result disconnect();
vc_result authenticate_guest(const char* nickname);
vc_result authenticate_user(const char* username, const char* password);
vc_result join_channel(uint32_t channel_id, const char* password);
vc_result leave_channel();
vc_result join_voice();
vc_result leave_voice();
vc_result stream_start(const vc_stream_desc& desc, uint32_t* out_stream_id);
vc_result stream_stop(uint32_t stream_id);
vc_result set_input_device(uint32_t stream_id, const char* device_id);
vc_result set_capture_channels(uint32_t stream_id, uint32_t channels);
vc_result set_input_mode(vc_input_mode mode);
vc_result set_vad_threshold(float threshold);
vc_result set_push_to_talk(bool active);
vc_result set_self_mute(bool mic_muted, bool deafened);
vc_result set_output_volume(float gain);
vc_result set_input_gain(float gain);
vc_result set_input_noise_reduction(bool enable);
vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted,
bool noise_reduction);
vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id,
vc_remote_stream_state* out);
vc_result audio_suspend();
vc_result audio_resume();
vc_result audio_restart();
vc_result send_text(vc_text_scope scope, uint32_t target_id, const char* utf8);
vc_result list_devices(vc_device_kind kind, vc_device_list* out);
// Channel/user/stream snapshot getters (read session_model_; see voicecat.h).
vc_result list_channels(vc_channel_list* out);
vc_result list_users(vc_user_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).
vc_result confirm_server_identity(bool accept);
vc_result get_server_identity_display(char* out_buf, size_t buf_cap, size_t* out_len);
// Effective Opus config for a (user_id, stream_id) — our own pending/active local
// streams, or any peer's broadcast StreamInfo.audio.
vc_result get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
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,
size_t samples_per_channel, uint32_t channels);
// External PCM sink (see voicecat.h: vc_set_pcm_sink). Delegates to AudioEngine.
vc_result set_pcm_sink(vc_pcm_sink_cb cb, void* user);
// External mixed-output sink + external-playback mode (iOS VPIO; see voicecat.h).
vc_result set_mixed_output_sink(vc_mixed_output_cb cb, void* user);
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);
// Moderation & admin.
vc_result kick_user(uint32_t user_id, const char* reason);
vc_result ban_user(uint32_t user_id, const char* reason, uint64_t expires_unix_ms);
vc_result set_permission(uint32_t user_id, const vc_permissions* perms);
vc_result set_server_mute(uint32_t user_id, bool muted, bool deafened);
vc_result move_user(uint32_t user_id, uint32_t channel_id);
vc_result create_channel(const vc_channel_info* info);
vc_result edit_channel(const vc_channel_info* info);
vc_result delete_channel(uint32_t channel_id);
vc_result create_account(const char* username, const char* password);
vc_result reset_password(const char* username, const char* new_password);
vc_result delete_account(const char* username);
vc_result list_accounts();
vc_result get_account_list(vc_account_list* out);
vc_result get_permissions(vc_permissions* out);
vc_connection_state state() const {
return state_net_.load(std::memory_order_acquire);
}
private:
void emit(const vc_event& ev) const;
vc_config cfg_{};
vc_callbacks cb_{};
// ── TCP/TLS control channel ───────────────────────────────────────────────────
std::atomic<vc_connection_state> state_net_{VC_STATE_DISCONNECTED};
// Blocking I/O thread (one per vc_client lifetime)
std::thread io_thread_;
std::atomic<bool> io_stop_{false};
// Send queue: pushed by any thread, drained by io_thread_
std::mutex send_mutex_;
std::condition_variable send_cv_;
std::deque<std::vector<uint8_t>> send_queue_;
// TLS context — created + used exclusively on io_thread_
std::unique_ptr<voicecat::crypto::TlsContext> tls_;
// Raw socket fd (stored after TCP connect; closed by disconnect())
std::atomic<int> io_fd_{-1};
// Pending auth stored before ServerHello arrives
struct PendingAuth {
bool is_guest{true};
std::string nick_or_user;
std::string password;
};
std::mutex pending_auth_mutex_;
std::optional<PendingAuth> pending_auth_;
// Self identity filled in after AuthResult
uint32_t self_user_id_{0};
uint64_t server_session_id_{0};
std::atomic<uint64_t> next_req_id_{1};
// Voice-plane subscription state. When false, the server does not relay voice frames to
// us, and we do not wire up remote-stream decoders (sync_remote_streams is gated on this).
// Toggled by vc_join_voice / vc_leave_voice; confirmed via VoiceSubscriptionResult.
std::atomic<bool> voice_subscribed_{false};
// Keepalive: client sends a Ping every ~15s (docs/protocol.md §7) so the server's
// last_seen stays fresh and the reaper doesn't drop us. Pong echoes the nonce, which
// we correlate to measure RTT. The read loop's 50ms TLS timeout means it spins fast
// enough to check the ping interval with ample resolution.
static constexpr int64_t kPingIntervalMs = 15000;
std::atomic<int64_t> last_ping_ms_{0};
std::atomic<uint64_t> ping_nonce_{1};
std::mutex ping_mutex_;
std::unordered_map<uint64_t, int64_t> pending_pings_; // nonce → sent_ms
std::atomic<int64_t> last_rtt_ms_{0};
// Graceful disconnect: set by disconnect() after queueing Disconnect{code=0}. The io
// thread checks this after drain_sends() — when set, it sets io_stop_ and exits the
// read loop, so the Disconnect is sent before the thread ends. The main thread just
// joins; no socket close from the main thread (the io thread's cleanup closes it),
// avoiding the double-close race that the send_cv_ wait approach exposed.
std::atomic<bool> graceful_disconnect_pending_{false};
// UDP keepalive: send a lightweight KEEPALIVE frame every ~5s to hold NAT bindings and
// bump the server's last_seen independently of the TCP ping (docs/voice.md §6). Sent
// as plaintext (no AEAD) — the server identifies the sender by its already-verified UDP
// endpoint, and the TCP reaper is the real timeout authority. Avoids racing the
// non-atomic send_counter_ in SodiumMediaCrypto::seal() with the audio callback thread.
static constexpr int64_t kUdpKeepaliveIntervalMs = 5000;
std::atomic<int64_t> last_udp_keepalive_ms_{0};
// Client-side session model. Mutated only on io_thread_ (handle_server_state/
// handle_user_event/handle_channel_event), but read from any thread via the
// list_channels/list_users/list_user_streams getters — session_model_mu_ guards both.
voicecat::session::SessionModel session_model_;
mutable std::mutex session_model_mu_;
// Last ListAccountsResult snapshot, populated on io_thread_ when
// VC_EVENT_ACCOUNT_LIST fires and read by vc_get_account_list on caller threads.
std::vector<voicecat::v1::AccountEntry> last_account_list_;
mutable std::mutex account_list_mu_;
// ── TOFU server-identity gate ─────────────────────────────────────────────────
std::unique_ptr<voicecat::crypto::TofuStore> tofu_store_; // owns the pin file
std::mutex tofu_mu_;
std::condition_variable tofu_cv_;
bool tofu_decision_pending_{false};
bool tofu_accept_{false};
std::string pending_identity_fp_hex_; // ServerHello's Ed25519 fp, display-only
// ── 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_;
// One LocalStream per concurrently-active stream kind (MIC/SCREEN_AUDIO/AUX_DEVICE
// are each singletons for a given client).
struct LocalStream {
voicecat::codec::OpusEncoder encoder;
std::atomic<bool> active{false};
bool pending{false}; // announced, awaiting StreamAnnounceResult
uint32_t stream_id{0};
uint32_t ssrc{0};
uint32_t timestamp{0};
uint16_t frame_samples{960};
uint64_t pending_request_id{0};
// Full effective OpusParams from the last StreamAnnounceResult — retained so
// vc_get_stream_audio_config() has something to read back for our own streams.
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};
bool talking = false;
// Talkspurt marker: the sender's `timestamp` omits VAD/PTT/DTX silence, so the receiver
// can't tell a continuation from a post-silence restart. encode_and_send_frame stamps
// kFlagMarker on the first frame after a transmission gap (detected via last_send_ms) so
// the receiver reseeds its playout clock cleanly. -1 = no frame sent yet (first frame is
// always a marker).
int64_t last_send_ms = -1;
// Device-enumeration follow-up: the device this stream's capture should use ("" =
// default). Only meaningful for VC_STREAM_MIC today (the real capture device); set via
// vc_set_input_device. Opaque id from AudioEngine::enumerate_devices — see
// audio_engine.h's DeviceInfo doc comment.
std::string capture_device_id;
// Capture channel count (1 = mono, 2 = stereo interleaved). Only meaningful for
// VC_STREAM_MIC. Set via vc_set_capture_channels; read by ensure_audio_running() to
// configure AudioParams.capture_channels before the device opens. Defaults to 1 (mono).
uint32_t capture_channels = 1;
// If true, the caller is feeding PCM via vc_stream_feed_pcm — skip start/stop of the
// core's WASAPI loopback device. Set from vc_stream_desc::external_feed at stream_start
// time and checked in handle_stream_announce_result / stream_stop.
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;
// Reframe buffer: the AudioEngine clock is fixed at 48 kHz / 20 ms, so capture/feed
// always delivers 960-sample frames — but the channel's frame_ms (docs/voice.md §3)
// 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
// 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
// (architecture.md §3 — no RT-thread allocation). encode_accum holds interleaved int16
// at accum_channels; upmix_scratch is the pre-sized mono→stereo upmix target.
std::vector<int16_t> encode_accum;
size_t accum_count = 0; // flat samples currently buffered
int accum_channels = 0; // channel count of buffered data; resets on change
std::vector<int16_t> upmix_scratch;
};
mutable std::mutex local_streams_mu_;
std::unordered_map<int, LocalStream> local_streams_; // keyed by vc_stream_kind
std::unordered_map<uint64_t, int> pending_announce_kind_; // request_id -> kind
uint32_t next_local_stream_id_{1};
// 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_;
// Talk-indicator polling thread (separate from udp_thread_ / the miniaudio callback
// thread — see architecture.md §3 real-time rule).
std::thread talk_timer_thread_;
std::atomic<bool> talk_timer_stop_{false};
static constexpr int64_t kTalkPollMs = 100;
static constexpr int64_t kTalkHangoverMs = 300;
// 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};
std::atomic<bool> server_muted_{false};
std::atomic<bool> server_deafened_{false};
// Permissions from last AuthResult.
vc_permissions own_permissions_{};
// Send-side input gate (docs/voice.md §11). MIC-only — SCREEN_AUDIO/
// AUX_DEVICE are never gated (see PROGRESS.md for the rationale). Pure local state, no
// 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<bool> ptt_active_{false};
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<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::unique_ptr<voicecat::audio::ApmProcessor> mic_vad_;
// Send-side mic noise suppressor (RNNoise). Constructed once with the MIC stream alongside
// mic_vad_ (off the RT capture callback); toggling only flips input_noise_reduction_, so the
// capture callback never allocates or races this pointer.
std::unique_ptr<voicecat::audio::ApmProcessor> mic_ns_;
// teardown_voice() is called from run_io() and disconnect() concurrently; this mutex
// makes it idempotent (avoids a double-join race on udp_thread_/talk_timer_thread_).
std::mutex teardown_mu_;
// ── io_thread_ entry point ──────────────────────────────────────────────────
void run_io(std::string host, uint16_t port);
// ── Protocol dispatch (called on io_thread_) ────────────────────────────────
void handle_envelope(const voicecat::v1::Envelope& env);
void handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t req_id);
void handle_auth_result(const voicecat::v1::AuthResult& msg);
void handle_server_state(const voicecat::v1::ServerStateSnapshot& snap);
void handle_user_event(const voicecat::v1::UserEvent& ue);
void handle_channel_event(const voicecat::v1::ChannelEvent& ce);
// Called from handle_channel_event when the current channel's audio config changed:
// stop→start 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
// 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
// on re-announce and returns new effective_audio; peers' sync_remote_streams wire up
// fresh decoders at the new ssrc.
void restart_active_streams_for_channel(uint32_t channel_id);
void handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg);
void handle_text_message(const voicecat::v1::TextMessage& msg);
void handle_disconnect(const voicecat::v1::Disconnect& msg);
void handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg);
void handle_stream_announce_result(uint64_t req_id,
const voicecat::v1::StreamAnnounceResult& msg);
void handle_voice_subscription_result(const voicecat::v1::VoiceSubscriptionResult& msg);
// Wire up remote-stream decoders for all users in the session model (used on voice join).
void resync_remote_streams();
// Tear down all remote-stream decoders and clear remote_streams_ (used on voice leave).
void clear_all_remote_streams();
// Stop every active local stream (used on voice leave — emits STREAM_STOPPED for each).
void stop_all_local_streams();
// ── 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();
// 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
// (docs/voice.md §6). Plaintext — no AEAD — to avoid racing the audio thread's seal().
void send_udp_keepalive();
// capture_cb passed to audio_engine_.start(): encode + seal + send one frame for the
// given local stream `kind` (multiple concurrent local streams are possible).
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 mono→stereo
// 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_
// is held and the send gate/crypto checks have already passed.
void encode_and_send_frame(LocalStream& ls, const int16_t* pcm, int samples, int channels,
int fd);
// 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();
// talk_timer_thread_ entry point: polls audio_engine_ for remote talk-state edges
// and local capture activity, emitting VC_EVENT_TALK_STATE. Never the audio RT thread.
void run_talk_timer();
// Find a LocalStream by its client-assigned stream_id (held under local_streams_mu_ by
// the caller, or taken internally). Returns nullptr if not found/not active.
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);
// 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
// has elapsed. (docs/protocol.md §7)
void send_ping();
// Drain send_queue_ by doing blocking TLS writes (called on io_thread_).
void drain_sends();
// Transition state + emit VC_EVENT_CONNECTION_STATE.
void set_state(vc_connection_state s);
// Convenience event emitters.
void emit_error(vc_result r, const char* text);
void emit_disconnected(vc_result r, const char* reason);
};
#endif // VOICECAT_CORE_CLIENT_H