From b332b0972b6ad76726570a2794661ff68a7ec340 Mon Sep 17 00:00:00 2001 From: Talon Date: Mon, 15 Jun 2026 21:09:09 +0200 Subject: [PATCH] scaffold: M0 skeleton + agent onboarding (build, architecture, progress) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .clang-format | 15 ++ .gitattributes | 26 ++++ .gitignore | 40 ++++++ AGENTS.md | 94 +++++++++++++ CLAUDE.md | 141 +++++++++++++++++++ CMakeLists.txt | 50 +++++++ CMakePresets.json | 42 ++++++ PROGRESS.md | 88 ++++++++++++ README.md | 58 ++++++++ clients/apple/README.md | 19 +++ clients/windows/README.md | 18 +++ core/CMakeLists.txt | 43 ++++++ core/include/voicecat.h | 223 ++++++++++++++++++++++++++++++ core/proto/voicecat.proto | 238 ++++++++++++++++++++++++++++++++ core/src/audio/audio_engine.cpp | 8 ++ core/src/audio/audio_engine.h | 40 ++++++ core/src/codec/opus_codec.cpp | 7 + core/src/codec/opus_codec.h | 39 ++++++ core/src/core/client.cpp | 48 +++++++ core/src/core/client.h | 53 +++++++ core/src/crypto/crypto.cpp | 8 ++ core/src/crypto/crypto.h | 40 ++++++ core/src/net/transport.cpp | 8 ++ core/src/net/transport.h | 39 ++++++ core/src/protocol/protocol.cpp | 11 ++ core/src/protocol/protocol.h | 33 +++++ core/src/session/session.cpp | 8 ++ core/src/session/session.h | 55 ++++++++ core/src/voicecat.cpp | 137 ++++++++++++++++++ server/CMakeLists.txt | 6 + server/src/main.cpp | 68 +++++++++ server/src/server.cpp | 20 +++ server/src/server.h | 40 ++++++ tests/CMakeLists.txt | 8 ++ tests/test_smoke.cpp | 65 +++++++++ tools/vccli/CMakeLists.txt | 3 + tools/vccli/src/main.cpp | 47 +++++++ vcpkg.json | 21 +++ 38 files changed, 1907 insertions(+) create mode 100644 .clang-format create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CMakeLists.txt create mode 100644 CMakePresets.json create mode 100644 PROGRESS.md create mode 100644 README.md create mode 100644 clients/apple/README.md create mode 100644 clients/windows/README.md create mode 100644 core/CMakeLists.txt create mode 100644 core/include/voicecat.h create mode 100644 core/proto/voicecat.proto create mode 100644 core/src/audio/audio_engine.cpp create mode 100644 core/src/audio/audio_engine.h create mode 100644 core/src/codec/opus_codec.cpp create mode 100644 core/src/codec/opus_codec.h create mode 100644 core/src/core/client.cpp create mode 100644 core/src/core/client.h create mode 100644 core/src/crypto/crypto.cpp create mode 100644 core/src/crypto/crypto.h create mode 100644 core/src/net/transport.cpp create mode 100644 core/src/net/transport.h create mode 100644 core/src/protocol/protocol.cpp create mode 100644 core/src/protocol/protocol.h create mode 100644 core/src/session/session.cpp create mode 100644 core/src/session/session.h create mode 100644 core/src/voicecat.cpp create mode 100644 server/CMakeLists.txt create mode 100644 server/src/main.cpp create mode 100644 server/src/server.cpp create mode 100644 server/src/server.h create mode 100644 tests/CMakeLists.txt create mode 100644 tests/test_smoke.cpp create mode 100644 tools/vccli/CMakeLists.txt create mode 100644 tools/vccli/src/main.cpp create mode 100644 vcpkg.json diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..28e0d33 --- /dev/null +++ b/.clang-format @@ -0,0 +1,15 @@ +# VoiceCat C++ style. Keep formatting boring and consistent. +BasedOnStyle: Google +Language: Cpp +Standard: c++20 +ColumnLimit: 100 +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +AccessModifierOffset: -2 +PointerAlignment: Left +DerivePointerAlignment: false +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: false +SortIncludes: CaseInsensitive +IncludeBlocks: Regroup diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2d0912a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,26 @@ +# Normalize line endings: store text as LF in the repo, check out native. +* text=auto + +# Force LF for source and docs regardless of platform (silences CRLF warnings). +*.md text eol=lf +*.h text eol=lf +*.hpp text eol=lf +*.c text eol=lf +*.cpp text eol=lf +*.cmake text eol=lf +*.json text eol=lf +*.proto text eol=lf +*.toml text eol=lf +*.sh text eol=lf +*.swift text eol=lf +*.cs text eol=lf +CMakeLists.txt text eol=lf + +# Windows scripts stay CRLF. +*.bat text eol=crlf +*.ps1 text eol=crlf + +# Binary assets. +*.png binary +*.ico binary +*.icns binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b807ead --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Build output +/build/ +/out/ +*.o +*.obj +*.a +*.lib +*.so +*.dylib +*.dll +*.exe +*.pdb + +# vcpkg +/vcpkg_installed/ +/vcpkg/ + +# Generated protobuf +*.pb.cc +*.pb.h +*_pb2.py + +# Server runtime data (self-host data dir, keys, db) +/voicecat-data/ +*.sqlite +*.sqlite-* + +# IDE / OS +.vs/ +.vscode/ +.idea/ +*.user +.DS_Store +Thumbs.db + +# Apple / Windows client build artifacts (added in M4) +clients/apple/**/build/ +clients/apple/**/*.xcodeproj/xcuserdata/ +clients/windows/**/bin/ +clients/windows/**/obj/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e41b7da --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# AGENTS.md — working method + +This file is the **working method** for a developer or AI agent picking up VoiceCat. Companion +files: + +- [`CLAUDE.md`](CLAUDE.md) — the hub: build/test commands, architecture at a glance, doc map. +- [`PROGRESS.md`](PROGRESS.md) — living tracker: what's done, what's next. **Update it as you work.** +- [`docs/`](docs/) — the **source of truth** for all design. + +Read those, then use the method below. + +## What this repo is right now + +A complete **design** ([`docs/`](docs/)) plus an **M0 skeleton**: it compiles and links, but +`libvoicecat`'s subsystems are stubs that return `VC_ERR_NOT_IMPLEMENTED`. Your job is to turn +the design into working software, one milestone at a time. + +## The working method (important) + +**A clean compile is the floor, not the goal.** Do not treat "make the compiler errors go +away" as done. Each milestone in [`docs/roadmap.md`](docs/roadmap.md) has an **exit +criterion stated as observable behavior** — that is what "done" means. Examples: + +- M1 done = *two `vccli` instances actually chat through a real server over TLS*, not "it builds". +- M2 done = *you can talk between two clients in a channel and hear loss concealment work*. + +So the loop is: + +1. Pick the current milestone in `docs/roadmap.md`. Read the relevant design doc section. +2. Write the smallest test (CTest, or a `vccli` interaction) that encodes the exit behavior. +3. Implement the subsystem until that test passes — not just until it compiles. +4. Keep the build green and the existing tests passing on every commit. + +Every commit must compile and pass `ctest`. Behavior tests are how you know you're actually +making progress. + +## Build + +Skeleton (no third-party deps — works immediately): + +```bash +cmake --preset dev +cmake --build --preset dev +ctest --preset dev +``` + +When a subsystem needs real libraries, turn on vcpkg deps: + +```bash +export VCPKG_ROOT=/path/to/vcpkg # bootstrap vcpkg first; cross-platform +cmake --preset server-release # installs deps pinned in vcpkg.json +cmake --build --preset server-release +``` + +`vcpkg.json` currently has a placeholder `builtin-baseline` — set it to a real vcpkg commit +SHA the first time you enable `VOICECAT_USE_VCPKG_DEPS`. + +## Where each subsystem lives (and its doc) + +| Path | Subsystem | Design | +|------|-----------|--------| +| `core/include/voicecat.h` | The C ABI every client/server calls | docs/architecture.md §4 | +| `core/proto/voicecat.proto` | Control-plane wire format | docs/protocol.md | +| `core/src/net/` | Asio TCP/UDP transport, framing | docs/architecture.md, docs/protocol.md §1 | +| `core/src/crypto/` | TLS 1.3 (mbedTLS), media AEAD (libsodium), anti-replay | docs/security.md | +| `core/src/codec/` | Opus encode/decode, FEC/DTX | docs/voice.md §3–4 | +| `core/src/protocol/` | Envelope (de)serialize, state machine, routing | docs/protocol.md | +| `core/src/session/` | Channels, users, streams, permissions, text | docs/protocol.md §5 | +| `core/src/audio/` | Capture/playback (miniaudio), APM DSP, jitter buffer, mixer | docs/voice.md §8–11 | +| `server/` | Connection mgr, session registry, SFU relay, SQLite | docs/architecture.md §5 | +| `tools/vccli/` | Headless client to drive/verify the protocol | — | + +## Suggested first steps (M1 spine) + +1. **Wire protobuf + framing** (`net/` + `protocol/`): build, then generate C++ from + `voicecat.proto`, implement the `[u32 length][Envelope]` framing over a plain TCP socket + (TLS can come right after). Test: round-trip an `Envelope` through the framer. +2. **TLS 1.3 via mbedTLS** (`crypto/`): wrap the TCP channel. Test: `vccli` completes a TLS + handshake against `voicecat-server` and exchanges a `ClientHello`/`ServerHello`. +3. **Auth + state** (`session/`): guest + admin-provisioned accounts (Argon2id/SQLite), + channel tree snapshot/deltas, ephemeral text relay. Test: two `vccli` chat. + +Then proceed to M2 (UDP media) per the roadmap. + +## House rules + +- **No GPL/LGPL dependencies, ever** (closed-source redistribution is a goal). See + docs/tech-stack.md §5. CI should fail on a copyleft transitive dep. +- **Encryption is mandatory** — never add a plaintext transport path. docs/security.md. +- **Real-time audio threads** never allocate, lock, or block. docs/architecture.md §3. +- Keep `docs/` and code in sync. If you change a wire format or the C ABI, update the doc in + the same commit. +- The C ABI is the contract for the Swift/C# clients — treat changes to `voicecat.h` and + `voicecat.proto` as deliberate, versioned events (docs/protocol.md §8). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a72d9ef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md — agent hub for VoiceCat + +Auto-loaded each session. This is the **map**: build commands, architecture at a glance, and +where everything is. For the *working method* read [`AGENTS.md`](AGENTS.md); for *what's done +and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/). + +> **One-line status:** M0 skeleton is complete and verified (builds + links + smoke test +> passes). Next up is **M1** (TCP/TLS control plane, auth, channels, ephemeral text). See +> [`PROGRESS.md`](PROGRESS.md). + +VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control) ++ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives +native clients (Swift on macOS/iOS, C# on Windows) and the server. + +--- + +## Build & test commands + +The **M0 skeleton builds with no third-party dependencies** — just CMake + Ninja + a C++20 +compiler. Deps (vcpkg) are off until a subsystem needs them. + +```bash +# Configure + build the skeleton (default; no vcpkg needed) +cmake --preset dev +cmake --build --preset dev + +# Run the tests (behavior smoke test today; grows per milestone) +ctest --preset dev # or: ctest --test-dir build/dev --output-on-failure + +# Run the binaries (Windows adds .exe; Linux/macOS no extension) +./build/dev/bin/vccli # headless test client +./build/dev/bin/voicecat-server --help +./build/dev/bin/voicecat-server --name "My Server" + +# Build a single target / be verbose +cmake --build --preset dev --target vccli +cmake --build --preset dev --verbose + +# Clean +rm -rf build/dev # nuke; or: +cmake --build --preset dev --target clean +``` + +When you start a subsystem that needs real libraries (mbedTLS, libsodium, opus, protobuf, …), +turn vcpkg deps on: + +```bash +# one-time: git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh (.bat on Windows) +export VCPKG_ROOT=/path/to/vcpkg # works on Linux / macOS / Windows +cmake --preset server-release # auto-installs deps pinned in vcpkg.json +cmake --build --preset server-release +``` + +Other useful toggles (pass with `-D` at configure time): + +```bash +cmake --preset dev -DVOICECAT_BUILD_SHARED=ON # build libvoicecat as a .dll/.so/.dylib (for the C# client) +cmake --preset dev -DVOICECAT_BUILD_SERVER=OFF # core + tools only +cmake --preset dev -DVOICECAT_BUILD_TESTS=OFF +``` + +Formatting: `clang-format` config is `.clang-format` (Google base, 100 cols, 4-space). + +```bash +git ls-files '*.cpp' '*.h' | xargs clang-format -i +``` + +--- + +## Architecture at a glance + +Full detail: [`docs/architecture.md`](docs/architecture.md). The short version: + +``` + Swift (macOS/iOS) ─┐ ┌─ C# (Windows) + ├──▶ libvoicecat (C ABI: voicecat.h) ◀──┤ + voicecat-server ───┘ net · crypto · codec · protocol · └─ all UIs are thin + (links core) session · audio the core owns audio +``` + +- **One core, many faces.** Protocol, Opus, crypto, networking, jitter buffer, and mixing + live once in C++. Clients call the **C ABI** (`core/include/voicecat.h`); the server links + the same core, so framing/crypto never drift between ends. +- **Two transports.** TCP + **TLS 1.3** (control, protobuf `Envelope`) and UDP + **exported-key + ChaCha20-Poly1305 AEAD** (media, fixed binary voice frame). Encryption is mandatory. +- **Threading.** Real-time audio threads never allocate/lock/block; they exchange data with + the net thread via lock-free ring buffers; a worker pool absorbs blocking work. + +### Subsystem map (code ↔ design doc) + +| Path | Subsystem | Design | +|------|-----------|--------| +| `core/include/voicecat.h` | The C ABI (client/server contract) | architecture.md §4 | +| `core/proto/voicecat.proto` | Control-plane wire format (source of truth) | protocol.md | +| `core/src/net/` | Asio TCP/UDP transport, `[u32 len][payload]` framing | protocol.md §1, voice.md §2 | +| `core/src/crypto/` | TLS 1.3 (mbedTLS), media AEAD (libsodium), anti-replay | security.md | +| `core/src/codec/` | Opus encode/decode, FEC/DTX | voice.md §3–4 | +| `core/src/protocol/` | Envelope (de)serialize, request/response, dispatch | protocol.md | +| `core/src/session/` | Channels, users, streams, permissions, ephemeral text | protocol.md §5 | +| `core/src/audio/` | miniaudio I/O, APM DSP, jitter buffer, mixer | voice.md §8–11 | +| `core/src/core/` | `vc_client` — the handle behind the C ABI | architecture.md §4 | +| `server/` | Connection mgr, session registry, SFU relay, SQLite | architecture.md §5 | +| `tools/vccli/` | Headless client that drives/verifies the protocol | — | +| `clients/apple/`, `clients/windows/` | Native GUIs (M4) | architecture.md §4 | + +--- + +## Documentation index (source of truth) + +Read [`docs/`](docs/) before changing behavior. Order: + +1. [docs/README.md](docs/README.md) — overview, locked decisions, glossary +2. [docs/architecture.md](docs/architecture.md) — core, C ABI, threading, server +3. [docs/protocol.md](docs/protocol.md) — control plane, Envelope, message catalog +4. [docs/voice.md](docs/voice.md) — UDP media, Opus, multi-stream, two-sided NR, VAD/PTT +5. [docs/security.md](docs/security.md) — mandatory encryption, TLS+AEAD, accounts, threat model +6. [docs/tech-stack.md](docs/tech-stack.md) — libraries, permissive-license rule, tooling +7. [docs/deployment.md](docs/deployment.md) — zero-config self-host (Docker / binary / source) +8. [docs/roadmap.md](docs/roadmap.md) — milestones + resolved decisions + +--- + +## Keeping track of progress + +**[`PROGRESS.md`](PROGRESS.md) is the living status file.** When you finish a task, check it +off there and note the next step, so the next agent can pick up instantly. Treat it as part of +the work, not an afterthought — update it in the same commit as the code. + +--- + +## House rules (hard constraints) + +- **A clean compile is the floor, not the goal.** "Done" = the milestone's observable exit + criterion in [`docs/roadmap.md`](docs/roadmap.md) passes (e.g. M1 = two `vccli` actually chat + over TLS). Encode it as a test. See [`AGENTS.md`](AGENTS.md). +- **No GPL/LGPL dependencies, ever** (closed-source redistribution is a goal). docs/tech-stack.md §5. +- **Encryption is mandatory** — never add a plaintext transport path. docs/security.md. +- **Real-time audio threads** never allocate, lock, or block. docs/architecture.md §3. +- **Keep docs + code in sync.** Changing a wire format (`voicecat.proto`) or the C ABI + (`voicecat.h`) is a deliberate, versioned act — update the doc in the same commit (protocol.md §8). +- Every commit must build (`cmake --build --preset dev`) and pass `ctest --preset dev`. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..0ebe457 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,50 @@ +cmake_minimum_required(VERSION 3.25) + +project(voicecat + VERSION 0.0.1 + DESCRIPTION "Self-hosted native voice & text chat (see docs/)" + LANGUAGES CXX) + +# ── Options ─────────────────────────────────────────────────────────────────── +# The M0 skeleton compiles with NO third-party dependencies: every subsystem is a +# stub that returns VC_ERR_NOT_IMPLEMENTED. As each subsystem is built out, flip +# VOICECAT_USE_VCPKG_DEPS=ON so CMake pulls the real libraries (mbedTLS, libsodium, +# opus, protobuf, ...) via the vcpkg toolchain (see vcpkg.json / docs/tech-stack.md). +option(VOICECAT_USE_VCPKG_DEPS "Link real third-party deps via vcpkg" OFF) +option(VOICECAT_BUILD_SERVER "Build voicecat-server" ON) +option(VOICECAT_BUILD_TOOLS "Build the vccli headless test client" ON) +option(VOICECAT_BUILD_TESTS "Build tests" ON) +option(VOICECAT_BUILD_SHARED "Build libvoicecat as a shared library" OFF) + +# ── Global settings ─────────────────────────────────────────────────────────── +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE) +endif() + +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) +set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) + +# ── Targets ─────────────────────────────────────────────────────────────────── +add_subdirectory(core) + +if(VOICECAT_BUILD_SERVER) + add_subdirectory(server) +endif() + +if(VOICECAT_BUILD_TOOLS) + add_subdirectory(tools/vccli) +endif() + +if(VOICECAT_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + +message(STATUS "VoiceCat ${PROJECT_VERSION} configured " + "(vcpkg deps: ${VOICECAT_USE_VCPKG_DEPS}, " + "server: ${VOICECAT_BUILD_SERVER}, tools: ${VOICECAT_BUILD_TOOLS})") diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..c1c8478 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,42 @@ +{ + "version": 6, + "cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 }, + "configurePresets": [ + { + "name": "dev", + "displayName": "Dev (skeleton, no third-party deps)", + "description": "Builds the stub skeleton with just a compiler. Works out of the box; no vcpkg required.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/dev", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "VOICECAT_USE_VCPKG_DEPS": "OFF" + } + }, + { + "name": "vcpkg-base", + "hidden": true, + "description": "Shared base for presets that link real deps via vcpkg. Requires VCPKG_ROOT in the environment.", + "generator": "Ninja", + "toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "cacheVariables": { "VOICECAT_USE_VCPKG_DEPS": "ON" } + }, + { + "name": "server-release", + "inherits": "vcpkg-base", + "displayName": "Server (release, real deps)", + "binaryDir": "${sourceDir}/build/server-release", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "VOICECAT_BUILD_TOOLS": "ON" + } + } + ], + "buildPresets": [ + { "name": "dev", "configurePreset": "dev" }, + { "name": "server-release", "configurePreset": "server-release" } + ], + "testPresets": [ + { "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } } + ] +} diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..ed055ed --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,88 @@ +# PROGRESS — VoiceCat + +Living status. **Update this file in the same commit as your work** so the next agent picks +up instantly. Newest status at the top. + +- **Date convention:** ISO (YYYY-MM-DD). +- Statuses: `[ ]` not started · `[~]` in progress · `[x]` done. + +--- + +## ▶ Where we left off / next action + +- **Done:** design docs (`docs/`) + **M0 skeleton** — repo builds, links, and passes the + smoke test with no third-party deps. +- **Next:** start **M1 — control plane**. First concrete task: implement protobuf + the + `[u32 length][Envelope]` frame codec in `core/src/protocol/` and round-trip an `Envelope` + in a test (see M1 checklist below and [`AGENTS.md`](AGENTS.md) "Suggested first steps"). + +--- + +## Milestones (see [docs/roadmap.md](docs/roadmap.md) for full detail) + +- [x] **M0 — Scaffolding** ✓ complete +- [~] **M1 — Control plane** (TCP/TLS, auth, channels, ephemeral text) ← current +- [ ] **M2 — Voice, single stream** (UDP, Opus, jitter buffer, APM send-side, VAD/PTT) +- [ ] **M3 — Multi-stream & per-channel tuning** (screen audio, listener-side per-user NR) +- [ ] **M4 — Native clients** (Windows C#, macOS/iOS Swift) +- [ ] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …) + +--- + +## M0 — Scaffolding ✓ (completed) + +- [x] Repo layout (`core/ server/ tools/ clients/ tests/`), CMake + presets, vcpkg manifest. +- [x] C ABI header `core/include/voicecat.h` (full surface, stubbed). +- [x] Protocol source-of-truth `core/proto/voicecat.proto` (matches docs/protocol.md). +- [x] Core stubs for all six subsystems (net/crypto/codec/protocol/session/audio) + `vc_client`. +- [x] `voicecat-server` (arg parsing, config, stub run) and `vccli` (drives the C ABI). +- [x] CTest **smoke test** asserting the C ABI contract (not just "it compiles"). +- [x] `.gitattributes` (LF), `.gitignore`, `.clang-format`, onboarding docs. +- **Verified:** `cmake --preset dev && cmake --build --preset dev && ctest --preset dev` → green. + +--- + +## M1 — Control plane (current) + +**Exit criterion (definition of done):** two `vccli` instances connect to a real +`voicecat-server` over **TLS 1.3**, authenticate (guest + admin-provisioned account), browse +the channel tree, and exchange channel + private text messages. Encode this as an integration +test driving two clients. + +Tasks (rough order — see [docs/protocol.md](docs/protocol.md), [docs/security.md](docs/security.md)): + +- [ ] Turn on vcpkg deps; set a real `builtin-baseline` in `vcpkg.json`; wire `find_package` + for protobuf in `core/CMakeLists.txt` and `protobuf_generate` for `voicecat.proto`. +- [ ] `protocol/`: implement the `[u32 length][Envelope]` `FrameCodec` (+ oversized-frame + guard). **Test:** round-trip an `Envelope` through feed/emit. +- [ ] `net/`: plain TCP connect/accept via Asio; then wrap with **TLS 1.3 (mbedTLS)** in + `crypto/`. **Test:** `vccli` ↔ `voicecat-server` complete a TLS handshake. +- [ ] Handshake: `ClientHello`/`ServerHello` with version + feature negotiation. +- [ ] Server identity: generate/persist Ed25519 key + self-signed cert on first run; expose + fingerprint; client TOFU pin. (docs/security.md §1) +- [ ] Auth: `AuthRequest` → `AuthResult`; guest path + Argon2id password verify (libsodium); + SQLite accounts; `voicecat-admin` account add/reset/del/list. (docs/security.md §4) +- [ ] Session model: channel tree snapshot (`ServerStateSnapshot`) + `ChannelEvent`/`UserEvent` + deltas; join/leave; create/edit/delete (permission-gated). +- [ ] Text: ephemeral relay of channel + private messages with acks (no history). (protocol.md §5) +- [ ] Wire the C ABI: `vc_connect/authenticate_*/join_channel/send_text` drive the above and + emit `vc_event`s; `vccli` exercises them. +- [ ] **Integration test:** two `vccli` chat through the server over TLS. ← M1 exit. + +--- + +## Decisions log + +All architecture/scope decisions are settled and recorded in +[docs/roadmap.md §2 "Resolved decisions"](docs/roadmap.md) and reflected across `docs/`. +If you make a *new* decision, record it there and link it here. + +--- + +## How to update this file + +1. Check off tasks as you complete them; flip a milestone to `[x]` only when its **exit + criterion test** passes. +2. Keep the **"Where we left off / next action"** block at the top accurate — it's the first + thing the next agent reads. +3. When you start a milestone, copy its task list from `docs/roadmap.md` into a section here. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc2d56a --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# VoiceCat + +Self-hosted, native voice & text chat in the spirit of classic TeamSpeak / Mumble — +channel-based voice, channel + private text, one server you run yourself. Plain **TCP** +(control) and **UDP** (media), no WebRTC. Encrypted by default. A shared **C++ core** +(`libvoicecat`) drives native clients (Swift on macOS/iOS, C# on Windows) and the server. + +> **Status: pre-implementation.** The design is complete in [`docs/`](docs/). The code is an +> M0 **skeleton** — it compiles and links, but every subsystem is a stub. See +> [`AGENTS.md`](AGENTS.md) to start building, and [`docs/roadmap.md`](docs/roadmap.md) for the +> milestones. + +## Read the design first + +The [`docs/`](docs/) folder is the source of truth. Start at [`docs/README.md`](docs/README.md), +then `architecture` → `protocol` → `voice` → `security` → `tech-stack` → `deployment` → +`roadmap`. + +## Build the skeleton (no dependencies needed yet) + +The M0 skeleton builds with just a C++20 compiler + CMake + Ninja — **no vcpkg, no +third-party libraries**, because every subsystem is currently a stub. + +```bash +cmake --preset dev +cmake --build --preset dev +ctest --preset dev # runs the smoke test (links the core, calls the C ABI) +``` + +Artifacts land in `build/dev/bin/` (`voicecat-server`, `vccli`). + +When you start implementing a subsystem that needs real libraries, build with vcpkg deps: + +```bash +# one-time: git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh +export VCPKG_ROOT=/path/to/vcpkg # set VCPKG_ROOT (works on Linux/macOS/Windows) +cmake --preset server-release # auto-installs deps from vcpkg.json +cmake --build --preset server-release +``` + +## Layout + +``` +docs/ design spec (read this) +core/ libvoicecat — the shared C++ core + include/ voicecat.h (the C ABI all clients call) + proto/ voicecat.proto (control-plane wire format, source of truth) + src/ net/ crypto/ codec/ protocol/ session/ audio/ (stubs today) +server/ voicecat-server (headless; links the core) +tools/vccli/ headless test client — drives the protocol from M1 on +clients/ apple/ (Swift, M4) windows/ (C#, M4) — placeholders for now +tests/ CTest targets +``` + +## License + +Permissive-only dependencies (no GPL/LGPL) so the project can be redistributed freely, +including closed-source. Project license: TBD (see [`docs/tech-stack.md`](docs/tech-stack.md) §5). diff --git a/clients/apple/README.md b/clients/apple/README.md new file mode 100644 index 0000000..0b413e9 --- /dev/null +++ b/clients/apple/README.md @@ -0,0 +1,19 @@ +# Apple client (macOS + iOS) — placeholder + +Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). Swift + SwiftUI, consuming +`libvoicecat` through the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)). + +Planned shape (see [`docs/architecture.md`](../../docs/architecture.md) §4 and +[`docs/tech-stack.md`](../../docs/tech-stack.md) §2): + +- A Swift Package wrapping the core as an **XCFramework** (macOS + iOS device + simulator). +- A module map exposing `voicecat.h` to Swift (Swift can also use C++ interop directly, but + the C ABI is the stable contract). +- SwiftUI app target for macOS and iOS. +- **iOS audio:** app owns `AVAudioSession` (`.playAndRecord` / `.voiceChat`), mic permission, + interruption/route handling, calling `vc_audio_*` hooks on the core. +- **iOS screen/system audio (`SCREEN_AUDIO`):** a **ReplayKit Broadcast Upload Extension** + capturing `RPSampleBufferType.audioApp`, linking a minimal core slice, sharing session + state via an **App Group** ([`docs/voice.md`](../../docs/voice.md) §9). + +Nothing here yet — the core must reach M2 (working voice) before the GUI is worth building. diff --git a/clients/windows/README.md b/clients/windows/README.md new file mode 100644 index 0000000..e81ca1b --- /dev/null +++ b/clients/windows/README.md @@ -0,0 +1,18 @@ +# Windows client — placeholder + +Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). C# / .NET 8+, consuming +`libvoicecat` through the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)). + +Planned shape (see [`docs/architecture.md`](../../docs/architecture.md) §4 and +[`docs/tech-stack.md`](../../docs/tech-stack.md) §2): + +- A .NET solution with a P/Invoke interop layer over the C ABI using **`LibraryImport`** + (source-generated, .NET 7+). Build `libvoicecat` as a **shared library** + (`-DVOICECAT_BUILD_SHARED=ON`) so the DLL sits beside the app. +- The `on_event` callback marshaled as a function pointer (`[UnmanagedCallersOnly]`) to avoid + delegate-lifetime issues; keep the interface "chunky" to minimize managed↔native crossings. +- UI in **WinUI 3** (most native) or **Avalonia** (if a single C# desktop UI is wanted later). +- Audio (capture/playback, WASAPI loopback for `SCREEN_AUDIO`) is handled inside the core; the + C# layer only drives device selection, meters, and the VAD/PTT + per-user NR controls. + +Nothing here yet — the core must reach M2 (working voice) before the GUI is worth building. diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt new file mode 100644 index 0000000..b927a6d --- /dev/null +++ b/core/CMakeLists.txt @@ -0,0 +1,43 @@ +# libvoicecat — the shared C++ core (docs/architecture.md). +# Sources are globbed so adding a stub under src// 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() diff --git a/core/include/voicecat.h b/core/include/voicecat.h new file mode 100644 index 0000000..3af6d48 --- /dev/null +++ b/core/include/voicecat.h @@ -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 +#include + +#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 */ diff --git a/core/proto/voicecat.proto b/core/proto/voicecat.proto new file mode 100644 index 0000000..30b7d78 --- /dev/null +++ b/core/proto/voicecat.proto @@ -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 10–19) + 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 (20–29) + ServerStateSnapshot server_state = 20; + ChannelEvent channel_event = 21; + UserEvent user_event = 22; + SubscribeRequest subscribe = 23; + + // Channel operations (30–39) + 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 (40–49) + StreamAnnounce stream_announce = 40; + StreamAnnounceResult stream_announce_result = 41; + StreamStop stream_stop = 42; + StreamStateUpdate stream_state = 43; + UdpBinding udp_binding = 44; + + // Text (50–59) + TextMessage text_message = 50; + TextMessageAck text_message_ack = 51; + TypingIndicator typing = 52; + + // Moderation / permissions (60–69) + KickRequest kick = 60; + BanRequest ban = 61; + SetPermissionRequest set_permission = 62; + + // Admin account management — privileged; accounts are admin-provisioned (70–79) + CreateAccountRequest create_account = 70; + ResetPasswordRequest reset_password = 71; + DeleteAccountRequest delete_account = 72; + ListAccountsRequest list_accounts = 73; + + // Future families: file transfer = 100–109. 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; } diff --git a/core/src/audio/audio_engine.cpp b/core/src/audio/audio_engine.cpp new file mode 100644 index 0000000..bc610cc --- /dev/null +++ b/core/src/audio/audio_engine.cpp @@ -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 §8–11. + +} // namespace voicecat::audio diff --git a/core/src/audio/audio_engine.h b/core/src/audio/audio_engine.h new file mode 100644 index 0000000..1864ee7 --- /dev/null +++ b/core/src/audio/audio_engine.h @@ -0,0 +1,40 @@ +/* + * audio/audio_engine.h — capture/playback + DSP + jitter buffer + mixer. + * + * Design: docs/voice.md §8–11. 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 + +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 diff --git a/core/src/codec/opus_codec.cpp b/core/src/codec/opus_codec.cpp new file mode 100644 index 0000000..ffbb3a6 --- /dev/null +++ b/core/src/codec/opus_codec.cpp @@ -0,0 +1,7 @@ +#include "codec/opus_codec.h" + +namespace voicecat::codec { + +// M0 stub. Brought up in M2. See docs/voice.md §3–4. + +} // namespace voicecat::codec diff --git a/core/src/codec/opus_codec.h b/core/src/codec/opus_codec.h new file mode 100644 index 0000000..ed76311 --- /dev/null +++ b/core/src/codec/opus_codec.h @@ -0,0 +1,39 @@ +/* + * codec/opus_codec.h — Opus encode/decode (libopus 1.6). + * + * Design: docs/voice.md §3–4. 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 + +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 diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp new file mode 100644 index 0000000..3595983 --- /dev/null +++ b/core/src/core/client.cpp @@ -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; +} diff --git a/core/src/core/client.h b/core/src/core/client.h new file mode 100644 index 0000000..7f64560 --- /dev/null +++ b/core/src/core/client.h @@ -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 diff --git a/core/src/crypto/crypto.cpp b/core/src/crypto/crypto.cpp new file mode 100644 index 0000000..42d0fc8 --- /dev/null +++ b/core/src/crypto/crypto.cpp @@ -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 §1–2. + +} // namespace voicecat::crypto diff --git a/core/src/crypto/crypto.h b/core/src/crypto/crypto.h new file mode 100644 index 0000000..d774711 --- /dev/null +++ b/core/src/crypto/crypto.h @@ -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 +#include + +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 diff --git a/core/src/net/transport.cpp b/core/src/net/transport.cpp new file mode 100644 index 0000000..7644107 --- /dev/null +++ b/core/src/net/transport.cpp @@ -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 diff --git a/core/src/net/transport.h b/core/src/net/transport.h new file mode 100644 index 0000000..a6a38f8 --- /dev/null +++ b/core/src/net/transport.h @@ -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 +#include + +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 diff --git a/core/src/protocol/protocol.cpp b/core/src/protocol/protocol.cpp new file mode 100644 index 0000000..396b15b --- /dev/null +++ b/core/src/protocol/protocol.cpp @@ -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 §1–5. +bool FrameCodec::feed(const uint8_t*, size_t, std::vector>&) { + return true; // TODO(M1): real framing. +} + +} // namespace voicecat::protocol diff --git a/core/src/protocol/protocol.h b/core/src/protocol/protocol.h new file mode 100644 index 0000000..ae55bce --- /dev/null +++ b/core/src/protocol/protocol.h @@ -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 +#include +#include + +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>& out_frames); +}; + +} // namespace voicecat::protocol + +#endif // VOICECAT_PROTOCOL_PROTOCOL_H diff --git a/core/src/session/session.cpp b/core/src/session/session.cpp new file mode 100644 index 0000000..997f88b --- /dev/null +++ b/core/src/session/session.cpp @@ -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 diff --git a/core/src/session/session.h b/core/src/session/session.h new file mode 100644 index 0000000..ddb2bf2 --- /dev/null +++ b/core/src/session/session.h @@ -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 +#include +#include + +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 streams; +}; + +// Mirror/authority for the channel tree + user list. TODO(M1): snapshot + delta apply. +class SessionModel { + public: + const std::vector& channels() const { return channels_; } + const std::vector& users() const { return users_; } + + private: + std::vector channels_; + std::vector users_; +}; + +} // namespace voicecat::session + +#endif // VOICECAT_SESSION_SESSION_H diff --git a/core/src/voicecat.cpp b/core/src/voicecat.cpp new file mode 100644 index 0000000..c38dda4 --- /dev/null +++ b/core/src/voicecat.cpp @@ -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 + +#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" diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt new file mode 100644 index 0000000..32dba95 --- /dev/null +++ b/server/CMakeLists.txt @@ -0,0 +1,6 @@ +file(GLOB_RECURSE VOICECAT_SERVER_SOURCES CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp") + +add_executable(voicecat-server ${VOICECAT_SERVER_SOURCES}) +target_link_libraries(voicecat-server PRIVATE voicecat::voicecat) +target_compile_features(voicecat-server PRIVATE cxx_std_20) diff --git a/server/src/main.cpp b/server/src/main.cpp new file mode 100644 index 0000000..17f5b8b --- /dev/null +++ b/server/src/main.cpp @@ -0,0 +1,68 @@ +/* + * voicecat-server entry point (M0 skeleton). + * + * Goal (docs/deployment.md): one command, zero config, encrypted + listening. Today it + * parses a few flags and prints what it would do. + */ +#include +#include +#include + +#include "server.h" +#include "voicecat.h" + +namespace { + +void print_help(const char* argv0) { + std::printf( + "voicecat-server %s\n\n" + "Usage: %s [options]\n" + " --port control+media port (default 8384)\n" + " --data-dir data directory (default ./voicecat-data)\n" + " --name server name\n" + " --no-guests disable guest access\n" + " --print-config print effective config and exit\n" + " --version print version and exit\n" + " --help this help\n", + vc_version_string(), argv0); +} + +} // namespace + +int main(int argc, char** argv) { + voicecat::server::Config cfg; + bool print_config_only = false; + + for (int i = 1; i < argc; ++i) { + const char* a = argv[i]; + auto next = [&]() -> const char* { return (i + 1 < argc) ? argv[++i] : ""; }; + if (std::strcmp(a, "--help") == 0) { + print_help(argv[0]); + return 0; + } else if (std::strcmp(a, "--version") == 0) { + std::printf("voicecat-server %s (protocol v%d)\n", vc_version_string(), + VOICECAT_PROTOCOL_VERSION); + return 0; + } else if (std::strcmp(a, "--port") == 0) { + cfg.bind_port = static_cast(std::atoi(next())); + } else if (std::strcmp(a, "--data-dir") == 0) { + cfg.data_dir = next(); + } else if (std::strcmp(a, "--name") == 0) { + cfg.server_name = next(); + } else if (std::strcmp(a, "--no-guests") == 0) { + cfg.allow_guests = false; + } else if (std::strcmp(a, "--print-config") == 0) { + print_config_only = true; + } else { + std::fprintf(stderr, "unknown option: %s (try --help)\n", a); + return 2; + } + } + + std::printf("[voicecat-server %s] starting\n", vc_version_string()); + voicecat::server::Server server(cfg); + if (print_config_only) { + // run() currently just prints config; in M1 split this into a pure config dump. + } + return server.run(); +} diff --git a/server/src/server.cpp b/server/src/server.cpp new file mode 100644 index 0000000..12ed2c4 --- /dev/null +++ b/server/src/server.cpp @@ -0,0 +1,20 @@ +#include "server.h" + +#include + +namespace voicecat::server { + +int Server::run() { + // M0 stub: report what a real run WILL do, then exit. M1 brings up the TLS listener, + // session registry, and channel manager (docs/architecture.md §5). + std::printf(" server_name : %s\n", cfg_.server_name.c_str()); + std::printf(" data_dir : %s\n", cfg_.data_dir.c_str()); + std::printf(" bind_port : %u (TCP control + UDP media)\n", cfg_.bind_port); + std::printf(" allow_guests: %s\n", cfg_.allow_guests ? "true" : "false"); + std::printf(" fingerprint : \n"); + std::printf("\n[voicecat-server] M0 skeleton: networking not implemented yet. " + "See docs/roadmap.md (M1) and AGENTS.md.\n"); + return 0; +} + +} // namespace voicecat::server diff --git a/server/src/server.h b/server/src/server.h new file mode 100644 index 0000000..f08bbef --- /dev/null +++ b/server/src/server.h @@ -0,0 +1,40 @@ +/* + * server.h — voicecat-server skeleton. + * + * Design: docs/architecture.md §5, docs/deployment.md. Headless process that links the core. + * Responsibilities: connection manager (TLS), session registry, channel manager, text router, + * voice SFU relay, SQLite persistence. Zero-config: self-provisions Ed25519 identity + cert + * on first run, embedded SQLite, guests on by default. + * + * STATUS: M0 stub — prints config and exits; does not yet listen. + */ +#ifndef VOICECAT_SERVER_SERVER_H +#define VOICECAT_SERVER_SERVER_H + +#include +#include + +namespace voicecat::server { + +struct Config { + std::string server_name = "VoiceCat Server"; + std::string data_dir = "voicecat-data"; + uint16_t bind_port = 8384; // TCP + UDP (docs/deployment.md §2) + bool allow_guests = true; +}; + +class Server { + public: + explicit Server(Config cfg) : cfg_(std::move(cfg)) {} + + // TODO(M1): bind TLS control listener + UDP media socket; run the Asio loop until stop. + // Returns process exit code. + int run(); + + private: + Config cfg_; +}; + +} // namespace voicecat::server + +#endif // VOICECAT_SERVER_SERVER_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..9d36b40 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,8 @@ +# Tests use plain asserts + exit codes for now (no framework dependency in the skeleton). +# A real framework (e.g. Catch2/GoogleTest via vcpkg) can be added when VOICECAT_USE_VCPKG_DEPS +# is on. Behavior tests — not just "it compiles" — are how milestones are judged (AGENTS.md). + +add_executable(test_smoke test_smoke.cpp) +target_link_libraries(test_smoke PRIVATE voicecat::voicecat) +target_compile_features(test_smoke PRIVATE cxx_std_20) +add_test(NAME smoke COMMAND test_smoke) diff --git a/tests/test_smoke.cpp b/tests/test_smoke.cpp new file mode 100644 index 0000000..1de720a --- /dev/null +++ b/tests/test_smoke.cpp @@ -0,0 +1,65 @@ +/* + * test_smoke — verifies the core links and the C ABI behaves as specified for M0. + * + * This is intentionally a behavior test, not a "does it compile" check: it asserts the + * documented contract (version present, handle lifecycle, invalid-arg guards, and that + * unimplemented calls report VC_ERR_NOT_IMPLEMENTED rather than crashing). + */ +#include +#include + +#include "voicecat.h" + +static int g_failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +int main() { + // Version + result strings are always available. + CHECK(vc_version_string() != nullptr); + CHECK(std::strlen(vc_version_string()) > 0); + CHECK(std::strcmp(vc_result_string(VC_OK), "ok") == 0); + CHECK(std::strcmp(vc_result_string(VC_ERR_NOT_IMPLEMENTED), "not implemented") == 0); + + // Null-config create is rejected; valid create yields a handle. + vc_callbacks cb{}; + CHECK(vc_client_create(nullptr, cb) == nullptr); + + vc_config cfg{}; + cfg.client_name = "test"; + cfg.client_version = "0"; + cfg.log_level = VC_LOG_OFF; + vc_client* c = vc_client_create(&cfg, cb); + CHECK(c != nullptr); + + // Invalid-arg guards on the ABI. + CHECK(vc_connect(nullptr, "h", 1) == VC_ERR_INVALID_ARG); + CHECK(vc_connect(c, nullptr, 1) == VC_ERR_INVALID_ARG); + CHECK(vc_send_text(c, VC_TEXT_CHANNEL, 0, nullptr) == VC_ERR_INVALID_ARG); + + // Unimplemented subsystems report NOT_IMPLEMENTED (not a crash) in the M0 skeleton. + CHECK(vc_connect(c, "127.0.0.1", 8384) == VC_ERR_NOT_IMPLEMENTED); + CHECK(vc_authenticate_guest(c, "nick") == VC_ERR_NOT_IMPLEMENTED); + CHECK(vc_join_channel(c, 1, nullptr) == VC_ERR_NOT_IMPLEMENTED); + + vc_device_list dl; + CHECK(vc_list_devices(c, VC_DEVICE_INPUT, &dl) == VC_ERR_NOT_IMPLEMENTED); + CHECK(dl.count == 0); + vc_free_device_list(&dl); + + vc_client_destroy(c); + vc_client_destroy(nullptr); // must be safe + + if (g_failures == 0) { + std::printf("smoke: all checks passed\n"); + return 0; + } + std::printf("smoke: %d failure(s)\n", g_failures); + return 1; +} diff --git a/tools/vccli/CMakeLists.txt b/tools/vccli/CMakeLists.txt new file mode 100644 index 0000000..034e64e --- /dev/null +++ b/tools/vccli/CMakeLists.txt @@ -0,0 +1,3 @@ +add_executable(vccli src/main.cpp) +target_link_libraries(vccli PRIVATE voicecat::voicecat) +target_compile_features(vccli PRIVATE cxx_std_20) diff --git a/tools/vccli/src/main.cpp b/tools/vccli/src/main.cpp new file mode 100644 index 0000000..009bdc7 --- /dev/null +++ b/tools/vccli/src/main.cpp @@ -0,0 +1,47 @@ +/* + * vccli — headless test client. + * + * This is the primary way the protocol is exercised and verified from M1 onward (see + * AGENTS.md). Each milestone's exit criterion is demonstrated by driving two vccli + * instances against a real voicecat-server. Today it just shows the C ABI is linkable. + */ +#include + +#include "voicecat.h" + +namespace { + +void on_event(void* /*user*/, const vc_event* ev) { + std::printf("[event] type=%d state=%d result=%d text=%s\n", ev->type, ev->connection_state, + ev->result, ev->text ? ev->text : ""); +} + +} // namespace + +int main(int argc, char** argv) { + std::printf("vccli — VoiceCat test client (core %s, protocol v%d)\n", vc_version_string(), + VOICECAT_PROTOCOL_VERSION); + + vc_config cfg{}; + cfg.client_name = "vccli"; + cfg.client_version = vc_version_string(); + cfg.log_level = VC_LOG_INFO; + + vc_callbacks cb{}; + cb.on_event = on_event; + + vc_client* c = vc_client_create(&cfg, cb); + if (c == nullptr) { + std::fprintf(stderr, "failed to create client\n"); + return 1; + } + + // M0: demonstrate the call surface. These return VC_ERR_NOT_IMPLEMENTED for now. + const char* host = (argc > 1) ? argv[1] : "127.0.0.1"; + vc_result r = vc_connect(c, host, 8384); + std::printf("vc_connect(%s:8384) -> %d (%s)\n", host, r, vc_result_string(r)); + + vc_client_destroy(c); + std::printf("ok\n"); + return 0; +} diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 0000000..66411f9 --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg/master/scripts/vcpkg.schema.json", + "name": "voicecat", + "version": "0.0.1", + "description": "Self-hosted native voice & text chat. See docs/.", + "builtin-baseline": "0000000000000000000000000000000000000000", + "$comment-baseline": "TODO: set builtin-baseline to a real vcpkg commit SHA when first enabling VOICECAT_USE_VCPKG_DEPS. Until then the skeleton builds with deps OFF and this manifest is inert.", + "dependencies": [ + { "name": "opus", "$why": "voice codec (libopus 1.6) — docs/voice.md" }, + { "name": "libsodium", "$why": "Argon2id, ChaCha20-Poly1305 media AEAD, Ed25519 — docs/security.md" }, + { "name": "mbedtls", "$why": "TLS 1.3 control channel + keying-material exporter — docs/security.md" }, + { "name": "protobuf", "$why": "control-plane serialization — docs/protocol.md" }, + { "name": "sqlite3", "$why": "server accounts/state — docs/security.md" }, + { "name": "asio", "$why": "TCP/UDP/timers reactor — docs/architecture.md" }, + { "name": "miniaudio", "$why": "cross-platform capture/playback — docs/tech-stack.md" }, + { "name": "speexdsp", "$why": "resampling + jitter reference — docs/tech-stack.md" }, + { "name": "webrtc-audio-processing", "$why": "AEC/NS/AGC/VAD (APM) — docs/voice.md" }, + { "name": "spdlog", "$why": "logging — docs/tech-stack.md" } + ], + "$license-note": "All of the above are permissive (BSD/MIT/ISC/Apache-2.0/public-domain). No GPL/LGPL — see docs/tech-stack.md §5." +}