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." +}