scaffold: M0 skeleton + agent onboarding (build, architecture, progress)

Turn the design into a buildable, dependency-free M0 skeleton plus the
onboarding layer so a new agent can pick up instantly.

Build system:
- CMake + CMakePresets (dev = no deps; server-release = vcpkg) + vcpkg.json
- Skeleton builds with just a C++20 compiler; deps stay off until needed
- .gitattributes (LF), .gitignore, .clang-format

Core (libvoicecat):
- core/include/voicecat.h: full C ABI (the client/server contract), stubbed
- core/proto/voicecat.proto: control-plane wire format, matches docs/protocol.md
- src/{net,crypto,codec,protocol,session,audio,core}: subsystem stubs that
  return VC_ERR_NOT_IMPLEMENTED, each pointing to its design doc
- server/ (voicecat-server) and tools/vccli/ link the core
- tests/: CTest smoke test asserting the C ABI contract (behavior, not just build)
- clients/{apple,windows}: M4 placeholders

Onboarding for agents:
- CLAUDE.md: hub — build/test commands, architecture at a glance, doc map, rules
- AGENTS.md: working method (behavior-driven; clean compile is the floor not the goal)
- PROGRESS.md: living tracker — M0 done, M1 task checklist, "where we left off"

Verified: cmake --preset dev && cmake --build --preset dev && ctest --preset dev → green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:09:09 +02:00
parent 268d511f79
commit b332b0972b
38 changed files with 1907 additions and 0 deletions

43
core/CMakeLists.txt Normal file
View File

@@ -0,0 +1,43 @@
# libvoicecat — the shared C++ core (docs/architecture.md).
# Sources are globbed so adding a stub under src/<subsystem>/ needs no CMake edit.
file(GLOB_RECURSE VOICECAT_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
if(VOICECAT_BUILD_SHARED)
add_library(voicecat SHARED ${VOICECAT_SOURCES})
else()
add_library(voicecat STATIC ${VOICECAT_SOURCES})
# Static consumers must see VC_API as empty (no dllimport).
target_compile_definitions(voicecat PUBLIC VOICECAT_STATIC)
endif()
add_library(voicecat::voicecat ALIAS voicecat)
target_include_directories(voicecat
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_definitions(voicecat PRIVATE VOICECAT_BUILDING)
target_compile_features(voicecat PUBLIC cxx_std_20)
set_target_properties(voicecat PROPERTIES
C_VISIBILITY_PRESET hidden
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN ON)
if(VOICECAT_USE_VCPKG_DEPS)
# Wire real dependencies here as each subsystem is implemented. Example (uncomment
# the ones a subsystem needs; see docs/tech-stack.md and core/proto for protobuf):
# find_package(unofficial-sodium CONFIG REQUIRED) # crypto/ (libsodium)
# find_package(MbedTLS CONFIG REQUIRED) # crypto/ (TLS 1.3)
# find_package(Opus CONFIG REQUIRED) # codec/
# find_package(protobuf CONFIG REQUIRED) # protocol/
# find_package(asio CONFIG REQUIRED) # net/
# find_package(unofficial-sqlite3 CONFIG REQUIRED) # server persistence
# find_package(spdlog CONFIG REQUIRED)
# target_link_libraries(voicecat PRIVATE Opus::opus protobuf::libprotobuf ...)
#
# protobuf codegen (when protocol/ is implemented):
# find_package(Protobuf CONFIG REQUIRED)
# protobuf_generate(TARGET voicecat PROTOS proto/voicecat.proto LANGUAGE cpp)
message(STATUS "voicecat: deps ON — add find_package()/link calls here as subsystems land")
endif()

223
core/include/voicecat.h Normal file
View File

@@ -0,0 +1,223 @@
/*
* voicecat.h — the C ABI for libvoicecat.
*
* This is the single boundary every front-end calls: Swift (macOS/iOS) and C# (Windows)
* both bind to this header, and the server links the same core. It is C-linkage and
* handle-based so it is stable and trivially bindable from any language.
*
* Design: docs/architecture.md §4. Everything here is async + event-driven — calls return
* immediately and results/state changes arrive via the vc_callbacks.on_event callback.
*
* STATUS: M0 skeleton. Implementations live in core/src and currently return
* VC_ERR_NOT_IMPLEMENTED. The shapes below are the contract to build against.
*/
#ifndef VOICECAT_H
#define VOICECAT_H
#include <stddef.h>
#include <stdint.h>
#if defined(__cplusplus)
extern "C" {
#endif
/* ── Export macro ─────────────────────────────────────────────────────────── */
#if defined(VOICECAT_STATIC)
#define VC_API
#elif defined(_WIN32)
#if defined(VOICECAT_BUILDING)
#define VC_API __declspec(dllexport)
#else
#define VC_API __declspec(dllimport)
#endif
#else
#if defined(VOICECAT_BUILDING)
#define VC_API __attribute__((visibility("default")))
#else
#define VC_API
#endif
#endif
/* ── Version ──────────────────────────────────────────────────────────────── */
#define VOICECAT_VERSION_MAJOR 0
#define VOICECAT_VERSION_MINOR 0
#define VOICECAT_VERSION_PATCH 1
/* The control-protocol version this build speaks (docs/protocol.md §4). */
#define VOICECAT_PROTOCOL_VERSION 1
/* ── Result codes ─────────────────────────────────────────────────────────── */
typedef enum vc_result {
VC_OK = 0,
VC_ERR_NOT_IMPLEMENTED = 1, /* skeleton stub */
VC_ERR_INVALID_ARG = 2,
VC_ERR_NOT_CONNECTED = 3,
VC_ERR_ALREADY = 4,
VC_ERR_AUTH_FAILED = 5,
VC_ERR_PERMISSION_DENIED = 6,
VC_ERR_TIMEOUT = 7,
VC_ERR_IO = 8,
VC_ERR_PROTOCOL = 9,
VC_ERR_CRYPTO = 10,
VC_ERR_AUDIO = 11,
VC_ERR_INTERNAL = 12,
} vc_result;
typedef enum vc_log_level {
VC_LOG_TRACE = 0,
VC_LOG_DEBUG = 1,
VC_LOG_INFO = 2,
VC_LOG_WARN = 3,
VC_LOG_ERROR = 4,
VC_LOG_OFF = 5,
} vc_log_level;
typedef enum vc_connection_state {
VC_STATE_DISCONNECTED = 0,
VC_STATE_CONNECTING = 1,
VC_STATE_TLS_HANDSHAKE = 2,
VC_STATE_AUTHENTICATING = 3,
VC_STATE_CONNECTED = 4,
} vc_connection_state;
typedef enum vc_text_scope {
VC_TEXT_CHANNEL = 0,
VC_TEXT_PRIVATE = 1,
VC_TEXT_SERVER = 2,
} vc_text_scope;
typedef enum vc_device_kind {
VC_DEVICE_INPUT = 0,
VC_DEVICE_OUTPUT = 1,
} vc_device_kind;
typedef enum vc_stream_kind {
VC_STREAM_MIC = 0,
VC_STREAM_SCREEN_AUDIO = 1, /* system/desktop audio (docs/voice.md §9) */
VC_STREAM_AUX_DEVICE = 2,
} vc_stream_kind;
/* Send-side input gate (docs/voice.md §11). */
typedef enum vc_input_mode {
VC_INPUT_VOICE_ACTIVATION = 0,
VC_INPUT_PUSH_TO_TALK = 1,
} vc_input_mode;
typedef enum vc_event_type {
VC_EVENT_CONNECTION_STATE = 0, /* connection_state set */
VC_EVENT_AUTH_RESULT = 1, /* result set; user_id = self on success */
VC_EVENT_CHANNEL_LIST = 2, /* channel tree snapshot/delta available */
VC_EVENT_USER_JOINED = 3, /* user_id, channel_id, text = nickname */
VC_EVENT_USER_LEFT = 4, /* user_id */
VC_EVENT_USER_UPDATED = 5, /* user_id */
VC_EVENT_TEXT_MESSAGE = 6, /* text_scope, user_id (sender), channel_id, text */
VC_EVENT_STREAM_STARTED = 7, /* user_id, stream_id */
VC_EVENT_STREAM_STOPPED = 8, /* user_id, stream_id */
VC_EVENT_TALK_STATE = 9, /* user_id, stream_id, u32a = talking(0/1) */
VC_EVENT_ERROR = 10, /* result, text */
VC_EVENT_DISCONNECTED = 11, /* result, text = reason */
} vc_event_type;
/* ── Structs ──────────────────────────────────────────────────────────────── */
/*
* An event delivered to vc_callbacks.on_event. Pointer fields are owned by the core and
* valid ONLY for the duration of the callback — copy what you need. Which fields are
* meaningful depends on `type` (see vc_event_type comments above).
*/
typedef struct vc_event {
vc_event_type type;
vc_connection_state connection_state;
int32_t result; /* vc_result */
uint32_t user_id;
uint32_t channel_id;
uint32_t stream_id;
vc_text_scope text_scope;
uint32_t u32a; /* generic small payload, meaning per event type */
const char* text;
uint64_t timestamp_unix_ms;
} vc_event;
typedef struct vc_callbacks {
/* State changes, messages, presence. Called on the core's event thread. */
void (*on_event)(void* user, const vc_event* ev);
/* Throttled level meter (RMS 0..1) for a local or remote stream; may be NULL. */
void (*on_level)(void* user, uint32_t stream_id, float rms);
void* user;
} vc_callbacks;
typedef struct vc_config {
const char* client_name; /* e.g. "VoiceCat-macOS" */
const char* client_version; /* e.g. "0.0.1" */
vc_log_level log_level;
} vc_config;
typedef struct vc_stream_desc {
vc_stream_kind kind;
const char* device_id; /* NULL = default device for this kind */
const char* label; /* human label, e.g. "Microphone" */
} vc_stream_desc;
typedef struct vc_device {
const char* id;
const char* name;
int is_default; /* bool */
} vc_device;
typedef struct vc_device_list {
vc_device* items;
size_t count;
} vc_device_list;
/* Opaque client handle. */
typedef struct vc_client vc_client;
/* ── Lifecycle ────────────────────────────────────────────────────────────── */
VC_API const char* vc_version_string(void);
VC_API const char* vc_result_string(vc_result code);
VC_API vc_client* vc_client_create(const vc_config* cfg, vc_callbacks cb);
VC_API void vc_client_destroy(vc_client* c);
/* ── Connection & auth (async; results via on_event) ──────────────────────── */
VC_API vc_result vc_connect(vc_client* c, const char* host, uint16_t port);
VC_API vc_result vc_disconnect(vc_client* c);
VC_API vc_result vc_authenticate_guest(vc_client* c, const char* nickname);
VC_API vc_result vc_authenticate_user(vc_client* c, const char* username,
const char* password);
/* ── Channels ─────────────────────────────────────────────────────────────── */
VC_API vc_result vc_join_channel(vc_client* c, uint32_t channel_id,
const char* password /* nullable */);
VC_API vc_result vc_leave_channel(vc_client* c);
/* ── Local media streams (mic / screen audio / aux) ───────────────────────── */
VC_API vc_result vc_stream_start(vc_client* c, const vc_stream_desc* desc,
uint32_t* out_stream_id);
VC_API vc_result vc_stream_stop(vc_client* c, uint32_t stream_id);
VC_API vc_result vc_set_input_device(vc_client* c, uint32_t stream_id,
const char* device_id);
/* Send-side: input gate mode + PTT key state, and self mute/deafen. */
VC_API vc_result vc_set_input_mode(vc_client* c, vc_input_mode mode);
VC_API vc_result vc_set_push_to_talk(vc_client* c, int active /* bool */);
VC_API vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened);
/* Receive-side, per remote stream, all LOCAL (no protocol traffic) — docs/voice.md §10:
* gain (0..) , mute, and listener-chosen noise reduction on a specific user's stream. */
VC_API vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id,
float gain, int muted, int noise_reduction);
/* ── Text ─────────────────────────────────────────────────────────────────── */
VC_API vc_result vc_send_text(vc_client* c, vc_text_scope scope, uint32_t target_id,
const char* utf8);
/* ── Device enumeration (for UI pickers) ──────────────────────────────────── */
VC_API vc_result vc_list_devices(vc_client* c, vc_device_kind kind, vc_device_list* out);
VC_API void vc_free_device_list(vc_device_list* list);
#if defined(__cplusplus)
} /* extern "C" */
#endif
#endif /* VOICECAT_H */

238
core/proto/voicecat.proto Normal file
View File

@@ -0,0 +1,238 @@
// VoiceCat control-plane wire format. SOURCE OF TRUTH for the protocol.
// Spec: docs/protocol.md. Each TCP/TLS frame is [u32 length][encoded Envelope].
// Media frames (voice) are NOT here — they use the fixed binary header in docs/voice.md §2.
//
// Extensibility rules (docs/protocol.md §8): never reuse/renumber tags; new oneof arms and
// fields are additive; gate new features behind capability strings in ClientHello/ServerHello.
syntax = "proto3";
package voicecat.v1;
// ── Envelope ──────────────────────────────────────────────────────────────────
message Envelope {
// Nonzero on a request; echoed in the matching response for correlation. 0 = unsolicited.
uint64 request_id = 1;
oneof body {
// Session / handshake (tags 1019)
ClientHello client_hello = 10;
ServerHello server_hello = 11;
AuthRequest auth_request = 12;
AuthResult auth_result = 13;
Disconnect disconnect = 14;
Ping ping = 15;
Pong pong = 16;
// State sync (2029)
ServerStateSnapshot server_state = 20;
ChannelEvent channel_event = 21;
UserEvent user_event = 22;
SubscribeRequest subscribe = 23;
// Channel operations (3039)
JoinChannelRequest join_channel = 30;
JoinChannelResult join_channel_result = 31;
LeaveChannelRequest leave_channel = 32;
CreateChannelRequest create_channel = 33;
EditChannelRequest edit_channel = 34;
DeleteChannelRequest delete_channel = 35;
MoveUserRequest move_user = 36;
GenericResult generic_result = 37;
// Voice signaling — media is on UDP (4049)
StreamAnnounce stream_announce = 40;
StreamAnnounceResult stream_announce_result = 41;
StreamStop stream_stop = 42;
StreamStateUpdate stream_state = 43;
UdpBinding udp_binding = 44;
// Text (5059)
TextMessage text_message = 50;
TextMessageAck text_message_ack = 51;
TypingIndicator typing = 52;
// Moderation / permissions (6069)
KickRequest kick = 60;
BanRequest ban = 61;
SetPermissionRequest set_permission = 62;
// Admin account management — privileged; accounts are admin-provisioned (7079)
CreateAccountRequest create_account = 70;
ResetPasswordRequest reset_password = 71;
DeleteAccountRequest delete_account = 72;
ListAccountsRequest list_accounts = 73;
// Future families: file transfer = 100109. Extension escape hatch = 200+.
Extension extension = 200;
}
}
// ── Enums ──────────────────────────────────────────────────────────────────────
enum ChannelType { CHANNEL_PERMANENT = 0; CHANNEL_TEMPORARY = 1; }
enum ChannelMode { MODE_MONO = 0; MODE_STEREO = 1; }
enum StreamKind { STREAM_MIC = 0; STREAM_SCREEN_AUDIO = 1; STREAM_AUX_DEVICE = 2; }
enum TextScope { TEXT_CHANNEL = 0; TEXT_PRIVATE = 1; TEXT_SERVER = 2; }
enum OpusApplication { OPUS_VOIP = 0; OPUS_AUDIO = 1; OPUS_LOWDELAY = 2; }
// ── Common types ────────────────────────────────────────────────────────────────
message AudioConfig {
uint32 codec = 1; // 0 = OPUS
ChannelMode mode = 2;
uint32 sample_rate = 3; // 48000 recommended
uint32 bitrate_bps = 4;
uint32 frame_ms = 5; // 2.5/5/10/20/40/60
OpusApplication application = 6;
bool fec = 7;
uint32 expected_packet_loss = 8; // %
bool dtx = 9;
uint32 complexity = 10; // 0..10
}
message StreamInfo {
uint32 stream_id = 1; // unique within the user
uint32 ssrc = 2; // media-plane id assigned by server
StreamKind kind = 3;
AudioConfig audio = 4;
string label = 5;
}
message Channel {
uint32 id = 1;
uint32 parent_id = 2; // 0 = root
string name = 3;
string topic = 4;
bool password_protected = 5;
uint32 max_users = 6;
ChannelType type = 7;
AudioConfig audio = 8;
int32 order = 9;
}
message User {
uint32 id = 1;
string nickname = 2;
bool is_guest = 3;
uint32 channel_id = 4;
bool self_mic_muted = 5;
bool self_deafened = 6;
bool server_muted = 7;
repeated StreamInfo streams = 8;
}
message Permissions {
// Minimal v1 flag set; the moderation milestone expands this. Server-side is authoritative.
bool can_create_temp_channel = 1;
bool can_kick = 2;
bool can_ban = 3;
bool can_move_users = 4;
bool can_admin_accounts = 5;
bool is_admin = 6;
}
// ── Session / handshake ─────────────────────────────────────────────────────────
message ClientHello {
uint32 proto_version = 1;
repeated string features = 2; // "opus", "fec", "screen-audio", ...
string client_name = 3;
string client_version = 4;
string preferred_locale = 5;
}
message ServerHello {
uint32 proto_version = 1;
repeated string features = 2;
string server_name = 3;
string server_version = 4;
repeated string auth_methods = 5; // "guest", "password"
uint32 udp_port = 6;
bytes server_identity_fingerprint = 7; // Ed25519 fp for TOFU
}
message GuestAuth { string nickname = 1; }
message PasswordAuth { string username = 1; string password = 2; }
message AuthRequest {
oneof method {
GuestAuth guest = 1;
PasswordAuth password = 2;
}
}
message AuthResult {
bool ok = 1;
string error = 2;
uint64 session_id = 3;
User self = 4;
Permissions permissions = 5;
bytes udp_token = 6; // bind the UDP 5-tuple with this (security.md §3)
}
message Disconnect { uint32 code = 1; string reason = 2; }
message Ping { uint64 nonce = 1; }
message Pong { uint64 nonce = 1; }
// ── State sync ──────────────────────────────────────────────────────────────────
message ServerStateSnapshot {
repeated Channel channels = 1;
repeated User users = 2;
}
message ChannelEvent {
enum Kind { CREATED = 0; UPDATED = 1; DELETED = 2; }
Kind kind = 1;
Channel channel = 2;
uint32 deleted_id = 3;
}
message UserEvent {
enum Kind { JOINED = 0; LEFT = 1; UPDATED = 2; }
Kind kind = 1;
User user = 2;
uint32 left_id = 3;
}
message SubscribeRequest { repeated uint32 channel_ids = 1; bool presence = 2; }
// ── Channel operations ──────────────────────────────────────────────────────────
message JoinChannelRequest { uint32 channel_id = 1; string password = 2; }
message JoinChannelResult {
bool ok = 1; string error = 2;
uint32 channel_id = 3;
repeated User members = 4;
AudioConfig audio = 5; // authoritative channel Opus params
}
message LeaveChannelRequest {}
message CreateChannelRequest { Channel channel = 1; string password = 2; }
message EditChannelRequest { Channel channel = 1; string password = 2; }
message DeleteChannelRequest { uint32 channel_id = 1; }
message MoveUserRequest { uint32 user_id = 1; uint32 channel_id = 2; }
message GenericResult { bool ok = 1; uint32 code = 2; string message = 3; }
// ── Voice signaling ─────────────────────────────────────────────────────────────
message StreamAnnounce { StreamKind kind = 1; AudioConfig requested_audio = 2; string label = 3; }
message StreamAnnounceResult{ bool ok = 1; string error = 2; uint32 stream_id = 3; uint32 ssrc = 4; AudioConfig effective_audio = 5; }
message StreamStop { uint32 stream_id = 1; }
message StreamStateUpdate { uint32 user_id = 1; uint32 stream_id = 2; bool muted = 3; bool talking = 4; }
message UdpBinding { bytes udp_token = 1; bool ack = 2; }
// ── Text (ephemeral — server does not persist history, docs/protocol.md §5) ──────
message TextMessage {
TextScope scope = 1;
uint32 target_id = 2; // channel_id or user_id per scope
uint32 sender_id = 3; // set by server on relay
string body = 4; // UTF-8, server-bounded length
uint64 sent_at_unix_ms = 5; // server timestamp on relay
string client_msg_id = 6; // echoed in ack (dedup)
}
message TextMessageAck { string client_msg_id = 1; bool ok = 2; }
message TypingIndicator { TextScope scope = 1; uint32 target_id = 2; uint32 user_id = 3; }
// ── Moderation / permissions ─────────────────────────────────────────────────────
message KickRequest { uint32 user_id = 1; string reason = 2; }
message BanRequest { uint32 user_id = 1; string reason = 2; uint64 expires_unix_ms = 3; }
message SetPermissionRequest { uint32 user_id = 1; Permissions permissions = 2; }
// ── Admin account management (privileged) ────────────────────────────────────────
message CreateAccountRequest { string username = 1; string password = 2; }
message ResetPasswordRequest { string username = 1; string new_password = 2; }
message DeleteAccountRequest { string username = 1; }
message ListAccountsRequest {}
// ── Extension escape hatch ───────────────────────────────────────────────────────
message Extension { string ns = 1; bytes payload = 2; }

View File

@@ -0,0 +1,8 @@
#include "audio/audio_engine.h"
namespace voicecat::audio {
// M0 stub. Capture/playback (miniaudio), APM DSP, jitter buffer, and mixer land in M2/M3.
// See docs/voice.md §811.
} // namespace voicecat::audio

View File

@@ -0,0 +1,40 @@
/*
* audio/audio_engine.h — capture/playback + DSP + jitter buffer + mixer.
*
* Design: docs/voice.md §811. Real-time path:
* capture(miniaudio) → APM(AEC/NS/AGC/VAD, send-side) → Opus encode → ...
* ... → Opus decode → per-user recv NS (listener-chosen) → gain/mute → mix → playback
*
* REAL-TIME RULE: audio-callback threads never allocate, lock, or block (architecture.md §3).
*
* STATUS: M0 stub.
*/
#ifndef VOICECAT_AUDIO_AUDIO_ENGINE_H
#define VOICECAT_AUDIO_AUDIO_ENGINE_H
#include <cstdint>
namespace voicecat::audio {
// Adaptive per-ssrc jitter buffer (voice.md §5). TODO(M2).
class JitterBuffer {
public:
uint32_t target_depth_ms() const { return target_depth_ms_; }
private:
uint32_t target_depth_ms_ = 40;
};
// Owns miniaudio capture/playback, the APM instances, codecs, jitter buffers, and the mixer.
class AudioEngine {
public:
// TODO(M2): start/stop capture+playback; push/pull frames via lock-free ring buffers.
bool running() const { return running_; }
private:
bool running_ = false;
};
} // namespace voicecat::audio
#endif // VOICECAT_AUDIO_AUDIO_ENGINE_H

View File

@@ -0,0 +1,7 @@
#include "codec/opus_codec.h"
namespace voicecat::codec {
// M0 stub. Brought up in M2. See docs/voice.md §34.
} // namespace voicecat::codec

View File

@@ -0,0 +1,39 @@
/*
* codec/opus_codec.h — Opus encode/decode (libopus 1.6).
*
* Design: docs/voice.md §34. Per-channel AudioConfig (mono/stereo, bitrate, frame size,
* FEC, DTX, complexity). The server relays Opus payloads unmodified (no transcode).
*
* STATUS: M0 stub.
*/
#ifndef VOICECAT_CODEC_OPUS_CODEC_H
#define VOICECAT_CODEC_OPUS_CODEC_H
#include <cstdint>
namespace voicecat::codec {
struct OpusParams {
uint32_t sample_rate = 48000;
uint32_t bitrate_bps = 24000;
uint32_t frame_ms = 20;
bool stereo = false;
bool fec = true;
bool dtx = true;
uint32_t complexity = 10;
uint32_t expected_packet_loss = 0;
};
class OpusEncoder {
public:
// TODO(M2): init(params); encode(pcm, frame) -> opus bytes.
};
class OpusDecoder {
public:
// TODO(M2): init(params); decode(opus, out_pcm); PLC on loss; FEC from next packet.
};
} // namespace voicecat::codec
#endif // VOICECAT_CODEC_OPUS_CODEC_H

48
core/src/core/client.cpp Normal file
View File

@@ -0,0 +1,48 @@
#include "core/client.h"
namespace {
constexpr vc_result kStub = VC_ERR_NOT_IMPLEMENTED;
} // namespace
vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {}
vc_client::~vc_client() = default;
void vc_client::emit(const vc_event& ev) const {
if (cb_.on_event != nullptr) {
cb_.on_event(cb_.user, &ev);
}
}
// ── Connection & auth ────────────────────────────────────────────────────────
// TODO(M1): drive the TLS 1.3 control channel + handshake state machine here, updating
// state_ and emitting VC_EVENT_CONNECTION_STATE as it advances. docs/protocol.md §4.
vc_result vc_client::connect(const char*, uint16_t) { return kStub; }
vc_result vc_client::disconnect() { return kStub; }
vc_result vc_client::authenticate_guest(const char*) { return kStub; }
vc_result vc_client::authenticate_user(const char*, const char*) { return kStub; }
// ── Channels ─────────────────────────────────────────────────────────────────
vc_result vc_client::join_channel(uint32_t, const char*) { return kStub; }
vc_result vc_client::leave_channel() { return kStub; }
// ── Local media streams ──────────────────────────────────────────────────────
// TODO(M2/M3): allocate a stream id, announce it over the control channel, and start the
// capture→APM→Opus→AEAD→UDP pipeline. docs/voice.md.
vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) { return kStub; }
vc_result vc_client::stream_stop(uint32_t) { return kStub; }
vc_result vc_client::set_input_device(uint32_t, const char*) { return kStub; }
vc_result vc_client::set_input_mode(vc_input_mode) { return kStub; }
vc_result vc_client::set_push_to_talk(bool) { return kStub; }
vc_result vc_client::set_self_mute(bool, bool) { return kStub; }
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) { return kStub; }
// ── Text ─────────────────────────────────────────────────────────────────────
vc_result vc_client::send_text(vc_text_scope, uint32_t, const char*) { return kStub; }
// ── Devices ──────────────────────────────────────────────────────────────────
vc_result vc_client::list_devices(vc_device_kind, vc_device_list* out) {
out->items = nullptr;
out->count = 0;
return kStub;
}

53
core/src/core/client.h Normal file
View File

@@ -0,0 +1,53 @@
/*
* client.h — the implementation type behind the opaque `vc_client*` handle.
*
* M0 skeleton: holds config/callbacks/state and returns VC_ERR_NOT_IMPLEMENTED for
* everything that needs a subsystem. As subsystems land (docs/architecture.md §2), this
* class wires them together: a net transport, a protocol state machine, a session model,
* and an audio engine, plus the event thread that drains into vc_callbacks.on_event.
*/
#ifndef VOICECAT_CORE_CLIENT_H
#define VOICECAT_CORE_CLIENT_H
#include "voicecat.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 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_input_mode(vc_input_mode mode);
vc_result set_push_to_talk(bool active);
vc_result set_self_mute(bool mic_muted, bool deafened);
vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted,
bool noise_reduction);
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);
vc_connection_state state() const { return state_; }
private:
// Deliver an event to the host application. Safe to call with cb_.on_event == nullptr.
void emit(const vc_event& ev) const;
vc_config cfg_{};
vc_callbacks cb_{};
vc_connection_state state_ = VC_STATE_DISCONNECTED;
};
#endif // VOICECAT_CORE_CLIENT_H

View File

@@ -0,0 +1,8 @@
#include "crypto/crypto.h"
namespace voicecat::crypto {
// M0 stub. Brought up in M1 (TLS 1.3 via mbedTLS) and M2 (media AEAD via libsodium).
// See docs/security.md §12.
} // namespace voicecat::crypto

40
core/src/crypto/crypto.h Normal file
View File

@@ -0,0 +1,40 @@
/*
* crypto/crypto.h — TLS 1.3 (mbedTLS) and the media AEAD (libsodium).
*
* Design: docs/security.md. Control channel = TLS 1.3. Media = keys exported from the TLS
* session (RFC 5705 / 8446) + per-frame ChaCha20-Poly1305 with a counter nonce and a
* sliding-window replay filter. Encryption is MANDATORY — never add a plaintext path.
*
* STATUS: M0 stub.
*/
#ifndef VOICECAT_CRYPTO_CRYPTO_H
#define VOICECAT_CRYPTO_CRYPTO_H
#include <cstddef>
#include <cstdint>
namespace voicecat::crypto {
// TLS 1.3 endpoint wrapper (mbedTLS). Provides the keying-material exporter that seeds
// MediaCrypto, so the UDP path inherits the authenticated control session's trust.
class TlsContext {
public:
// TODO(M1): client/server handshake; read/write; export_keying_material(label,...).
};
// Per-frame media encryption. Abstracted so the backend (exported-key AEAD now; a DTLS 1.3
// backend later, if a permissive impl matures) is swappable without touching voice code.
class MediaCrypto {
public:
virtual ~MediaCrypto() = default;
// seal/open one voice frame; `aad` carries the routable header fields (e.g. ssrc).
// Returns bytes written, or -1 on failure (replay/auth). TODO(M2).
virtual long seal(const uint8_t* plain, size_t len, const uint8_t* aad, size_t aad_len,
uint8_t* out, size_t out_cap) = 0;
virtual long open(const uint8_t* sealed, size_t len, const uint8_t* aad, size_t aad_len,
uint8_t* out, size_t out_cap) = 0;
};
} // namespace voicecat::crypto
#endif // VOICECAT_CRYPTO_CRYPTO_H

View File

@@ -0,0 +1,8 @@
#include "net/transport.h"
namespace voicecat::net {
// M0 stub. Subsystem brought up in M1 (TCP/TLS) and M2 (UDP). See docs/protocol.md,
// docs/voice.md, and AGENTS.md "Suggested first steps".
} // namespace voicecat::net

39
core/src/net/transport.h Normal file
View File

@@ -0,0 +1,39 @@
/*
* net/transport.h — TCP control channel + UDP media channel.
*
* Design: docs/architecture.md (Net thread), docs/protocol.md §1 (framing), docs/voice.md §2
* (UDP frame). Implementation will use standalone Asio (one reactor) for sockets/timers.
*
* STATUS: M0 stub — interfaces only, no Asio yet.
*/
#ifndef VOICECAT_NET_TRANSPORT_H
#define VOICECAT_NET_TRANSPORT_H
#include <cstdint>
#include <string>
namespace voicecat::net {
// Length-prefixed [u32 length][payload] framing over a TLS 1.3 byte stream (protocol.md §1).
class TcpControlChannel {
public:
// TODO(M1): connect(host, port), TLS handshake, send/recv framed Envelopes.
bool connected() const { return connected_; }
private:
bool connected_ = false;
};
// UDP media channel: encrypted voice frames (voice.md §2), bound to a session via token.
class UdpMediaChannel {
public:
// TODO(M2): bind, send/recv AEAD-sealed voice frames, keepalive.
bool bound() const { return bound_; }
private:
bool bound_ = false;
};
} // namespace voicecat::net
#endif // VOICECAT_NET_TRANSPORT_H

View File

@@ -0,0 +1,11 @@
#include "protocol/protocol.h"
namespace voicecat::protocol {
// M0 stub. The frame codec + protobuf Envelope dispatch are the first M1 task
// (AGENTS.md "Suggested first steps" #1). See docs/protocol.md §15.
bool FrameCodec::feed(const uint8_t*, size_t, std::vector<std::vector<uint8_t>>&) {
return true; // TODO(M1): real framing.
}
} // namespace voicecat::protocol

View File

@@ -0,0 +1,33 @@
/*
* protocol/protocol.h — control-plane (de)serialization + routing.
*
* Design: docs/protocol.md. Wire format is a length-prefixed protobuf `Envelope`
* (core/proto/voicecat.proto). This layer parses frames into Envelopes, correlates
* request_id ↔ response, and dispatches to handlers. Media frames do NOT come through here
* (they use the fixed binary header in voice.md §2).
*
* STATUS: M0 stub — protobuf codegen is wired in CMake (commented) and turned on in M1.
*/
#ifndef VOICECAT_PROTOCOL_PROTOCOL_H
#define VOICECAT_PROTOCOL_PROTOCOL_H
#include <cstddef>
#include <cstdint>
#include <vector>
namespace voicecat::protocol {
constexpr uint32_t kProtocolVersion = 1; // docs/protocol.md §4
constexpr uint32_t kMaxFrameBytes = 16u * 1024 * 1024; // §1 oversized-frame guard
// Reads/writes [u32 length][payload] frames from a byte stream. TODO(M1).
class FrameCodec {
public:
// Append received bytes; pop complete frame payloads. Returns false on protocol error
// (e.g. length > kMaxFrameBytes).
bool feed(const uint8_t* data, size_t len, std::vector<std::vector<uint8_t>>& out_frames);
};
} // namespace voicecat::protocol
#endif // VOICECAT_PROTOCOL_PROTOCOL_H

View File

@@ -0,0 +1,8 @@
#include "session/session.h"
namespace voicecat::session {
// M0 stub. Channel tree, users, streams, permissions, and ephemeral text relay land in M1.
// See docs/protocol.md §5.
} // namespace voicecat::session

View File

@@ -0,0 +1,55 @@
/*
* session/session.h — domain model: channels, users, streams, permissions, text.
*
* Design: docs/protocol.md §5, docs/architecture.md §5. Shared by client (local mirror of
* server state) and server (authoritative). Text is ephemeral (no history). Accounts are
* admin-provisioned.
*
* STATUS: M0 stub.
*/
#ifndef VOICECAT_SESSION_SESSION_H
#define VOICECAT_SESSION_SESSION_H
#include <cstdint>
#include <string>
#include <vector>
namespace voicecat::session {
struct Channel {
uint32_t id = 0;
uint32_t parent_id = 0;
std::string name;
bool password_protected = false;
uint32_t max_users = 0;
};
struct Stream {
uint32_t stream_id = 0;
uint32_t ssrc = 0;
int kind = 0; // vc_stream_kind
std::string label;
};
struct User {
uint32_t id = 0;
std::string nickname;
bool is_guest = true;
uint32_t channel_id = 0;
std::vector<Stream> streams;
};
// Mirror/authority for the channel tree + user list. TODO(M1): snapshot + delta apply.
class SessionModel {
public:
const std::vector<Channel>& channels() const { return channels_; }
const std::vector<User>& users() const { return users_; }
private:
std::vector<Channel> channels_;
std::vector<User> users_;
};
} // namespace voicecat::session
#endif // VOICECAT_SESSION_SESSION_H

137
core/src/voicecat.cpp Normal file
View File

@@ -0,0 +1,137 @@
/*
* voicecat.cpp — C ABI implementation (M0 skeleton).
*
* Lifecycle (create/destroy) and trivial accessors are real. Everything that needs a
* subsystem (net/crypto/codec/protocol/session/audio) returns VC_ERR_NOT_IMPLEMENTED for
* now and is the work of M1+ (see AGENTS.md / docs/roadmap.md).
*/
#include "voicecat.h"
#include <new>
#include "core/client.h"
#define VC_STR2(x) #x
#define VC_STR(x) VC_STR2(x)
extern "C" {
const char* vc_version_string(void) {
static const char* kVersion = VC_STR(VOICECAT_VERSION_MAJOR) "." VC_STR(
VOICECAT_VERSION_MINOR) "." VC_STR(VOICECAT_VERSION_PATCH);
return kVersion;
}
const char* vc_result_string(vc_result code) {
switch (code) {
case VC_OK: return "ok";
case VC_ERR_NOT_IMPLEMENTED: return "not implemented";
case VC_ERR_INVALID_ARG: return "invalid argument";
case VC_ERR_NOT_CONNECTED: return "not connected";
case VC_ERR_ALREADY: return "already in requested state";
case VC_ERR_AUTH_FAILED: return "authentication failed";
case VC_ERR_PERMISSION_DENIED: return "permission denied";
case VC_ERR_TIMEOUT: return "timeout";
case VC_ERR_IO: return "i/o error";
case VC_ERR_PROTOCOL: return "protocol error";
case VC_ERR_CRYPTO: return "crypto error";
case VC_ERR_AUDIO: return "audio error";
case VC_ERR_INTERNAL: return "internal error";
}
return "unknown";
}
vc_client* vc_client_create(const vc_config* cfg, vc_callbacks cb) {
if (cfg == nullptr) return nullptr;
return new (std::nothrow) vc_client(*cfg, cb);
}
void vc_client_destroy(vc_client* c) { delete c; }
/* ── Everything below delegates to the (stub) client. ─────────────────────── */
vc_result vc_connect(vc_client* c, const char* host, uint16_t port) {
if (c == nullptr || host == nullptr) return VC_ERR_INVALID_ARG;
return c->connect(host, port);
}
vc_result vc_disconnect(vc_client* c) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->disconnect();
}
vc_result vc_authenticate_guest(vc_client* c, const char* nickname) {
if (c == nullptr || nickname == nullptr) return VC_ERR_INVALID_ARG;
return c->authenticate_guest(nickname);
}
vc_result vc_authenticate_user(vc_client* c, const char* username, const char* password) {
if (c == nullptr || username == nullptr || password == nullptr) return VC_ERR_INVALID_ARG;
return c->authenticate_user(username, password);
}
vc_result vc_join_channel(vc_client* c, uint32_t channel_id, const char* password) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->join_channel(channel_id, password);
}
vc_result vc_leave_channel(vc_client* c) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->leave_channel();
}
vc_result vc_stream_start(vc_client* c, const vc_stream_desc* desc, uint32_t* out_stream_id) {
if (c == nullptr || desc == nullptr) return VC_ERR_INVALID_ARG;
return c->stream_start(*desc, out_stream_id);
}
vc_result vc_stream_stop(vc_client* c, uint32_t stream_id) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->stream_stop(stream_id);
}
vc_result vc_set_input_device(vc_client* c, uint32_t stream_id, const char* device_id) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->set_input_device(stream_id, device_id);
}
vc_result vc_set_input_mode(vc_client* c, vc_input_mode mode) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->set_input_mode(mode);
}
vc_result vc_set_push_to_talk(vc_client* c, int active) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->set_push_to_talk(active != 0);
}
vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->set_self_mute(mic_muted != 0, deafened != 0);
}
vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, float gain,
int muted, int noise_reduction) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->set_remote_stream(user_id, stream_id, gain, muted != 0, noise_reduction != 0);
}
vc_result vc_send_text(vc_client* c, vc_text_scope scope, uint32_t target_id,
const char* utf8) {
if (c == nullptr || utf8 == nullptr) return VC_ERR_INVALID_ARG;
return c->send_text(scope, target_id, utf8);
}
vc_result vc_list_devices(vc_client* c, vc_device_kind kind, vc_device_list* out) {
if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG;
return c->list_devices(kind, out);
}
void vc_free_device_list(vc_device_list* list) {
if (list == nullptr) return;
/* Stub: no allocation yet. Real impl frees list->items here. */
list->items = nullptr;
list->count = 0;
}
} // extern "C"