Retire legacy sources and verify managed iOS deployment
This commit is contained in:
@@ -2,9 +2,9 @@ name: .NET port
|
||||
|
||||
on:
|
||||
push:
|
||||
paths: ['dotnet/**', 'clients/apple/dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
|
||||
paths: ['dotnet/**', 'clients/windows/**', 'clients/apple/dotnet/**', 'native/**', 'proto/**', 'assets/**', 'Dockerfile', '.github/workflows/dotnet.yml']
|
||||
pull_request:
|
||||
paths: ['dotnet/**', 'clients/apple/dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
|
||||
paths: ['dotnet/**', 'clients/windows/**', 'clients/apple/dotnet/**', 'native/**', 'proto/**', 'assets/**', 'Dockerfile', '.github/workflows/dotnet.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -50,36 +50,3 @@ jobs:
|
||||
run: dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx
|
||||
- name: Build managed AppKit and UIKit clients
|
||||
run: dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug --no-restore
|
||||
|
||||
cpp-conformance:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
global-json-file: dotnet/global.json
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/vcpkg
|
||||
key: dotnet-oracle-linux-${{ hashFiles('vcpkg.json', 'vcpkg') }}
|
||||
- name: Install C++ build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake ninja-build curl zip unzip tar pkg-config autoconf autoconf-archive automake libtool nasm python3
|
||||
./vcpkg/bootstrap-vcpkg.sh -disableMetrics
|
||||
- name: Build and verify both implementations
|
||||
run: |
|
||||
cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON
|
||||
cmake --build --preset dev
|
||||
ctest --preset dev
|
||||
./build/dev/bin/voicecat-dotnet-oracle build/dev/cpp-wire.json
|
||||
diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json build/dev/cpp-wire.json
|
||||
./build/dev/bin/voicecat-dotnet-password-oracle build/dev/cpp-passwords.json
|
||||
diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-passwords.json build/dev/cpp-passwords.json
|
||||
./build/dev/bin/voicecat-dotnet-dsp-oracle build/dev/cpp-noise.json
|
||||
pwsh -File dotnet/compare-dsp-fixtures.ps1 dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json build/dev/cpp-noise.json
|
||||
pwsh -File dotnet/build-native.ps1
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
VOICECAT_TLS_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-tls-oracle" VOICECAT_DATABASE_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-database-oracle" VOICECAT_VOICE_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-voice-oracle" VOICECAT_VCCLI="$PWD/build/dev/bin/vccli" dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
|
||||
@@ -1,115 +1,54 @@
|
||||
# AGENTS.md — working method
|
||||
# VoiceCat working method
|
||||
|
||||
This file is the **working method** for a developer or AI agent picking up VoiceCat. Companion
|
||||
files:
|
||||
Start with `CLAUDE.md` for commands and architecture and `PROGRESS.md` for the current handoff.
|
||||
The supported product is the .NET 10 implementation. Old C++ and Swift application code is
|
||||
retirement material, not a source of truth.
|
||||
|
||||
- [`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.
|
||||
## Definition of done
|
||||
|
||||
Read those, then use the method below.
|
||||
A clean compile is the floor. Changes are done when their observable behavior is covered by a
|
||||
test or, for hardware/accessibility/release behavior, by a clearly recorded manual gate.
|
||||
|
||||
## What this repo is right now
|
||||
For implementation work:
|
||||
|
||||
A complete **design** ([`docs/`](docs/)) plus a working implementation through M5: real TLS
|
||||
control plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree,
|
||||
permissions, moderation, disconnect/keepalive/reaper. The Windows WinForms C# client is
|
||||
shipped (M4). The macOS/iOS Swift client is next. The `skeleton` preset still links a
|
||||
no-deps stub path (`VC_ERR_NOT_IMPLEMENTED`) for smoke-check builds.
|
||||
1. Read the relevant code and current contract document.
|
||||
2. Add or adjust the smallest behavior test that demonstrates the requested result.
|
||||
3. Implement the change without weakening encryption, real-time safety, or accessibility.
|
||||
4. Build and run the affected tests, then the full managed test suite when practical.
|
||||
5. Update concise current documentation and `PROGRESS.md` if the handoff changed.
|
||||
|
||||
## The working method (important)
|
||||
## Supported layout
|
||||
|
||||
**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:
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `proto/voicecat.proto` | Control-plane wire schema |
|
||||
| `dotnet/src/` | Managed server, core, protocol, crypto, audio, codec/DSP, and CLI |
|
||||
| `dotnet/tests/VoiceCat.Tests/` | Managed behavior tests |
|
||||
| `clients/windows/` | Supported WinForms client |
|
||||
| `clients/apple/dotnet/` | Supported AppKit and UIKit clients |
|
||||
| `native/media/` | Required narrow Opus/RNNoise C ABI |
|
||||
| `native/rnnoise/` | Vendored RNNoise source/model |
|
||||
| `native/apple/broadcast/` | Required ReplayKit extension and shared-memory producer |
|
||||
|
||||
- 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
|
||||
|
||||
Default development preset (real deps via vcpkg — works on Windows/Linux/macOS). vcpkg is
|
||||
bundled as a git submodule at `vcpkg/`, pinned to `vcpkg.json`'s `builtin-baseline`:
|
||||
## Core verification
|
||||
|
||||
```bash
|
||||
git submodule update --init vcpkg # one-time, after cloning
|
||||
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||
cmake --preset dev
|
||||
cmake --build --preset dev
|
||||
ctest --preset dev
|
||||
./dotnet/build-native.ps1
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
./dotnet/check-licenses.ps1
|
||||
```
|
||||
|
||||
To use an external vcpkg checkout instead, `export VCPKG_ROOT=/path/to/vcpkg` — it always
|
||||
takes priority over the bundled submodule.
|
||||
Apple builds additionally use `./dotnet/build-native-ios.sh` and
|
||||
`clients/apple/dotnet/VoiceCat.Apple.slnx` on macOS.
|
||||
|
||||
Skeleton (no third-party deps — works immediately, no vcpkg needed):
|
||||
## Hard rules
|
||||
|
||||
```bash
|
||||
cmake --preset skeleton
|
||||
cmake --build --preset skeleton
|
||||
ctest --preset skeleton
|
||||
```
|
||||
|
||||
> **Windows gotcha — always run `ctest` and built binaries via PowerShell, not Git Bash.**
|
||||
> MinGW-built executables fail in Git Bash with exit code `0xc0000139`
|
||||
> (STATUS_ENTRYPOINT_NOT_FOUND) even though the file exists and is marked executable.
|
||||
> PowerShell runs them correctly. Use the PowerShell tool (not Bash) for any `ctest`,
|
||||
> `voicecat-server.exe`, or `vccli.exe` invocation on Windows.
|
||||
|
||||
Other presets: `release` (optimized + tests), `server-release` (optimized + stripped,
|
||||
deployment-shaped), `windows-client` (DLL for C# app), `apple-dev`/`apple-ios`/
|
||||
`apple-ios-sim` (Apple platform scaffolding). See [`docs/building.md`](docs/building.md)
|
||||
for the full matrix.
|
||||
|
||||
`vcpkg.json` pins all deps to a fixed vcpkg baseline — `cmake --preset dev` resolves them
|
||||
automatically on first configure. The vcpkg triplet is auto-resolved from the host platform
|
||||
by [`cmake/voicecat-toolchain.cmake`](cmake/voicecat-toolchain.cmake), which also resolves
|
||||
`VCPKG_ROOT` (env var override, else the bundled `vcpkg/` submodule).
|
||||
|
||||
## 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).
|
||||
- Encryption is mandatory: TLS control plus authenticated encrypted UDP media.
|
||||
- Never introduce GPL/LGPL dependencies.
|
||||
- Real-time audio callbacks never allocate, lock, block, or perform I/O.
|
||||
- Preserve accessible names, keyboard operation, and curated screen-reader announcements.
|
||||
- Wire, database, and shared-ring changes are deliberate versioned changes.
|
||||
- Do not restore compatibility tests for retired implementations unless explicitly requested.
|
||||
- Keep `PROGRESS.md` short. Use Git history rather than accumulating completed-work prose.
|
||||
|
||||
@@ -1,194 +1,53 @@
|
||||
# CLAUDE.md — agent hub for VoiceCat
|
||||
# VoiceCat developer guide
|
||||
|
||||
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/).
|
||||
VoiceCat is a self-hosted encrypted voice and text chat system. The supported implementation
|
||||
is .NET 10. Read `AGENTS.md` for working rules and `PROGRESS.md` for the current short status.
|
||||
|
||||
> **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move,
|
||||
> server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows
|
||||
> WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj`
|
||||
> at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at
|
||||
> `clients/apple/iOS/`. `ctest --preset dev` green — 29/29 tests.
|
||||
> External PCM feed/tap API (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) shipped.
|
||||
> **Screen-audio sharing shipped on macOS (ScreenCaptureKit) and iOS (ReplayKit Broadcast
|
||||
> Upload Extension → host App Group ring → `vc_stream_feed_pcm`).**
|
||||
> The managed C# AppKit replacement now implements the Swift macOS feature surface and has a
|
||||
> validated ad-hoc Release bundle. Swift remains the release oracle until VoiceOver, live-call,
|
||||
> and credentialed notarization gates pass; see `clients/apple/dotnet/README.md`.
|
||||
> **Noise suppression shipped (RNNoise, vendored at `third_party/rnnoise/`)** — both send-side
|
||||
> mic NR (`vc_set_input_noise_reduction`) and per-listener receive NR; client on/off toggles ship
|
||||
on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23).
|
||||
> See [`PROGRESS.md`](PROGRESS.md).
|
||||
## Build and test
|
||||
|
||||
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). TLS over 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 .NET rewrite lives under `dotnet/`. Build and test its wire/crypto, TLS, codec/DSP, and managed control/UDP server slices
|
||||
alongside the existing C++ tree:
|
||||
|
||||
```powershell
|
||||
./dotnet/build-native.ps1 # CMake + C compiler; pinned Opus with DRED + RNNoise
|
||||
```bash
|
||||
./dotnet/build-native.ps1
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
./dotnet/check-licenses.ps1
|
||||
```
|
||||
|
||||
See `dotnet/README.md` for C# conventions and required native voice/CLI conformance,
|
||||
and `docs/api-dotnet.md` for managed interfaces. The managed server, CLI, audio/client core,
|
||||
Windows cutover, and macOS functional surface are implemented. The Windows and macOS apps
|
||||
contain only the permitted media shim. Accessibility/manual endurance validation, macOS
|
||||
notarization, the iOS rewrite, and broad server deployment work remain.
|
||||
|
||||
The default development preset is **`dev`** — it builds everything (server + tools + tests)
|
||||
with real vcpkg deps. The `skeleton` preset (no deps, stubs only) is a fast smoke check; see
|
||||
[`docs/building.md`](docs/building.md) for the full preset matrix.
|
||||
Apple client builds require macOS, Xcode, and the .NET macOS/iOS workloads:
|
||||
|
||||
```bash
|
||||
# Configure + build (default development preset; needs VCPKG_ROOT)
|
||||
cmake --preset dev
|
||||
cmake --build --preset dev
|
||||
|
||||
# Run the tests (21 behavior tests — grows per milestone)
|
||||
# NOTE on Windows: run ctest via PowerShell, NOT Git Bash — MinGW binaries fail in Git Bash
|
||||
# with exit 0xc0000139 (STATUS_ENTRYPOINT_NOT_FOUND). PowerShell runs them correctly.
|
||||
ctest --preset dev # or: ctest --test-dir build/dev --output-on-failure
|
||||
|
||||
# Run the binaries — same Windows rule: use PowerShell, not Git Bash
|
||||
./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
|
||||
./dotnet/build-native-ios.sh
|
||||
dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx
|
||||
dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug --no-restore
|
||||
```
|
||||
|
||||
Other presets (see [`docs/building.md`](docs/building.md) for full detail):
|
||||
Windows publishing uses `clients/windows/publish-client.ps1`. Server publishing uses
|
||||
`dotnet/publish-server.ps1`. See `docs/building.md` while it is being rewritten; prefer the
|
||||
scripts themselves when historical text disagrees with them.
|
||||
|
||||
```bash
|
||||
cmake --preset skeleton # no-deps stub smoke (no VCPKG_ROOT needed) — 2 tests
|
||||
cmake --preset release # optimized + tests on, symbols kept (profile/debug-friendly)
|
||||
cmake --preset server-release # optimized + stripped, no tests (deployment-shaped)
|
||||
cmake --preset windows-client # voicecat.dll for the C# WinForms client (Windows only)
|
||||
cmake --preset apple-dev # libvoicecat.a for macOS Swift Package (scaffolding, macOS only)
|
||||
```
|
||||
## Current architecture
|
||||
|
||||
Vcpkg triplet is auto-resolved from the host platform by
|
||||
[`cmake/voicecat-toolchain.cmake`](cmake/voicecat-toolchain.cmake) — `x64-mingw-static` on
|
||||
Windows, `x64-linux` on Linux, `arm64-osx` on Apple Silicon. See docs/building.md §1
|
||||
"Platform matrix" for details.
|
||||
- `proto/voicecat.proto` is the control-plane wire schema.
|
||||
- `dotnet/src/VoiceCat.Protocol` owns protobuf framing and generated types.
|
||||
- `dotnet/src/VoiceCat.Crypto` owns TLS, TOFU, media AEAD, identity, and password hashing.
|
||||
- `dotnet/src/VoiceCat.Server` owns the TLS/UDP server and SQLite state.
|
||||
- `dotnet/src/VoiceCat.Core` owns client connection and protocol state.
|
||||
- `dotnet/src/VoiceCat.Audio`, `.Codec`, and `.Dsp` own voice processing.
|
||||
- `dotnet/src/VoiceCat.Cli` is the supported headless client.
|
||||
- `clients/windows` is the WinForms client.
|
||||
- `clients/apple/dotnet` contains the AppKit and UIKit clients.
|
||||
- `native/media` and `native/rnnoise` are the required Opus/RNNoise native boundary.
|
||||
- `native/apple/broadcast` is the required Swift ReplayKit extension.
|
||||
|
||||
vcpkg is bundled as a git submodule at `vcpkg/`, pinned to the commit in `vcpkg.json`'s
|
||||
`builtin-baseline`. One-time setup after cloning:
|
||||
The old C++ implementation and old Swift applications are unsupported retirement sources.
|
||||
They are not architectural authorities and compatibility with them is not a requirement.
|
||||
|
||||
```bash
|
||||
git submodule update --init vcpkg
|
||||
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||
```
|
||||
## Invariants
|
||||
|
||||
To use an external vcpkg checkout instead (e.g. one shared across projects), set
|
||||
`VCPKG_ROOT` — it always takes priority over the bundled submodule:
|
||||
|
||||
```bash
|
||||
export VCPKG_ROOT=/path/to/vcpkg # works on Linux / macOS / Windows
|
||||
```
|
||||
|
||||
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
|
||||
9. [docs/building.md](docs/building.md) — what each CMake preset is for + manual server/`vccli` testing
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
- TLS control and encrypted UDP media are mandatory; do not add plaintext transports.
|
||||
- No GPL or LGPL dependencies.
|
||||
- Audio callbacks must not allocate, lock, block, or perform network I/O.
|
||||
- Treat `proto/voicecat.proto`, persisted database formats, and the ReplayKit ring layout as
|
||||
explicit versioned contracts.
|
||||
- A passing build is not enough: add or update behavior tests for observable changes.
|
||||
- Keep `PROGRESS.md` concise; do not append a historical changelog.
|
||||
|
||||
+2
-7
@@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.25)
|
||||
project(voicecat
|
||||
VERSION 0.0.1
|
||||
DESCRIPTION "Self-hosted native voice & text chat (see docs/)"
|
||||
# C is needed for the vendored RNNoise noise-suppression lib (third_party/rnnoise).
|
||||
# C is needed for the retained native media shim under native/.
|
||||
LANGUAGES CXX C)
|
||||
|
||||
# On iOS, audio_engine.cpp includes miniaudio.h which pulls in AVFoundation Objective-C
|
||||
@@ -51,12 +51,7 @@ add_subdirectory(core)
|
||||
|
||||
option(VOICECAT_BUILD_DOTNET_NATIVE "Build native codec/DSP bindings for the .NET rewrite" OFF)
|
||||
if(VOICECAT_BUILD_DOTNET_NATIVE)
|
||||
add_subdirectory(dotnet/native)
|
||||
endif()
|
||||
|
||||
option(VOICECAT_BUILD_DOTNET_ORACLE "Build the .NET port conformance fixture generator" OFF)
|
||||
if(VOICECAT_BUILD_DOTNET_ORACLE)
|
||||
add_subdirectory(dotnet/oracle)
|
||||
add_subdirectory(native/media)
|
||||
endif()
|
||||
|
||||
if(VOICECAT_BUILD_SERVER)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.203-noble AS build
|
||||
WORKDIR /src
|
||||
COPY global.json Directory.Build.props Directory.Build.targets ./
|
||||
COPY core/proto/voicecat.proto core/proto/voicecat.proto
|
||||
COPY proto/voicecat.proto proto/voicecat.proto
|
||||
COPY dotnet/ dotnet/
|
||||
RUN dotnet restore dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj -r linux-x64 --locked-mode -p:NuGetLockFilePath=packages.publish.linux-x64.lock.json
|
||||
RUN dotnet publish dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj -c Release -r linux-x64 --self-contained true --no-restore \
|
||||
|
||||
+30
-1579
File diff suppressed because it is too large
Load Diff
@@ -1,68 +1,45 @@
|
||||
# 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.
|
||||
VoiceCat is a self-hosted, channel-based voice and text chat system built on .NET 10. It uses
|
||||
TLS 1.3 for protobuf control traffic and authenticated encrypted UDP for Opus media. There is
|
||||
no WebRTC, central directory, or plaintext mode.
|
||||
|
||||
> **Status:** Design complete in [`docs/`](docs/). M1–M5 are implemented — real TLS control
|
||||
> plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree,
|
||||
> permissions, moderation, disconnect/keepalive/reaper. Windows WinForms C# client shipped
|
||||
> (M4). macOS/iOS Swift client is next. See [`PROGRESS.md`](PROGRESS.md) and
|
||||
> [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## 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`.
|
||||
The repository contains a managed server, CLI, shared client/audio core, and native Windows,
|
||||
macOS, and iOS user interfaces. A small C library supplies Opus/RNNoise, and a small Swift iOS
|
||||
extension captures ReplayKit application audio.
|
||||
|
||||
## Build
|
||||
|
||||
The default development preset is **`dev`** — it builds everything (server + tools + tests)
|
||||
with real vcpkg deps. It works on Windows, Linux, and macOS (vcpkg triplet auto-resolved).
|
||||
|
||||
```bash
|
||||
# one-time vcpkg setup (bundled as a submodule, pinned to vcpkg.json's builtin-baseline):
|
||||
git submodule update --init vcpkg
|
||||
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||
|
||||
# configure + build + test:
|
||||
cmake --preset dev
|
||||
cmake --build --preset dev
|
||||
ctest --preset dev # 21 behavior tests
|
||||
./dotnet/build-native.ps1
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
```
|
||||
|
||||
To use an external vcpkg checkout instead, set `VCPKG_ROOT=/path/to/vcpkg` (or
|
||||
`$env:VCPKG_ROOT` on PowerShell) — it always takes priority over the bundled submodule.
|
||||
|
||||
Artifacts land in `build/dev/bin/` (`voicecat-server`, `vccli`, `voicecat-admin`).
|
||||
|
||||
The `skeleton` preset (no vcpkg deps, stubs only) is a fast smoke check that needs no
|
||||
third-party libraries:
|
||||
|
||||
```bash
|
||||
cmake --preset skeleton && cmake --build --preset skeleton && ctest --preset skeleton
|
||||
```
|
||||
|
||||
See [`docs/building.md`](docs/building.md) for the full preset matrix (including `release`,
|
||||
`server-release`, `windows-client`, and Apple platform scaffolding).
|
||||
See [CLAUDE.md](CLAUDE.md) for the developer map, [docs/README.md](docs/README.md) for current
|
||||
contracts, and [PROGRESS.md](PROGRESS.md) for the short release handoff.
|
||||
|
||||
## 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
|
||||
```text
|
||||
proto/ protobuf wire schema
|
||||
dotnet/src/ managed protocol, crypto, server, client, audio, and CLI
|
||||
dotnet/tests/ managed behavior and integration tests
|
||||
clients/windows/ WinForms client
|
||||
clients/apple/dotnet/ AppKit and UIKit clients
|
||||
native/media/ narrow Opus/RNNoise C shim
|
||||
native/rnnoise/ vendored RNNoise source and model
|
||||
native/apple/broadcast/ ReplayKit broadcast extension
|
||||
docs/ current contracts and operating documentation
|
||||
```
|
||||
|
||||
## License
|
||||
The remaining C++ implementation and old Swift applications are unsupported retirement
|
||||
sources. They are not compatibility targets or architectural authorities.
|
||||
|
||||
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).
|
||||
## Non-negotiable constraints
|
||||
|
||||
- Encryption is mandatory.
|
||||
- No GPL or LGPL dependencies.
|
||||
- Real-time audio callbacks never allocate, lock, block, or perform I/O.
|
||||
- Accessibility is a release requirement on every client platform.
|
||||
|
||||
@@ -107,7 +107,7 @@ scripts/build-xcframework.sh --all
|
||||
The `apple-dev` CMake preset produces a 1.9 MB `libvoicecat.a` containing only voicecat's
|
||||
own object files — vcpkg's static dependencies (protobuf, mbedtls, libsodium, opus, sqlite3,
|
||||
spdlog, asio, abseil, …) are 107 separate `.a` files under `vcpkg_installed/arm64-osx/lib/`,
|
||||
and the vendored RNNoise noise-suppression lib (`third_party/rnnoise/`, built as a CMake
|
||||
and the vendored RNNoise noise-suppression lib (`native/rnnoise/`, built as a CMake
|
||||
target → `build/<preset>/lib/librnnoise.a`) is another. A Swift Package binary target can
|
||||
only link ONE `.a` per XCFramework slice, so `build-xcframework.sh` merges them all — vcpkg
|
||||
deps plus the locally-built vendored libs — into a single self-contained `libvoicecat-fat.a`
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
The managed client now implements the Swift client's functional surface: profiles and Keychain authentication, TOFU, protected channels, hierarchical channel presentation and roster state, channel and modeless private text, microphone/auxiliary/screen-audio streams, selectable Core Audio devices, VAD/PTT/always-on input, stereo microphone, RNNoise, per-stream receive tuning, self/server mute and deafen, full channel configuration, moderation, permissions, account administration, event sounds and speech. It imports the legacy Swift profile, TOFU and Keychain state during cutover. Capture is converted to interleaved 48 kHz int16 PCM through `AVAudioConverter`; playback converts the shared bounded `PcmRing` into Core Audio's native planar Float32 layout inside an allocation-free, non-blocking `AVAudioSourceNode` callback.
|
||||
|
||||
The Swift app remains the release oracle until the managed app passes the manual VoiceOver and real multi-human call matrix. Produce and validate an ad-hoc Release bundle with `zsh clients/apple/dotnet/publish-macos.sh --dry-run`. The script prefers `/usr/local/share/dotnet/dotnet`, where the pinned Apple workload is installed; set `VOICECAT_DOTNET` to override that host. Signing is performed on `bin/Release/distribution/VoiceCat.app`, leaving MSBuild's incremental app bundle untouched. For distribution, set `VOICECAT_CODESIGN_IDENTITY`; setting `APPLE_ID`, `APPLE_TEAM_ID`, and `APPLE_APP_PASSWORD` additionally submits, staples, and Gatekeeper-validates the notarized bundle.
|
||||
The managed clients are the supported implementation. Manual VoiceOver and real multi-human call validation remain release gates. Produce and validate an ad-hoc Release bundle with `zsh clients/apple/dotnet/publish-macos.sh --dry-run`. The script prefers `/usr/local/share/dotnet/dotnet`, where the pinned Apple workload is installed; set `VOICECAT_DOTNET` to override that host. Signing is performed on `bin/Release/distribution/VoiceCat.app`, leaving MSBuild's incremental app bundle untouched. For distribution, set `VOICECAT_CODESIGN_IDENTITY`; setting `APPLE_ID`, `APPLE_TEAM_ID`, and `APPLE_APP_PASSWORD` additionally submits, staples, and Gatekeeper-validates the notarized bundle.
|
||||
|
||||
Build on Apple Silicon macOS 27 with Xcode 27, .NET SDK 10.0.401 and workload set 10.0.401. Homebrew `protobuf` supplies a native arm64 `protoc`; the current `Grpc.Tools` package contains only an x64 macOS compiler.
|
||||
|
||||
```bash
|
||||
sudo dotnet workload install macos ios --version 10.0.401
|
||||
brew install protobuf # if /opt/homebrew/bin/protoc is not already present
|
||||
cmake -S dotnet/native -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release -DVOICECAT_DOTNET_RID=osx-arm64
|
||||
cmake -S native/media -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release -DVOICECAT_DOTNET_RID=osx-arm64
|
||||
cmake --build dotnet/artifacts/native-build --config Release --target voicecat_media --parallel 2
|
||||
cmake --install dotnet/artifacts/native-build --config Release --component DotnetMedia --prefix dotnet/artifacts/native
|
||||
dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx
|
||||
@@ -29,7 +29,10 @@ dotnet build clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj -c Debug -r i
|
||||
For a physical device, use the checked-in build and deployment wrappers. The iPhone must be
|
||||
paired and trusted, and Xcode must have an Apple Development identity and provisioning profile
|
||||
for both `me.iamtalon.voicecat` and `me.iamtalon.voicecat.broadcast`. Let automatic signing
|
||||
select them, or set `VOICECAT_CODESIGN_KEY` and `VOICECAT_CODESIGN_PROVISION` before building.
|
||||
select them, or set `VOICECAT_CODESIGN_KEY`, `VOICECAT_CODESIGN_PROVISION`, and
|
||||
`VOICECAT_DEVELOPMENT_TEAM` before building. The development team is required by the retained
|
||||
ReplayKit extension's Xcode build. Set `VOICECAT_ALLOW_PROVISIONING_UPDATES=1` only when Xcode
|
||||
needs to create or download a profile.
|
||||
|
||||
```bash
|
||||
clients/apple/dotnet/build-ios-device.sh --configuration Debug
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
<BundleResource Include="../../Sources/VoiceCatCore/Sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
|
||||
<BundleResource Include="../../../../assets/sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Every managed media project stages the same dylib for ordinary .NET consumers.
|
||||
|
||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -42,8 +42,8 @@
|
||||
<BuildOutput>.</BuildOutput>
|
||||
<CodesignEntitlements>$(VoiceCatBroadcastOutput)/VoiceCatBroadcast.xcent</CodesignEntitlements>
|
||||
</AdditionalAppExtensions>
|
||||
<BundleResource Include="../../Sources/VoiceCatCore/Sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
|
||||
<ImageAsset Include="../../iOS/VoiceCatiOS/Assets.xcassets/**" Link="Assets.xcassets/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
<BundleResource Include="../../../../assets/sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
|
||||
<ImageAsset Include="Assets.xcassets/**" Link="Assets.xcassets/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
<Target Name="VoiceCatBuildBroadcastExtension" BeforeTargets="_ResolveAppExtensionReferences">
|
||||
<Exec Command=""$(MSBuildThisFileDirectory)build-broadcast-extension.sh" "$(Configuration)" "$(VoiceCatBroadcastSdk)" "$(VoiceCatBroadcastArch)" "$(VoiceCatBroadcastOutput)"" />
|
||||
|
||||
@@ -6,26 +6,32 @@ sdk="${2:?sdk is required}"
|
||||
architecture="${3:?architecture is required}"
|
||||
output="${4:?output directory is required}"
|
||||
script_dir="${0:A:h}"
|
||||
project="$script_dir/../../iOS/VoiceCatiOS.xcodeproj"
|
||||
project="$script_dir/../../../../native/apple/broadcast/VoiceCatBroadcast.xcodeproj"
|
||||
|
||||
mkdir -p "$output"
|
||||
signing=()
|
||||
if [[ "$sdk" == "iphonesimulator" ]]; then
|
||||
signing+=(CODE_SIGNING_ALLOWED=NO)
|
||||
elif [[ -n "${VOICECAT_DEVELOPMENT_TEAM:-}" ]]; then
|
||||
signing+=(DEVELOPMENT_TEAM="$VOICECAT_DEVELOPMENT_TEAM" CODE_SIGN_STYLE=Automatic)
|
||||
if [[ "${VOICECAT_ALLOW_PROVISIONING_UPDATES:-}" == "1" ]]; then
|
||||
signing=(-allowProvisioningUpdates "${signing[@]}")
|
||||
fi
|
||||
fi
|
||||
xcodebuild \
|
||||
-project "$project" \
|
||||
-target VoiceCatBroadcast \
|
||||
-scheme VoiceCatBroadcast \
|
||||
-configuration "$configuration" \
|
||||
-sdk "$sdk" \
|
||||
-arch "$architecture" \
|
||||
-derivedDataPath "$output/derived" \
|
||||
CONFIGURATION_BUILD_DIR="$output" \
|
||||
"${signing[@]}" \
|
||||
build
|
||||
|
||||
xcent="$script_dir/../../iOS/build/VoiceCatiOS.build/${configuration}-${sdk}/VoiceCatBroadcast.build/VoiceCatBroadcast.appex.xcent"
|
||||
xcent=$(find "$output/derived" -name 'VoiceCatBroadcast.appex.xcent' -print -quit)
|
||||
if [[ -f "$xcent" ]]; then
|
||||
cp "$xcent" "$output/VoiceCatBroadcast.xcent"
|
||||
else
|
||||
cp "$script_dir/../../iOS/VoiceCatBroadcast/VoiceCatBroadcast.entitlements" "$output/VoiceCatBroadcast.xcent"
|
||||
cp "$script_dir/../../../../native/apple/broadcast/VoiceCatBroadcast.entitlements" "$output/VoiceCatBroadcast.xcent"
|
||||
fi
|
||||
|
||||
@@ -123,7 +123,7 @@ build_slice() {
|
||||
# Two source dirs:
|
||||
# - vcpkg_target_lib_dir: vcpkg-installed deps (protobuf, mbedtls, sodium, opus, …).
|
||||
# - local_lib_dir: CMake static-lib targets built in this tree that are NOT vcpkg deps —
|
||||
# e.g. the vendored RNNoise lib (third_party/rnnoise, VOICECAT_HAS_NS). Without it the
|
||||
# e.g. the vendored RNNoise lib (native/rnnoise, VOICECAT_HAS_NS). Without it the
|
||||
# fat lib references _rnnoise_* symbols that nothing defines → undefined-symbol link
|
||||
# errors in Xcode. Exclude libvoicecat* (the main lib is $lib; libvoicecat-fat.a is the
|
||||
# output we're building here).
|
||||
|
||||
@@ -40,4 +40,4 @@ The noninteractive startup and real WASAPI device check is:
|
||||
dotnet run --project clients/windows/VoiceCat.App/VoiceCat.App.csproj
|
||||
```
|
||||
|
||||
Manual release validation still includes NVDA navigation and a ten-minute two-client listen test, as required by `docs/porting-to-dotnet.md`.
|
||||
Manual release validation includes NVDA navigation and a sustained two-client listen test, as tracked in `docs/roadmap.md`.
|
||||
|
||||
+4
-4
@@ -55,9 +55,9 @@ find_package(protobuf CONFIG REQUIRED)
|
||||
# Generate C++ from voicecat.proto into the build tree.
|
||||
protobuf_generate(
|
||||
TARGET voicecat
|
||||
PROTOS proto/voicecat.proto
|
||||
PROTOS ../proto/voicecat.proto
|
||||
LANGUAGE cpp
|
||||
IMPORT_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/proto
|
||||
IMPORT_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/../proto
|
||||
PROTOC_OUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated/proto)
|
||||
# Generated .pb.h files are included by protocol/envelope.h (consumed by tests and server),
|
||||
# so the generated dir and protobuf itself must be PUBLIC.
|
||||
@@ -74,12 +74,12 @@ find_package(protobuf CONFIG REQUIRED)
|
||||
|
||||
target_compile_definitions(voicecat PUBLIC VOICECAT_HAS_OPUS VOICECAT_HAS_AUDIO)
|
||||
|
||||
# ── RNNoise vendored noise-suppression DSP (third_party/rnnoise, BSD-3 + CC0). ──────────
|
||||
# ── RNNoise vendored noise-suppression DSP (native/rnnoise, BSD-3 + CC0). ──────────
|
||||
# The real backend behind ApmProcessor . Built as a standalone C
|
||||
# static lib with NO run-time CPU dispatch (RTCD off): portable scalar path on x86,
|
||||
# auto-NEON on arm64. -DDISABLE_DEBUG_FLOAT selects the int8-quantized weights that match our
|
||||
# shrunk model (third_party/README.md). The vcpkg port is !windows !arm, so we vendor it.
|
||||
set(RNNOISE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../third_party/rnnoise)
|
||||
set(RNNOISE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../native/rnnoise)
|
||||
add_library(rnnoise STATIC
|
||||
${RNNOISE_DIR}/src/denoise.c
|
||||
${RNNOISE_DIR}/src/rnn.c
|
||||
|
||||
@@ -49,7 +49,7 @@ class EnergyVadProcessor final : public ApmProcessor {
|
||||
};
|
||||
|
||||
// RnnoiseProcessor
|
||||
// Real noise suppression via vendored RNNoise (third_party/rnnoise
|
||||
// Real noise suppression via vendored RNNoise (native/rnnoise
|
||||
// RNNoise is a mono, 48 kHz, fixed 480-sample (10 ms) speech denoiser; our engine clock is fixed
|
||||
// at 48 kHz and every Opus frame size (480/960/1920/2880) is a multiple of 480, so we process
|
||||
// whole 480-sample chunks with no resampling and no cross-call carry. Mono only — callers gate
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
namespace voicecat::codec {
|
||||
|
||||
// Mirrors voicecat::v1::OpusApplication (core/proto/voicecat.proto) without depending on
|
||||
// Mirrors voicecat::v1::OpusApplication (proto/voicecat.proto) without depending on
|
||||
// generated protobuf headers from this low-level codec module.
|
||||
enum class OpusApplication {
|
||||
Voip = 0, // speech, optimized for low-rate intelligibility
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* protocol/protocol.h: control-plane (de)serialization + routing.
|
||||
*
|
||||
* Wire format is a length-prefixed protobuf `Envelope`
|
||||
* (core/proto/voicecat.proto). This layer parses frames into Envelopes, correlates
|
||||
* (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
|
||||
*
|
||||
|
||||
+31
-62
@@ -1,69 +1,38 @@
|
||||
# VoiceCat — Design Documentation
|
||||
# VoiceCat documentation
|
||||
|
||||
VoiceCat is a self-hosted, server-based voice and text chat system in the spirit of
|
||||
classic TeamSpeak / Mumble: channel-based voice, channel and private text chat, and a
|
||||
single server you own and run. It deliberately avoids WebRTC. The media path is plain
|
||||
**UDP**, the control path is plain **TCP**, and both are encrypted.
|
||||
VoiceCat is a self-hosted channel-based voice and text system. Control traffic uses TLS 1.3;
|
||||
media uses authenticated encrypted UDP derived from the TLS session. There is no plaintext
|
||||
mode and no central service.
|
||||
|
||||
This folder is the design spec. No code yet — these documents define the architecture,
|
||||
the wire protocol, the audio pipeline, the security model, and the dependency list, so
|
||||
that implementation can start from a shared, agreed plan.
|
||||
The .NET 10 implementation is the source of truth. Some detailed documents predate the managed
|
||||
rewrite and are being corrected as code changes touch them. When prose conflicts with current
|
||||
managed code or tests, follow the managed implementation and fix the document in the same
|
||||
change.
|
||||
|
||||
## Decisions locked so far
|
||||
## Current documents
|
||||
|
||||
| Area | Decision |
|
||||
|------|----------|
|
||||
| Code architecture | **Shared C++ core** (`libvoicecat`) consumed by native UIs over a **C ABI**. Server reuses the same core. |
|
||||
| Native clients | Managed **C#** WinForms and AppKit replacements are implemented; Swift macOS remains the migration/release oracle pending manual cutover gates, and iOS remains Swift. |
|
||||
| Control transport | **TCP + TLS 1.3** (mbedTLS) |
|
||||
| Media transport | **UDP** secured by **TLS-exported keys + ChaCha20-Poly1305 AEAD** — mandatory, no plaintext mode (see [security.md](security.md)) |
|
||||
| Crypto libraries | **mbedTLS** (TLS 1.3) + **libsodium** (AEAD, Argon2id, Ed25519) — both permissive, **no GPL/LGPL anywhere** |
|
||||
| Voice codec | **Opus** (libopus 1.6), per-channel configurable mono/stereo, bitrate, frame size, FEC/DTX |
|
||||
| Audio DSP | **webrtc-audio-processing (APM)** was the plan for AEC/NS/AGC/VAD, but has no working Windows/MSVC build upstream — v1 ships a lightweight energy/RMS VAD instead, no AEC/NS/AGC yet (see [tech-stack.md](tech-stack.md), [roadmap.md](roadmap.md) §2). NR is **two-sided**: sender can denoise, and each listener can denoise a *specific* other user locally — this plumbing exists but is currently inert pending a real DSP backend. Input gate supports **VAD and PTT**, client-configurable. |
|
||||
| Identity | **Guests + admin-provisioned local accounts** (Argon2id, SQLite). No self-serve registration; guests toggleable per server. |
|
||||
| Text | **Ephemeral** — live relay, no server-side history in v1. |
|
||||
| Serialization | **Protocol Buffers** for the control plane; **custom binary** for voice frames |
|
||||
| Deployment | **One `docker run`, one static binary, or `cmake --build`** — zero-config, secured by default (see [deployment.md](deployment.md)) |
|
||||
- [architecture.md](architecture.md) — components, ownership, concurrency, and native boundary.
|
||||
- [protocol.md](protocol.md) — protobuf control messages and fixed UDP media header.
|
||||
- [security.md](security.md) — TLS, TOFU, media keys, authentication, and threat model.
|
||||
- [voice.md](voice.md) — streams, Opus, loss handling, jitter, mixing, and screen audio.
|
||||
- [api-dotnet.md](api-dotnet.md) — managed APIs and ownership contracts.
|
||||
- [building.md](building.md) — development, platform builds, tests, and publishing.
|
||||
- [deployment.md](deployment.md) — server configuration and packaging.
|
||||
- [ios-deploy.md](ios-deploy.md) — managed iOS physical-device build and deployment.
|
||||
- [tech-stack.md](tech-stack.md) — supported dependencies and licensing.
|
||||
- [broadcast-ring-format.md](broadcast-ring-format.md) — frozen iOS extension/host ring ABI.
|
||||
- [roadmap.md](roadmap.md) — only current release gates and intentionally deferred features.
|
||||
|
||||
## Document index
|
||||
The completed C++-to-.NET migration plan was removed. Git history preserves that work without
|
||||
making every future agent load an obsolete implementation diary.
|
||||
|
||||
1. [architecture.md](architecture.md) — System layers, the shared core, threading model, the C ABI, server design.
|
||||
2. [protocol.md](protocol.md) — The control protocol: framing, connection lifecycle, the full message catalog, encoding, versioning/extensibility.
|
||||
3. [voice.md](voice.md) — The UDP media protocol: voice frame format, Opus configuration, multi-stream model, jitter buffer, packet-loss handling.
|
||||
4. [security.md](security.md) — Mandatory encryption (TLS 1.3 + exported-key media AEAD), server identity (TOFU), authentication, accounts, anti-replay, threat model.
|
||||
5. [tech-stack.md](tech-stack.md) — Concrete libraries with versions and rationale, the permissive-license rule, build tooling, per-platform notes.
|
||||
6. [deployment.md](deployment.md) — The "set it up in a few minutes" story: Docker, single binary, source build, zero-config defaults.
|
||||
7. [roadmap.md](roadmap.md) — Milestones, what ships when, and the list of open questions still to resolve.
|
||||
8. [porting-to-dotnet.md](porting-to-dotnet.md) — **Active migration.** Step-by-step plan and implementation checkpoints for replacing the C++ core, C++ server, and replaceable Swift clients with .NET 10 / C#. Dependency map, TLS exporter, real-time-audio design, and phased cutover gates.
|
||||
## Durable rules
|
||||
|
||||
## Design principles
|
||||
|
||||
- **One core, many faces.** Protocol, crypto, Opus, networking, jitter buffering, and
|
||||
mixing live once in C++. UIs are thin. This keeps behavior identical across platforms
|
||||
and the security-sensitive code reviewed in a single place.
|
||||
- **Boringly simple transport.** TCP for control, UDP for media. No ICE, no SDP, no
|
||||
TURN. A user opens a port (or port-forwards) and runs a server.
|
||||
- **Extensible from day one.** Every message rides in a versioned envelope; capabilities
|
||||
are negotiated at connect time; unknown fields are ignored. File transfer, screen-audio,
|
||||
and moderation slot in without breaking older clients.
|
||||
- **Real-time correctness.** The audio thread never blocks, never allocates, never takes a
|
||||
lock. Network and audio communicate through lock-free ring buffers.
|
||||
- **Encrypted, always.** There is no unencrypted mode to misconfigure. The server has no
|
||||
plaintext listener; encryption is on because it can't be turned off. And it's free to the
|
||||
operator — the server self-provisions its key/cert on first run.
|
||||
- **Stupid-easy to self-host.** The target reaction is "oh, I (or my agent) can stand this up
|
||||
in a few minutes." One `docker run`, or one static binary, or a plain `cmake --build` — no
|
||||
certificate wrangling, no external database, sane defaults out of the box. Permissive
|
||||
licenses only (no GPL/LGPL) so it can be redistributed freely, including closed-source.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Core** — `libvoicecat`, the shared C++ library.
|
||||
- **Control channel** — the TCP/TLS connection carrying protobuf messages.
|
||||
- **Media channel** — the UDP connection carrying voice frames.
|
||||
- **Stream** — one audio source from one user (e.g. mic, screen audio, second device). A
|
||||
user may publish several streams at once; each is independently controllable.
|
||||
- **Channel** — a room in the channel tree. Voice is scoped to a channel.
|
||||
- **Session** — an authenticated connection; ties a TCP control channel to a UDP 5-tuple.
|
||||
- **SFU relay** — the server forwards Opus packets between channel members without decoding
|
||||
them (selective forwarding, no transcoding).
|
||||
- `proto/voicecat.proto` is the control-plane schema.
|
||||
- Encryption is mandatory.
|
||||
- No GPL or LGPL dependencies.
|
||||
- Real-time audio callbacks never allocate, lock, block, or perform I/O.
|
||||
- The server relays encoded media; it does not mix or transcode.
|
||||
- Text is ephemeral in v1.
|
||||
- Accounts are administrator-provisioned; guests are an operator choice.
|
||||
- Wire, database, and shared-ring changes are explicitly versioned.
|
||||
|
||||
+5
-5
@@ -132,7 +132,7 @@ erasure of every runtime/library copy.
|
||||
|
||||
`VoiceCat.Codec` and `VoiceCat.Dsp` call the desktop `voicecat_media` native library
|
||||
through source-generated `LibraryImport`. It links pinned Opus 1.5.2 and the existing
|
||||
vendored RNNoise; it has no dependency on libvoicecat or its C ABI. Fixed C signatures
|
||||
vendored RNNoise; it has no dependency on the retired native core or its C ABI. Fixed C signatures
|
||||
wrap Opus controls so P/Invoke never calls C varargs. SafeHandle owns every native
|
||||
encoder, decoder, DRED parser/state, and denoiser, including failed initialization.
|
||||
|
||||
@@ -168,7 +168,7 @@ can try FEC/PLC. Only consume recovery output on success. Parse/native errors th
|
||||
`RnnoiseProcessor.Process(Span<short>, sampleRate)` operates in place on complete
|
||||
480-sample mono chunks at 48 kHz. Other rates pass through unchanged; partial chunks
|
||||
at 48 kHz throw instead of leaving a tail silently untreated. Float scratch is
|
||||
preallocated, and rounding/clipping matches the C++ processor. Use distinct instances
|
||||
preallocated, and rounding/clipping is covered by canonical vectors. Use distinct instances
|
||||
for stereo channels when the later pipeline supports stereo microphone denoising.
|
||||
Noise reduction does not gate speech.
|
||||
|
||||
@@ -230,7 +230,7 @@ Database v2 has no DRED column; CRUD rejects DRED rather than silently losing it
|
||||
|
||||
Session permissions gate kick/ban/move/mute and account operations. Only administrators
|
||||
can grant permissions; account-administration permission cannot grant administrator status.
|
||||
These two permission restrictions are stricter than the C++ oracle. Moves bypass channel
|
||||
These two permission restrictions are intentional managed-server policy. Moves bypass channel
|
||||
passwords but respect capacity and clear streams. Server mute/deafen immediately updates
|
||||
encrypted routing. Kick/ban retire routing before closure and emit one LEFT with the reason.
|
||||
Account bans persist by username; guest bans persist by address because nicknames are not
|
||||
@@ -281,7 +281,7 @@ The reaper sends a fatal disconnect, removes presence/routing and broadcasts one
|
||||
event. Valid UDP activity keeps a TCP-idle client alive. Shutdown cancels and awaits
|
||||
the accept, reaper, control and media loops before disposing credentials/storage.
|
||||
|
||||
`AccountStore(path)` retains the C++ schema version 2, accepts version 1 migration,
|
||||
`AccountStore(path)` uses schema version 2 and accepts version 1 migration,
|
||||
and rejects unknown revisions. Opening an existing channel table does not reseed it.
|
||||
Account creation/authentication uses parameterized SQL; two password workers bound
|
||||
per-store Argon2 work. Failed authentication leaves `last_login` unchanged. Dispose
|
||||
@@ -293,7 +293,7 @@ the native administration CLI; there is no automatic bootstrap account.
|
||||
Argon2id v19 PHC strings: 16-byte salt, 32-byte output, new-hash parameters
|
||||
64 MiB memory, two iterations, parallelism one. Verification supports up to 128 MiB,
|
||||
ten iterations, parallelism four and 1024 UTF-8 password bytes; malformed or excessive
|
||||
hashes fail closed. Standard C++ interactive-cost accounts are preserved. These
|
||||
hashes fail closed. Standard interactive-cost Argon2id accounts are preserved. These
|
||||
bounds intentionally reject imported hashes above those costs. Native fixtures cover
|
||||
ASCII, Unicode and embedded NUL; the database oracle verifies cross-implementation
|
||||
authentication in both directions.
|
||||
|
||||
+57
-232
@@ -1,251 +1,76 @@
|
||||
# Architecture
|
||||
|
||||
The parallel .NET rewrite under `dotnet/` currently implements shared protocol framing,
|
||||
voice headers, and media crypto. Existing server/client/audio behavior remains in C++.
|
||||
See `docs/api-dotnet.md` for the initial managed contract and
|
||||
`docs/porting-to-dotnet.md` for subsequent migration phases.
|
||||
VoiceCat is a .NET 10 client/server application with a deliberately narrow native media
|
||||
boundary. The managed implementation is authoritative.
|
||||
|
||||
## 1. The shared-core model
|
||||
## Runtime components
|
||||
|
||||
All non-UI logic lives in one C++ library, **`libvoicecat`**. The same library is linked
|
||||
into every client and into the server. Platform UIs are thin and call the core through a
|
||||
stable **C ABI** (`voicecat.h`).
|
||||
```text
|
||||
Windows WinForms ─┐
|
||||
macOS AppKit ─────┼─ VoiceCat.Core ─ VoiceCat.Audio ─ native media shim
|
||||
iOS UIKit ────────┘ │
|
||||
├─ VoiceCat.Protocol
|
||||
└─ VoiceCat.Crypto
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────┐
|
||||
macOS / iOS (Swift) │ │ Windows (C#)
|
||||
┌──────────────────┐ │ libvoicecat (C++) │ ┌──────────────────┐
|
||||
│ SwiftUI views │ │ ┌─────────────────────────────────────┐ │ │ WinForms (.NET 10│
|
||||
│ AVAudioSession │──┼─▶│ C ABI (voicecat.h) │◀─┼──│ LibraryImport │
|
||||
│ Swift↔C++ interop│ │ ├─────────────────────────────────────┤ │ │ P/Invoke │
|
||||
└──────────────────┘ │ │ Session / Protocol state machine │ │ └──────────────────┘
|
||||
│ │ Text + voice signaling │ │
|
||||
Linux/macOS/Windows │ │ Audio engine: capture→encode→send, │ │
|
||||
server │ │ recv→jitter→decode→mix→playback │ │
|
||||
┌──────────────────┐ │ │ Codec layer (Opus 1.6) │ │
|
||||
│ voicecat-server │──┼─▶│ Crypto + transport (TLS 1.3 + AEAD) │ │
|
||||
│ (reuses core) │ │ │ Net I/O (Asio: TCP + UDP + timers) │ │
|
||||
└──────────────────┘ │ └─────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────┘
|
||||
VoiceCat.Server ──────────── VoiceCat.Protocol + VoiceCat.Crypto + SQLite
|
||||
```
|
||||
|
||||
Why this shape:
|
||||
The server terminates TLS control sessions and relays authenticated encrypted Opus packets.
|
||||
It does not decode, mix, or transcode media. Clients own capture, encoding, jitter/loss
|
||||
recovery, decoding, mixing, and playback.
|
||||
|
||||
- **Swift** (5.9+) can import C++ directly, but we still ship a C ABI because it is the
|
||||
lowest-friction, most stable boundary and it is what **C#** needs (`LibraryImport`/
|
||||
P/Invoke). One ABI serves both.
|
||||
- The **server** is not a separate codebase. It links the same protocol, crypto, and Opus
|
||||
code as the client, so framing/encryption can never drift between the two ends.
|
||||
## Managed projects
|
||||
|
||||
## 2. Layered design inside the core
|
||||
- `VoiceCat.Protocol`: generated protobuf types and bounded length-prefixed framing.
|
||||
- `VoiceCat.Crypto`: BouncyCastle TLS 1.3, exporter-derived media secrets, certificate TOFU,
|
||||
Ed25519 server identity, ChaCha20-Poly1305, replay windows, and Argon2id.
|
||||
- `VoiceCat.Codec`: safe ownership around fixed Opus shim handles.
|
||||
- `VoiceCat.Dsp`: RNNoise and energy-VAD ownership.
|
||||
- `VoiceCat.Audio`: streams, packet-loss recovery, jitter buffers, mixing, PCM rings, and
|
||||
activation policy.
|
||||
- `VoiceCat.Core`: connection lifecycle, authentication, state snapshots/events, requests,
|
||||
stream negotiation, encrypted UDP, reconnect, and administration helpers.
|
||||
- `VoiceCat.Server`: listener/session ownership, SQLite state, permissions, moderation,
|
||||
channel management, encrypted UDP routing, and process administration commands.
|
||||
- `VoiceCat.Cli`: interactive client and deterministic text/voice behavior driver.
|
||||
|
||||
From the OS up:
|
||||
Leaf UI projects reference the managed core; platform audio objects translate between native
|
||||
device buffers and bounded PCM rings owned by `VoiceCat.Audio`.
|
||||
|
||||
| Layer | Responsibility | Key deps |
|
||||
|-------|----------------|----------|
|
||||
| **Platform I/O** | Sockets, timers; audio device capture/playback | Asio, miniaudio |
|
||||
| **Transport** | TLS 1.3 (TCP), exported-key ChaCha20-Poly1305 AEAD (UDP), framing, anti-replay | mbedTLS, libsodium |
|
||||
| **Codec & DSP** | Opus encode/decode; APM (AEC/NS/AGC/VAD) send-side + per-user NR receive-side; resample; jitter buffer; mixer | libopus, webrtc-audio-processing, speexdsp |
|
||||
| **Protocol** | Message (de)serialization, request/response correlation, state machine | protobuf |
|
||||
| **Session/domain** | Channels, users, streams, permissions, text routing | — |
|
||||
| **C ABI façade** | Handle-based API + event callbacks exposed to UIs | — |
|
||||
## Native boundary
|
||||
|
||||
A UI never sees a socket, an Opus packet, or a protobuf message. It sees: "connect",
|
||||
"join channel", "start a stream from this device", "send this text", and a stream of
|
||||
events ("user joined", "user is talking", "message received", "level meter = 0.4").
|
||||
`native/media` builds `voicecat_media` for desktop and static Apple targets. It contains only
|
||||
fixed C entry points for Opus/DRED and RNNoise. Vendored RNNoise is under `native/rnnoise`.
|
||||
Networking, TLS, media encryption, session state, jitter, and mixing do not live in native code.
|
||||
|
||||
## 3. Threading model
|
||||
`native/apple/broadcast` is a Swift ReplayKit upload extension. It captures application audio,
|
||||
converts it to 48 kHz stereo int16 PCM, and writes the frozen App Group ring documented in
|
||||
`broadcast-ring-format.md`. The managed iOS host drains that ring and performs encoding and
|
||||
networking. The extension deliberately has no managed runtime or VoiceCat protocol stack.
|
||||
|
||||
Three classes of thread, with strict rules.
|
||||
The iOS host also has small Objective-C bridges in its managed project for platform APIs that
|
||||
need direct native entry points. These are platform adapters, not a second core.
|
||||
|
||||
```
|
||||
┌──────────────┐ lock-free ┌──────────────┐ lock-free ┌──────────────┐
|
||||
│ Audio capture│ ──ring buffer─▶│ Net thread │ ──ring buffer─▶│Audio playback│
|
||||
│ (RT, miniaudio│ │ (Asio loop) │ │ (RT, miniaudio│
|
||||
│ callback) │◀───ring buffer─│ │◀───ring buffer─│ callback) │
|
||||
│ capture→Opus │ │ TLS + AEAD, │ │ jitter→Opus │
|
||||
│ encode │ │ route, relay │ │ decode→mix │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
│
|
||||
┌─────▼──────┐
|
||||
│ Worker pool│ DB, Argon2id, file I/O,
|
||||
│ (blocking) │ TLS handshakes, codec setup
|
||||
└────────────┘
|
||||
```
|
||||
## Ownership and concurrency
|
||||
|
||||
Rules:
|
||||
- A `VoiceCatClient` owns one TLS control connection, one media session, and its audio engine.
|
||||
- Control/TLS operations have one serialized owner. UI code submits requests and observes
|
||||
events; it does not call TLS concurrently.
|
||||
- The server publishes immutable routing state to its UDP loop. The UDP loop owns endpoint
|
||||
binding, packet authentication, replay checks, and recipient resealing.
|
||||
- Audio callbacks consume or produce preallocated ring-buffer memory. They never allocate,
|
||||
lock, block, log, or access sockets.
|
||||
- Teardown establishes a quiescence barrier before callback-owned state is released.
|
||||
- Blocking file, database, TLS, and device lifecycle work stays off real-time callbacks.
|
||||
|
||||
- **Audio (real-time) threads** are driven by the OS audio callback. They must not
|
||||
allocate, lock, log, or do syscalls beyond the ring-buffer hand-off. Opus encode/decode
|
||||
runs here (it is allocation-free after init).
|
||||
- **Net thread(s)** run the Asio event loop: TLS records, AEAD seal/open, protobuf parse, channel
|
||||
routing, jitter-buffer feed. On the server, this is where the SFU relay copies packets
|
||||
to subscribers.
|
||||
- **Worker pool** absorbs anything that can block: SQLite, Argon2id verification, DNS,
|
||||
TLS handshake CPU, codec (re)configuration.
|
||||
- **Communication** between audio and net is single-producer/single-consumer lock-free
|
||||
ring buffers (one per direction per stream). Control-plane events to the UI go through a
|
||||
thread-safe queue drained on the UI's terms.
|
||||
## Data and contracts
|
||||
|
||||
## 4. The C ABI (`voicecat.h`) — shape
|
||||
- `proto/voicecat.proto` is the control-plane schema.
|
||||
- Media uses the fixed header and AEAD construction described in `protocol.md` and
|
||||
`security.md`.
|
||||
- SQLite is the server's persistent store; schema changes require explicit migrations.
|
||||
- Client profiles and TOFU pins are local platform data.
|
||||
- The ReplayKit ring layout is separately versioned and frozen.
|
||||
|
||||
Handle-based, opaque pointers, C-linkage. Illustrative (final names in implementation):
|
||||
|
||||
```c
|
||||
typedef struct vc_client vc_client;
|
||||
|
||||
typedef struct {
|
||||
void (*on_event)(void* user, const vc_event* ev); // state changes, messages
|
||||
void (*on_level)(void* user, uint32_t stream_id, float rms); // meters (throttled)
|
||||
void* user;
|
||||
} vc_callbacks;
|
||||
|
||||
vc_client* vc_client_create(const vc_config* cfg, vc_callbacks cb);
|
||||
void vc_client_destroy(vc_client*);
|
||||
|
||||
int vc_connect(vc_client*, const char* host, uint16_t port); // async; result via event
|
||||
int vc_authenticate_guest(vc_client*, const char* nickname);
|
||||
int vc_authenticate_user(vc_client*, const char* user, const char* password);
|
||||
|
||||
int vc_join_channel(vc_client*, uint32_t channel_id, const char* password /*nullable*/);
|
||||
int vc_leave_channel(vc_client*);
|
||||
|
||||
// Streams (mic / screen audio / aux device)
|
||||
int vc_stream_start(vc_client*, const vc_stream_desc* desc, uint32_t* out_stream_id);
|
||||
int vc_stream_stop(vc_client*, uint32_t stream_id);
|
||||
int vc_set_input_device(vc_client*, uint32_t stream_id, const char* device_id);
|
||||
int vc_set_self_mute(vc_client*, bool mic_muted, bool deafened);
|
||||
|
||||
// Text
|
||||
int vc_send_text(vc_client*, vc_text_scope scope, uint32_t target_id, const char* utf8);
|
||||
|
||||
// Enumeration helpers for UI device pickers
|
||||
int vc_list_devices(vc_client*, vc_device_kind kind, vc_device_list* out);
|
||||
```
|
||||
|
||||
Design notes:
|
||||
|
||||
- **Async, event-driven.** Calls return immediately; results and state changes arrive via
|
||||
`on_event`. This maps cleanly onto SwiftUI/`async` and C# `event`/`Task` patterns.
|
||||
- **The core owns audio.** Capture, encode, decode, mixing, and playback happen inside the
|
||||
core via miniaudio. The UI only *selects devices*, *starts/stops streams*, and *renders
|
||||
meters/state*. This keeps the real-time path identical on every OS. (iOS is the one
|
||||
exception that needs UI-side cooperation — see below.)
|
||||
- **Device enumeration works pre-connect.** `vc_list_devices` needs no live session — device
|
||||
pickers can populate before `vc_connect`. `vc_device.id` is an opaque, internally-encoded
|
||||
handle (currently a hex-encoded `ma_device_id`) — always round-trip an id that came from
|
||||
`vc_list_devices`/`vc_get_stream_audio_config`; never construct one by hand. Tolerate an
|
||||
empty list (a machine can legitimately have zero input or output devices).
|
||||
- **Strings are UTF-8 `const char*`; ownership is explicit.** Output buffers are
|
||||
caller-allocated or returned with a paired `vc_free`.
|
||||
|
||||
### Per-platform binding notes
|
||||
|
||||
- **Swift / Apple.** Import the C ABI via a **module map** (`module VoiceCatC { header "voicecat.h" }`) staged into the XCFramework headers by `clients/apple/scripts/build-xcframework.sh` — Swift gets a clean `import VoiceCatC` with all C enums/structs/functions available directly (no manual redeclaration, unlike the C# P/Invoke layer). A **Swift wrapper** (`VoiceCatCore` package at `clients/apple/`) provides Swift-idiomatic types (`VoiceCatResult`, `VoiceCatEvent`, `Channel`, `User`, etc.) on top, mirroring the C# `VoiceCat.Interop` layer. Callbacks use `@convention(c)` closures (plain C function pointers, not ARC-managed closures) + `Unmanaged.passUnretained(self)` as the `user` context (the Swift analog of C#'s `[UnmanagedCallersOnly]` + `GCHandle`). Events are delivered on `@MainActor` via a coalesced `DispatchQueue.main` drain (one async block scheduled at a time) — the Swift analog of C#'s `Channel<VoiceCatEvent>` + 30ms WinForms Timer pump. `deinit` calls `vc_client_destroy` (joins all threads) then frees native CString config storage (the core stores raw pointers, doesn't copy). **macOS UI: AppKit** (chosen over SwiftUI for the most mature VoiceOver accessibility story — same rationale as the Windows client's WinForms choice); **iOS UI: SwiftUI** (narrower control surface, sufficient VoiceOver support). On **iOS** the app owns `AVAudioSession` (category `.playAndRecord`), requests mic permission, and handles interruptions/route changes — the core exposes hooks (`vc_audio_suspend`/`vc_audio_resume`/`vc_audio_restart`, implemented) the Swift layer calls from `AVAudioSession` notifications and `IOSAudioRouter` setting changes. All iOS audio routing (input port selection, mic orientation/polar patterns, HFP vs A2DP, measurement/raw mode, stereo capture) is driven from the Swift `IOSAudioRouter` singleton via `AVAudioSession` *before* the core (miniaudio) opens its device — miniaudio does NOT touch `AVAudioSession` on iOS. The core is told the capture channel count via `vc_set_capture_channels` (append-only ABI). `vc_audio_restart` does a full stop + re-init (unlike `suspend`/`resume` which only stop/start) so devices reopen against a new route after `AVAudioSession` reconfiguration. iOS 18.0 deployment target. Background voice and VoIP push (CallKit/PushKit) are a later milestone. The XCFramework carries a **fat static library** (`libvoicecat-fat.a`) bundling `libvoicecat.a` + all vcpkg static deps so the Swift Package links a single self-contained `.a` per slice.
|
||||
- **iOS screen / system-audio sharing** is supported via a **ReplayKit Broadcast Upload
|
||||
Extension** (the same mechanism Discord uses; triggered from Control Center's screen-record
|
||||
button via `RPSystemBroadcastPickerView`). The extension receives
|
||||
`RPSampleBufferType.audioApp` (system/app audio) and `.audioMic`. We capture **`.audioApp`**
|
||||
for the `SCREEN_AUDIO` "listen together" stream and ignore video. The extension runs in a
|
||||
**separate process with a ~50 MB memory cap** — that cap is a problem only for video
|
||||
frames, so audio-only stays well within budget. It links a *minimal* slice of the core
|
||||
(Opus encode + media send), shares the session/credentials with the host app through an
|
||||
**App Group**, and re-derives its own media keys. This is detailed in [voice.md](voice.md) §9.
|
||||
- **C# / Windows.** `[LibraryImport]` (source-generated P/Invoke, .NET 7+) over the C ABI.
|
||||
`[UnmanagedCallersOnly]` static methods for `on_event`/`on_level` to avoid delegate-lifetime
|
||||
pitfalls. UI in **WinForms (.NET 10)** — chosen over WinUI 3/Avalonia for its mature,
|
||||
predictable screen-reader (NVDA/JAWS/Narrator) UIA support (see roadmap.md §2).
|
||||
Events are delivered via `System.Threading.Channels.Channel<VoiceCatEvent>`, drained by a
|
||||
30ms `System.Windows.Forms.Timer` on the UI thread — simpler than a message-only HWND +
|
||||
`PostMessage` with no meaningful latency cost. `VoiceCatClientHandle : SafeHandle` wraps
|
||||
the `vc_client*` and guarantees `vc_client_destroy` runs on GC/Dispose.
|
||||
|
||||
### External PCM feed/tap
|
||||
|
||||
Two API functions let callers bypass miniaudio entirely for a stream:
|
||||
|
||||
| Function | Direction | Contract |
|
||||
|----------|-----------|----------|
|
||||
| `vc_stream_feed_pcm(c, stream_id, pcm, samples_per_channel, channels)` | **Send** — caller → network | Caller supplies interleaved int16 at the stream's sample rate (`channels` = 1 mono, 2 stereo). The core frames, Opus-encodes, AEAD-seals, and sends over UDP — identical wire path to hardware capture. The stream must already be started with `vc_stream_start`. Thread-safe; may be called from any thread (audio callback, ReplayKit delegate, SCStream callback). |
|
||||
| `vc_set_pcm_sink(c, cb, user)` | **Receive** — network → caller | `cb` is called on the audio (playback) thread once per decoded Opus frame per remote stream, with `(user_id, stream_id, pcm, samples_per_channel, channels, sample_rate)`. PCM is delivered to the sink **and** the hardware device — dual output; the hardware mix is unaffected. Pass `cb=NULL` to disable (default); once that call returns, no callback using the old `user` pointer remains in flight. **Must not block** — copy what you need and return. |
|
||||
|
||||
`vc_test_inject_capture` (the old TEST-ONLY mono-only predecessor) is a deprecated alias
|
||||
for `vc_stream_feed_pcm(..., channels=1)` — kept for source compatibility.
|
||||
|
||||
**Use cases:** ReplayKit Broadcast Extension (iOS `SCREEN_AUDIO`), ScreenCaptureKit (macOS
|
||||
`SCREEN_AUDIO`), music/TTS/relay bots, soundboards, transcription clients. The extension or
|
||||
bot links Opus + the feed entry point — no `ma_device`, no hardware, headless.
|
||||
|
||||
**Threading:** the feed path is thread-safe (ring buffer, no lock on the RT path). The sink
|
||||
callback runs on the miniaudio playback thread — observe the same rules as the capture
|
||||
callback: no allocations, no blocking calls.
|
||||
|
||||
## 5. Server architecture
|
||||
|
||||
`voicecat-server` is a headless process linking the core.
|
||||
|
||||
```
|
||||
TCP/TLS 1.3 UDP + media AEAD
|
||||
│ │
|
||||
┌────────▼─────────┐ ┌─────────▼──────────┐
|
||||
│ Connection mgr │ │ UDP demux │
|
||||
│ (accept, TLS, │ │ 5-tuple → session │
|
||||
│ per-conn state) │ │ anti-replay window │
|
||||
└────────┬─────────┘ └─────────┬──────────┘
|
||||
│ │
|
||||
┌────────▼───────────────────────────────────▼──────────┐
|
||||
│ Session registry (session_id ↔ TCP conn ↔ UDP tuple) │
|
||||
└────────┬───────────────────────────────┬───────────────┘
|
||||
│ │
|
||||
┌────────▼─────────┐ ┌──────────────┐ ┌▼─────────────────┐
|
||||
│ Channel manager │ │ Text router │ │ Voice router/SFU │
|
||||
│ tree, configs, │ │ channel + PM │ │ relay Opus to │
|
||||
│ membership, perms│ │ │ │ channel members │
|
||||
└────────┬─────────┘ └──────────────┘ └──────────────────┘
|
||||
│
|
||||
┌────────▼─────────┐
|
||||
│ Persistence │ accounts (Argon2id), channels, bans, config
|
||||
│ SQLite │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
- **Voice router is a relay, not a mixer.** For each incoming voice frame it looks up the
|
||||
sender's channel and forwards the *unmodified encoded Opus bytes* to other members.
|
||||
It authenticates/decrypts incoming media, then reseals with each recipient's directional
|
||||
key and counter. SSRC/timestamp/flags/codec pass through; sequence and ciphertext/tag change.
|
||||
No server-side decode/transcode → low CPU,
|
||||
low latency, and end-to-content is just Opus. Per-channel Opus params are enforced so all
|
||||
members are mutually decodable.
|
||||
- **Subscriptions.** Clients implicitly subscribe to their current channel's voice; text
|
||||
and presence can be subscribed more broadly. This keeps fan-out bounded on big servers.
|
||||
- **Stateless-ish media.** UDP carries no auth per packet beyond the media-AEAD session;
|
||||
the 5-tuple→session binding is established once via a token (see protocol.md §4).
|
||||
- **Keepalive reaper.** An `asio::steady_timer` sweeps every 15 s and drops any session
|
||||
whose `last_seen` (bumped on every inbound TCP or UDP frame) is older than 45 s. Each
|
||||
drop broadcasts `UserEvent::LEFT` so peers clean up immediately. This catches half-open
|
||||
connections that never produce a TCP EOF. Configurable via `server::Config`.
|
||||
- **Single process, scalable later.** v1 is one process, one machine. The session registry
|
||||
and router are written behind interfaces so a future build can sit them behind a shared
|
||||
bus for multi-node, but that is explicitly out of scope for now.
|
||||
|
||||
## 6. Repository layout (proposed)
|
||||
|
||||
```
|
||||
voice-cat/
|
||||
├── docs/ # this folder
|
||||
├── core/ # libvoicecat (C++)
|
||||
│ ├── include/voicecat.h # the C ABI
|
||||
│ ├── src/{net,crypto,codec,protocol,session,audio}/
|
||||
│ └── proto/ # .proto definitions (shared source of truth)
|
||||
├── server/ # voicecat-server (C++, links core)
|
||||
├── clients/
|
||||
│ ├── apple/ # Swift package + Xcode project (macOS + iOS)
|
||||
│ └── windows/ # .NET solution (C#)
|
||||
├── tools/
|
||||
│ └── vccli/ # headless test client (C++), for protocol bring-up
|
||||
├── third_party/ # vendored / vcpkg manifest
|
||||
└── CMakeLists.txt
|
||||
```
|
||||
|
||||
Build is **CMake** with **vcpkg** (manifest mode) for C/C++ deps; the Apple and Windows UI
|
||||
projects consume the built core as a binary + headers. See [tech-stack.md](tech-stack.md).
|
||||
Unsupported C++ and Swift applications may remain temporarily during repository cleanup, but
|
||||
they are not dependencies, compatibility targets, or design authorities.
|
||||
|
||||
+52
-515
@@ -1,550 +1,87 @@
|
||||
# Building & Manual Testing
|
||||
# Building and testing
|
||||
|
||||
## .NET rewrite
|
||||
## Prerequisites
|
||||
|
||||
The managed wire/crypto, TLS, and codec/DSP slices are under `dotnet/`, targeting .NET 10.
|
||||
From the root (CMake and a C compiler are required for codec/DSP):
|
||||
- .NET SDK selected by `dotnet/global.json`
|
||||
- CMake and a C compiler for the Opus/RNNoise shim
|
||||
- PowerShell for the cross-platform build scripts
|
||||
- Xcode plus the pinned .NET macOS/iOS workloads for Apple clients
|
||||
|
||||
```powershell
|
||||
Dependencies and NuGet lock files are committed. GPL/LGPL dependencies are forbidden.
|
||||
|
||||
## Managed core, server, CLI, and tests
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
./dotnet/build-native.ps1
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
./dotnet/check-licenses.ps1
|
||||
```
|
||||
|
||||
See `dotnet/README.md` for conformance fixtures and conventions. The C++ commands
|
||||
below remain required while the existing implementation is the migration oracle.
|
||||
The native script builds `native/media`, fetches checksum-pinned Opus 1.5.2, compiles vendored
|
||||
RNNoise, and stages the resulting library and notices in `dotnet/artifacts/native`.
|
||||
|
||||
This doc explains what each CMake preset in [`CMakePresets.json`](../CMakePresets.json) is
|
||||
*for*, which one to actually use day-to-day, and the commands to stand up a real server +
|
||||
`vccli` clients against each other for manual testing. For the one-paragraph quick-start see
|
||||
[`CLAUDE.md`](../CLAUDE.md); for `ctest` targets see [`AGENTS.md`](../AGENTS.md). This doc is
|
||||
the missing middle: *how the presets relate to each other*, *which platform each targets*,
|
||||
and *how to drive the binaries by hand*.
|
||||
|
||||
**Quick navigation:**
|
||||
|
||||
| What you want to build | Section | Key command |
|
||||
|------------------------|---------|-------------|
|
||||
| Server + `vccli` + tests (all platforms) | [§3](#3-build--test-the-loop-youll-run-constantly) | `cmake --preset dev && cmake --build --preset dev && ctest --preset dev` |
|
||||
| Production server (stripped, no tests) | [§5](#5-server-release-production-shaped-build) | `cmake --preset server-release && cmake --build --preset server-release` |
|
||||
| Windows client (C# / WinForms) | [§7](#7-windows-client-c--winforms) | `dotnet build clients/windows/VoiceCat.slnx` |
|
||||
| Managed macOS client (C# AppKit) | [§8](#8-macos-client-appkit) | `dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx` |
|
||||
| Swift macOS migration oracle | [§8](#8-macos-client-appkit) | `scripts/build-macos-client.sh` |
|
||||
| Managed iOS client (C# UIKit / simulator) | [§9](#9-ios-client-uikit) | `./dotnet/build-native-ios.sh && dotnet build clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj` |
|
||||
| Launch iOS app on simulator | [§9](#9-ios-client-swiftui) | `scripts/run-ios-simulator.sh` |
|
||||
| Swift core + tests | [§8](#8-macos-client-appkit) | `cd clients/apple && swift test` |
|
||||
|
||||
## 1. What each preset is for
|
||||
|
||||
| Preset | Binary dir | Deps | Build type | Server | Tools | Tests | Strip | Platform | What it's for |
|
||||
|--------|-----------|------|------------|--------|-------|-------|-------|----------|---------------|
|
||||
| `vcpkg-common` | — | vcpkg | — | — | — | — | — | all | Hidden base. Sets the vcpkg toolchain wrapper ([`cmake/voicecat-toolchain.cmake`](../cmake/voicecat-toolchain.cmake)) which auto-resolves the triplet from the host platform. Not used directly. |
|
||||
| `dev` | `build/dev` | vcpkg | Debug | ON | ON | ON | no | all | **The one you actually want.** Day-to-day development: real protocol, crypto, voice, server — everything. Builds server + tools + tests (29 tests). Works on Windows, Linux, and macOS (triplet auto-resolved). |
|
||||
| `release` | `build/release` | vcpkg | Release | ON | ON | ON | no | all | Optimized build with the full test suite. Use to run tests against optimized code, profile, or catch optimizer-sensitive bugs. Symbols kept (not stripped) so stack traces and profiling remain useful. |
|
||||
| `server-release` | `build/server-release` | vcpkg | Release | ON | ON | OFF | **yes** | all | Production-shaped build for deployment. Optimized + stripped binaries (`-s`), no tests. This is what you'd ship/run — see [docs/deployment.md](deployment.md). |
|
||||
| `windows-client` | `build/windows-client` | vcpkg | Release | OFF | OFF | OFF | no | Windows | Produces a redistributable `voicecat.dll` for the C# WinForms client (M4). Static MinGW runtime — no `libgcc_s_seh-1.dll` etc. See [clients/windows/README.md](../clients/windows/README.md). |
|
||||
| `apple-dev` | `build/apple-dev` | vcpkg | Release | OFF | OFF | OFF | no | macOS | Static `libvoicecat.a` for the Swift Package / XCFramework (macOS slice). Validated on macOS 26.5 / Apple Silicon — builds green, produces valid arm64 `.a` + XCFramework. See [clients/apple/README.md](../clients/apple/README.md). |
|
||||
| `apple-ios` | `build/apple-ios` | vcpkg | Release | OFF | OFF | OFF | no | macOS→iOS | **Scaffolding** — cross-compiled static `libvoicecat.a` for iOS device (`arm64-ios`). One XCFramework slice. Not yet CI-validated. |
|
||||
| `apple-ios-sim` | `build/apple-ios-sim` | vcpkg | Release | OFF | OFF | OFF | no | macOS→iOS sim | **Scaffolding** — cross-compiled static `libvoicecat.a` for iOS simulator (`arm64-ios-sim`). One XCFramework slice. Not yet CI-validated. |
|
||||
|
||||
So in practice there are two presets that matter for day-to-day work:
|
||||
- **`dev`** — everything: real protocol, real voice, real manual testing. This is the loop you run constantly.
|
||||
- **`release`** — same suite, optimized. Run it when you want to check optimized behavior or profile.
|
||||
|
||||
The rest are purpose-specific: `server-release` for deployment, `windows-client` for the DLL,
|
||||
`apple-*` for Apple platform slices.
|
||||
|
||||
### Platform matrix
|
||||
|
||||
The vcpkg presets (`dev`, `release`, `server-release`, `windows-client`, `apple-*`) auto-resolve
|
||||
the vcpkg triplet via [`cmake/voicecat-toolchain.cmake`](../cmake/voicecat-toolchain.cmake):
|
||||
|
||||
| Host platform | Auto-resolved triplet | Notes |
|
||||
|---------------|----------------------|-------|
|
||||
| Windows (MinGW/MSYS2) | `x64-mingw-static` | The project's Windows toolchain. MSVC users must set `VCPKG_TARGET_TRIPLET=x64-windows` explicitly. |
|
||||
| Linux x64 | `x64-linux` | Server's primary deployment target (Docker, systemd). |
|
||||
| Linux arm64 | `arm64-linux` | Raspberry Pi / ARM VPS. |
|
||||
| macOS (Apple Silicon) | `arm64-osx` | `apple-dev` uses this automatically. |
|
||||
| macOS (Intel) | `x64-osx` | `apple-dev` uses this automatically. |
|
||||
|
||||
Cross-compile presets (`apple-ios`, `apple-ios-sim`) override `VCPKG_TARGET_TRIPLET` explicitly;
|
||||
`VCPKG_HOST_TRIPLET` stays the host's (e.g. `arm64-osx` when building iOS on Apple Silicon).
|
||||
|
||||
### Preset history
|
||||
|
||||
The preset set was cleaned up on 2026-06-18 (see `PROGRESS.md`). The old names map as follows:
|
||||
|
||||
| Old name | New name | Notes |
|
||||
|----------|----------|-------|
|
||||
| `m1-dev` | `dev` | Renamed — the project is past M5, so milestone-named presets were misleading. This is now the default development preset. |
|
||||
| `m2-dev` | *(dropped)* | Was cache-identical to `m1-dev` (same flags, same triplet, only the binary dir differed). Removed. |
|
||||
| `skeleton` | *(dropped)* | Removed — was a no-deps stub build mode used during M0. All subsystems are now fully implemented; the stub `#ifdef` scaffolding has been deleted. |
|
||||
| `server-release` | `server-release` | Unchanged name; now stripped (`-s`) and auto-triplet. |
|
||||
| *(new)* | `release` | New: optimized build with tests on, symbols kept. |
|
||||
| `windows-client` | `windows-client` | Unchanged name; triplet now auto-resolved. |
|
||||
| *(new)* | `apple-dev`, `apple-ios`, `apple-ios-sim` | New: Apple platform scaffolding. |
|
||||
|
||||
If you see `m1-dev` or `m2-dev` in old scripts, commits, or `PROGRESS.md` history entries,
|
||||
use `dev` instead. Historical `PROGRESS.md` entries are left intact as a true record of what
|
||||
was run.
|
||||
|
||||
## 2. One-time setup for the real-deps presets
|
||||
|
||||
vcpkg is bundled as a git submodule at [`vcpkg/`](../vcpkg), pinned to the exact commit in
|
||||
[`vcpkg.json`](../vcpkg.json)'s `builtin-baseline` — so the bundled checkout and the manifest's
|
||||
resolved port versions can never drift apart. All presets except `vcpkg-common` need it
|
||||
bootstrapped:
|
||||
Equivalent direct native build:
|
||||
|
||||
```bash
|
||||
# once, after cloning:
|
||||
git submodule update --init vcpkg
|
||||
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||
cmake -S native/media -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build dotnet/artifacts/native-build --target voicecat_media --parallel 2
|
||||
cmake --install dotnet/artifacts/native-build --component DotnetMedia \
|
||||
--prefix dotnet/artifacts/native
|
||||
```
|
||||
|
||||
`cmake/voicecat-toolchain.cmake` resolves the vcpkg root itself — no environment variable
|
||||
needed. If you'd rather use an external vcpkg checkout (e.g. one shared across several
|
||||
projects), set `VCPKG_ROOT` and it takes priority over the bundled submodule:
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
# PowerShell:
|
||||
$env:VCPKG_ROOT = "D:\path\to\vcpkg"
|
||||
|
||||
# Linux/macOS:
|
||||
export VCPKG_ROOT=/path/to/vcpkg
|
||||
dotnet run --project dotnet/src/VoiceCat.Server -- --data-dir ./voicecat-data
|
||||
dotnet run --project dotnet/src/VoiceCat.Cli -- \
|
||||
--host 127.0.0.1 --port 8384 --nickname Alice --trust-first
|
||||
```
|
||||
|
||||
An external checkout must still be bootstrapped, and should be at (or compatible with)
|
||||
`vcpkg.json`'s `builtin-baseline` commit to resolve the same port versions.
|
||||
Both commands support `--help`. The CLI also supports deterministic two-process text and tone
|
||||
checks used by the managed test suite.
|
||||
|
||||
`vcpkg.json` (manifest mode) pins every dependency (protobuf, mbedTLS, libsodium, asio,
|
||||
sqlite3, spdlog, opus, miniaudio) — `cmake --preset dev` resolves and builds them
|
||||
automatically on first configure. That first configure is slow (vcpkg building from source);
|
||||
subsequent ones are cached.
|
||||
|
||||
## 3. Build + test (the loop you'll run constantly)
|
||||
|
||||
```bash
|
||||
cmake --preset dev
|
||||
cmake --build --preset dev
|
||||
ctest --preset dev
|
||||
```
|
||||
|
||||
Binaries land in `build/dev/bin/` (`.exe` suffix on Windows):
|
||||
|
||||
- `build/dev/bin/voicecat-server`
|
||||
- `build/dev/bin/vccli`
|
||||
- `build/dev/bin/voicecat-admin`
|
||||
|
||||
To run the same suite against optimized code:
|
||||
|
||||
```bash
|
||||
cmake --preset release
|
||||
cmake --build --preset release
|
||||
ctest --preset release
|
||||
```
|
||||
|
||||
## 4. Manual testing: server + two clients
|
||||
|
||||
### Start the server
|
||||
|
||||
```bash
|
||||
./build/dev/bin/voicecat-server --name "Test Server" --data-dir ./voicecat-data
|
||||
```
|
||||
|
||||
First run generates an Ed25519 identity + self-signed cert under `--data-dir`, creates the
|
||||
SQLite store, and creates a default "Lobby" channel. Other server flags:
|
||||
|
||||
```
|
||||
--port <n> control+media port (default 8384)
|
||||
--data-dir <path> data directory (default ./voicecat-data)
|
||||
--name <name> server name
|
||||
--no-guests disable guest access (then provision accounts via voicecat-admin)
|
||||
--print-config print effective config and exit
|
||||
--version print version and exit
|
||||
```
|
||||
|
||||
### Drive it with `vccli`
|
||||
|
||||
`vccli` is the headless client used to exercise the protocol by hand. Full flag list:
|
||||
|
||||
```
|
||||
vccli [--host H] [--port P] [--nick NAME] [--channel ID]
|
||||
[--voice] [--mute] [--text MSG] [--list-devices]
|
||||
[--input-device ID] [--input-mode vad|ptt] [--share-screen-audio]
|
||||
|
||||
--host H server host (default 127.0.0.1)
|
||||
--port P server TCP port (default 8384)
|
||||
--nick NAME guest nickname (default vccli-test)
|
||||
--channel ID channel to join after auth (default 1, Lobby)
|
||||
--voice start a MIC stream and stay connected until Ctrl+C
|
||||
--mute start with the mic muted (only meaningful with --voice)
|
||||
--text MSG send MSG to the channel, then exit
|
||||
--list-devices print input/output devices (vc_list_devices) and exit
|
||||
--input-device ID use device ID (from --list-devices) for the MIC stream
|
||||
--input-mode vad|ptt send-side input gate mode (default vad)
|
||||
--share-screen-audio also start a SCREEN_AUDIO stream (WASAPI loopback on Windows)
|
||||
```
|
||||
|
||||
While `--voice` is running, stdin accepts `ptt on`, `ptt off`, `mode vad`, `mode ptt` to
|
||||
toggle the input gate live.
|
||||
|
||||
**Smoke test — two clients talking:**
|
||||
|
||||
```bash
|
||||
# terminal A
|
||||
./build/dev/bin/vccli --nick Alice --text "hello from Alice"
|
||||
|
||||
# terminal B (separate window, after A confirms it sent)
|
||||
./build/dev/bin/vccli --nick Bob --text "hello from Bob"
|
||||
```
|
||||
|
||||
**Real voice between two clients (needs working mic/speakers, two terminals):**
|
||||
|
||||
```bash
|
||||
# terminal A
|
||||
./build/dev/bin/vccli --nick Alice --voice
|
||||
|
||||
# terminal B
|
||||
./build/dev/bin/vccli --nick Bob --voice
|
||||
```
|
||||
|
||||
Speak into the mic on one side; you should hear it on the other. Ctrl+C to disconnect.
|
||||
|
||||
**Enumerate audio devices before picking one:**
|
||||
|
||||
```bash
|
||||
./build/dev/bin/vccli --list-devices
|
||||
./build/dev/bin/vccli --nick Alice --voice --input-device <ID> --input-mode ptt
|
||||
```
|
||||
|
||||
**Provisioning a non-guest account** (if the server was started with `--no-guests`):
|
||||
|
||||
```bash
|
||||
./build/dev/bin/voicecat-admin --data-dir ./voicecat-data account add alice --password secret
|
||||
./build/dev/bin/voicecat-admin --data-dir ./voicecat-data account list
|
||||
```
|
||||
|
||||
## 5. `server-release` (production-shaped build)
|
||||
|
||||
Same dependency story, but `Release` build type, stripped binaries, and no tests — this is the
|
||||
closest local analogue to what [docs/deployment.md](deployment.md)'s "from source" path
|
||||
produces:
|
||||
|
||||
```bash
|
||||
cmake --preset server-release
|
||||
cmake --build --preset server-release
|
||||
./build/server-release/bin/voicecat-server
|
||||
```
|
||||
|
||||
Use this to sanity-check release-mode behavior (e.g. perf, optimized codepaths) — not for
|
||||
day-to-day development, since it has no test target wired up. The `-s` linker flag strips
|
||||
symbol tables from the binaries, producing smaller executables suitable for distribution.
|
||||
|
||||
## 6. Apple platform builds
|
||||
|
||||
The `apple-dev`, `apple-ios`, and `apple-ios-sim` presets produce static `libvoicecat.a`
|
||||
slices for the Swift Package / XCFramework. All three are validated on macOS 26.5 / Apple
|
||||
Silicon. Build on macOS:
|
||||
|
||||
```bash
|
||||
# macOS slice (arm64-osx on Apple Silicon, x64-osx on Intel)
|
||||
cmake --preset apple-dev
|
||||
cmake --build --preset apple-dev
|
||||
# → build/apple-dev/lib/libvoicecat.a
|
||||
|
||||
# iOS device slice (arm64-ios)
|
||||
cmake --preset apple-ios
|
||||
cmake --build --preset apple-ios
|
||||
# → build/apple-ios/lib/libvoicecat.a
|
||||
|
||||
# iOS simulator slice (arm64-ios-simulator)
|
||||
cmake --preset apple-ios-sim
|
||||
cmake --build --preset apple-ios-sim
|
||||
# → build/apple-ios-sim/lib/libvoicecat.a
|
||||
```
|
||||
|
||||
You rarely need to run these CMake commands directly. The
|
||||
`clients/apple/scripts/build-xcframework.sh` script drives them internally and stitches
|
||||
the result into a self-contained `VoiceCatCore.xcframework`:
|
||||
|
||||
```bash
|
||||
# macOS slice only (default — used by macOS AppKit client)
|
||||
clients/apple/scripts/build-xcframework.sh
|
||||
|
||||
# All 3 slices (macOS + iOS device + iOS sim — required for iOS client)
|
||||
clients/apple/scripts/build-xcframework.sh --all
|
||||
```
|
||||
|
||||
See [clients/apple/README.md](../clients/apple/README.md) for the XCFramework internals
|
||||
(fat static lib merge, module map staging).
|
||||
|
||||
## 7. Windows client (C# / WinForms)
|
||||
|
||||
The Windows client is a .NET 10 WinForms app that loads `voicecat.dll` (the MinGW-built
|
||||
shared library from the `windows-client` preset) via P/Invoke. Full details in
|
||||
[`clients/windows/README.md`](../clients/windows/README.md).
|
||||
|
||||
**Prerequisites:** .NET SDK 10, MinGW-w64 / MSYS2 UCRT64 (GCC 13+), vcpkg.
|
||||
|
||||
### Build the DLL
|
||||
## Windows client
|
||||
|
||||
```powershell
|
||||
cmake --preset windows-client
|
||||
cmake --build --preset windows-client
|
||||
# → build/windows-client/bin/voicecat.dll
|
||||
./dotnet/build-native.ps1
|
||||
dotnet restore clients/windows/VoiceCat.slnx --locked-mode
|
||||
dotnet build clients/windows/VoiceCat.slnx -c Release --no-restore
|
||||
./clients/windows/publish-client.ps1
|
||||
```
|
||||
|
||||
### Build the C# solution
|
||||
The supported app references `VoiceCat.Managed` and the managed core. Published output must
|
||||
contain `voicecat_media.dll` and must not contain the retired `voicecat.dll`.
|
||||
|
||||
```powershell
|
||||
cd clients/windows
|
||||
dotnet build VoiceCat.slnx
|
||||
```
|
||||
## Apple clients
|
||||
|
||||
`Directory.Build.props` copies `voicecat.dll` into the output directory automatically.
|
||||
|
||||
### Run the app
|
||||
|
||||
```powershell
|
||||
# Terminal 1 — start the server (built with the dev preset)
|
||||
./build/dev/bin/voicecat-server.exe --name "My Server"
|
||||
|
||||
# Terminal 2 — launch the client
|
||||
dotnet run --project clients/windows/VoiceCat.App/VoiceCat.App.csproj
|
||||
```
|
||||
|
||||
### Run the C# interop tests
|
||||
|
||||
```powershell
|
||||
dotnet test clients/windows/VoiceCat.slnx
|
||||
```
|
||||
|
||||
## 8. macOS client (AppKit)
|
||||
|
||||
The replacement client is a C# AppKit application that consumes the managed core and the
|
||||
small Opus/RNNoise media shim. The Swift/Xcode app remains the migration oracle until the
|
||||
managed app passes its VoiceOver, live-call and notarization gates. Managed-client details are
|
||||
in [`clients/apple/dotnet/README.md`](../clients/apple/dotnet/README.md); Swift-oracle details
|
||||
remain in [`clients/apple/README.md`](../clients/apple/README.md).
|
||||
|
||||
**Managed prerequisites:** Xcode 27, .NET SDK/workload set 10.0.401, Homebrew `protobuf`, and
|
||||
macOS 14+ (the current bindings target `net10.0-macos27.0`).
|
||||
|
||||
### Build and validate the managed app
|
||||
|
||||
```bash
|
||||
cmake -S dotnet/native -B dotnet/artifacts/native-build \
|
||||
-DCMAKE_BUILD_TYPE=Release -DVOICECAT_DOTNET_RID=osx-arm64
|
||||
cmake --build dotnet/artifacts/native-build --config Release \
|
||||
--target voicecat_media --parallel 2
|
||||
cmake --install dotnet/artifacts/native-build --config Release \
|
||||
--component DotnetMedia --prefix dotnet/artifacts/native
|
||||
dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx
|
||||
dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug
|
||||
open clients/apple/dotnet/VoiceCat.Mac/bin/Debug/net10.0-macos27.0/osx-arm64/VoiceCat.app
|
||||
```
|
||||
|
||||
Produce a Release bundle and verify its complete ad-hoc signature graph with:
|
||||
|
||||
```bash
|
||||
zsh clients/apple/dotnet/publish-macos.sh --dry-run
|
||||
```
|
||||
|
||||
For distribution, set `VOICECAT_CODESIGN_IDENTITY` to a Developer ID Application identity.
|
||||
Setting `APPLE_ID`, `APPLE_TEAM_ID`, and `APPLE_APP_PASSWORD` also submits the archive for
|
||||
notarization, staples it, and runs Gatekeeper assessment.
|
||||
|
||||
### Build the Swift migration oracle
|
||||
|
||||
**Prerequisites:** Xcode, vcpkg (`VCPKG_ROOT` set), macOS 14+ (deployment target).
|
||||
|
||||
### Build the XCFramework
|
||||
|
||||
The XCFramework is a local build artifact (gitignored, like the Windows DLL). It bundles
|
||||
`libvoicecat.a` + all vcpkg static deps into a single fat `.a` per slice, plus staged
|
||||
headers with a module map so Swift gets `import VoiceCatC`.
|
||||
|
||||
```bash
|
||||
# macOS slice only (default, validated)
|
||||
clients/apple/scripts/build-xcframework.sh
|
||||
# → clients/apple/VoiceCatCore.xcframework/
|
||||
|
||||
# All 3 slices (macOS + iOS device + iOS sim — iOS still scaffolding)
|
||||
clients/apple/scripts/build-xcframework.sh --all
|
||||
```
|
||||
|
||||
The script runs `cmake --preset apple-dev` + `cmake --build --preset apple-dev` internally,
|
||||
then merges vcpkg's static deps with `libtool -static` and stitches the XCFramework with
|
||||
`xcodebuild -create-xcframework`.
|
||||
|
||||
### Build the Swift core (SPM)
|
||||
|
||||
```bash
|
||||
cd clients/apple
|
||||
swift build # builds VoiceCatCore library
|
||||
swift test # 6 smoke tests against a real voicecat-server
|
||||
```
|
||||
|
||||
`swift test` requires the `dev` CMake preset to be built
|
||||
(`build/dev/bin/voicecat-server` + `voicecat-admin`).
|
||||
|
||||
### Build the macOS app (Xcode)
|
||||
|
||||
```bash
|
||||
xcodebuild -project clients/apple/macOS/VoiceCatMac.xcodeproj \
|
||||
-scheme VoiceCatMac -configuration Debug build
|
||||
# → ~/Library/Developer/Xcode/DerivedData/VoiceCatMac-*/Build/Products/Debug/VoiceCatMac.app
|
||||
```
|
||||
|
||||
Or open the project in Xcode and build from the UI:
|
||||
|
||||
```bash
|
||||
open clients/apple/macOS/VoiceCatMac.xcodeproj
|
||||
```
|
||||
|
||||
### Run the app
|
||||
|
||||
```bash
|
||||
# Terminal 1 — start the server (built with the dev preset)
|
||||
./build/dev/bin/voicecat-server --name "My Server"
|
||||
|
||||
# Terminal 2 — launch the client
|
||||
open ~/Library/Developer/Xcode/DerivedData/VoiceCatMac-*/Build/Products/Debug/VoiceCatMac.app
|
||||
```
|
||||
|
||||
Or use the per-artifact script which builds and stages to `dist/macos-client/`:
|
||||
|
||||
```bash
|
||||
scripts/build-macos-client.sh
|
||||
```
|
||||
|
||||
## 9. iOS client (UIKit)
|
||||
|
||||
The replacement iOS client is a native C# UIKit app at
|
||||
`clients/apple/dotnet/VoiceCat.iOS`. Build its static media shim and simulator bundle with:
|
||||
On Apple Silicon with the SDK/workload versions documented in
|
||||
`clients/apple/dotnet/README.md`:
|
||||
|
||||
```bash
|
||||
./dotnet/build-native.ps1
|
||||
./dotnet/build-native-ios.sh
|
||||
dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx
|
||||
dotnet build clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj \
|
||||
-c Debug -r iossimulator-arm64 --no-restore
|
||||
dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug --no-restore
|
||||
```
|
||||
|
||||
The build invokes Xcode for the retained Swift ReplayKit extension and embeds the resulting
|
||||
appex. The older SwiftUI application below remains the migration oracle during parity and
|
||||
physical-device accessibility testing.
|
||||
The iOS build invokes the standalone project in `native/apple/broadcast` and embeds its appex.
|
||||
Use `clients/apple/dotnet/build-ios-device.sh` and `deploy-ios-device.sh` for signed device
|
||||
builds. Use `clients/apple/dotnet/publish-macos.sh --dry-run` for an ad-hoc validated macOS
|
||||
bundle; its environment variables enable Developer ID signing and notarization.
|
||||
|
||||
The iOS client is an Xcode project (`clients/apple/iOS/VoiceCatiOS.xcodeproj`) that
|
||||
links `libvoicecat` via the same `VoiceCatCore` Swift Package as the macOS client.
|
||||
Full details in [`clients/apple/README.md`](../clients/apple/README.md).
|
||||
## Server publishing
|
||||
|
||||
**Prerequisites:** Xcode, vcpkg (`VCPKG_ROOT` set), iOS Simulator runtime installed
|
||||
(Xcode > Settings > Platforms > iOS). iOS deployment target: 18.0.
|
||||
`dotnet/publish-server.ps1` produces locked self-contained Windows and Linux artifacts.
|
||||
`Dockerfile`, Compose configuration, and systemd packaging use the managed server. Validate a
|
||||
published binary with its TLS `--health-check`, preferably including `--expect-fingerprint`.
|
||||
|
||||
### Build the XCFramework (all slices)
|
||||
## CI and release expectations
|
||||
|
||||
The iOS build requires the `ios-arm64-simulator` slice in the XCFramework — not just the
|
||||
macOS slice. Use the `--all` flag to produce all three slices:
|
||||
|
||||
```bash
|
||||
clients/apple/scripts/build-xcframework.sh --all
|
||||
# → clients/apple/VoiceCatCore.xcframework/
|
||||
# ├── macos-arm64/
|
||||
# ├── ios-arm64/
|
||||
# └── ios-arm64-simulator/
|
||||
```
|
||||
|
||||
### Build the iOS simulator app
|
||||
|
||||
```bash
|
||||
# Via the convenience script (recommended):
|
||||
scripts/build-ios-client.sh
|
||||
# → clients/apple/iOS/build/Debug-iphonesimulator/VoiceCatiOS.app
|
||||
# → dist/ios-client/VoiceCatiOS.app
|
||||
|
||||
# Or as a single command (what the script does under the hood):
|
||||
SIM_SDK="iphonesimulator$(xcrun --sdk iphonesimulator --show-sdk-version)"
|
||||
BUILD_DIR="$(pwd)/clients/apple/iOS/build"
|
||||
xcodebuild \
|
||||
-project clients/apple/iOS/VoiceCatiOS.xcodeproj \
|
||||
-target VoiceCatiOS \
|
||||
-sdk "$SIM_SDK" \
|
||||
-configuration Debug \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
ARCHS=arm64 \
|
||||
ONLY_ACTIVE_ARCH=YES \
|
||||
SYMROOT="$BUILD_DIR" \
|
||||
OBJROOT="$BUILD_DIR" \
|
||||
build
|
||||
```
|
||||
|
||||
**Why `-target` instead of `-scheme -destination`?** Using `-scheme VoiceCatiOS
|
||||
-destination 'platform=iOS Simulator,OS=latest'` requires a simulator runtime whose iOS
|
||||
version exactly matches the SDK version (`iphonesimulatorX.Y`). If you have an older
|
||||
runtime installed (common when the SDK ships ahead of runtime availability in Xcode), the
|
||||
build fails with "Unable to find a destination matching the provided destination specifier."
|
||||
Using `-target` bypasses destination matching and builds against the SDK directly.
|
||||
|
||||
**Why `SYMROOT=OBJROOT=clients/apple/iOS/build`?** When building with `-target` (not
|
||||
`-scheme`), the local Swift Package (`VoiceCatCore`) resolves its build products relative
|
||||
to `OBJROOT`. By default, SPM resolves into `clients/apple/build/`, while the app target
|
||||
looks in `clients/apple/iOS/build/`. Pointing both to the same directory fixes the
|
||||
"unable to resolve module dependency: 'VoiceCatCore'" error.
|
||||
|
||||
### Run on the iOS Simulator
|
||||
|
||||
```bash
|
||||
# Via the script (finds or boots an iPhone simulator, installs, launches):
|
||||
scripts/run-ios-simulator.sh
|
||||
|
||||
# Build and run in one step:
|
||||
scripts/run-ios-simulator.sh --build
|
||||
|
||||
# Stream app logs after launch:
|
||||
scripts/run-ios-simulator.sh --log
|
||||
|
||||
# Target a specific device by name or UDID:
|
||||
scripts/run-ios-simulator.sh --device "iPhone 16 Pro"
|
||||
scripts/run-ios-simulator.sh --udid XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
|
||||
```
|
||||
|
||||
Under the hood the script uses `xcrun simctl` commands:
|
||||
|
||||
```bash
|
||||
# Boot a simulator (if not already running):
|
||||
xcrun simctl boot <UDID>
|
||||
open -a Simulator
|
||||
xcrun simctl bootstatus <UDID> -b # wait until boot is complete before installing
|
||||
|
||||
# Install the built .app:
|
||||
xcrun simctl install <UDID> clients/apple/iOS/build/Debug-iphonesimulator/VoiceCatiOS.app
|
||||
|
||||
# Launch the app:
|
||||
xcrun simctl launch <UDID> cat.voice.VoiceCatiOS
|
||||
|
||||
# Stream logs (Ctrl+C to stop — does not kill the app):
|
||||
xcrun simctl spawn <UDID> log stream --predicate 'subsystem contains "VoiceCat"'
|
||||
```
|
||||
|
||||
### Run the full stack (server + iOS simulator)
|
||||
|
||||
```bash
|
||||
# Terminal 1 — start the server
|
||||
./build/dev/bin/voicecat-server --name "My Server"
|
||||
|
||||
# Terminal 2 — build + launch iOS client on simulator
|
||||
scripts/run-ios-simulator.sh --build
|
||||
```
|
||||
|
||||
On first connect the app will show a TOFU identity sheet — accept it, then join a channel.
|
||||
|
||||
### Open in Xcode
|
||||
|
||||
```bash
|
||||
open clients/apple/iOS/VoiceCatiOS.xcodeproj
|
||||
```
|
||||
|
||||
Xcode can build and run on the simulator directly. The XCFramework must already exist
|
||||
(`clients/apple/VoiceCatCore.xcframework/`) — run `build-xcframework.sh --all` once
|
||||
before opening Xcode.
|
||||
CI builds the native shim, managed solution, tests, licenses, and managed Apple clients. The
|
||||
old C++ implementation is not a conformance target. Release validation additionally includes
|
||||
real devices, screen readers, sustained calls, signing/notarization, container execution, and
|
||||
a server soak; see `roadmap.md`.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Deploying the managed iOS app
|
||||
|
||||
VoiceCat's supported iOS client is the .NET 10 UIKit application in
|
||||
`clients/apple/dotnet/VoiceCat.iOS`. The checked-in scripts build its native Opus/RNNoise
|
||||
dependency, compile and sign the managed app and Swift ReplayKit extension, stage the bundle,
|
||||
install it, and launch it on a paired physical device.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS 27, Xcode 27, .NET SDK 10.0.401, and the matching iOS workload.
|
||||
- An unlocked, trusted iPhone connected by USB or visible to Xcode over the network.
|
||||
- Apple Development profiles for `me.iamtalon.voicecat` and
|
||||
`me.iamtalon.voicecat.broadcast`, both with App Group `group.me.iamtalon.voicecat`.
|
||||
|
||||
List available devices:
|
||||
|
||||
```bash
|
||||
clients/apple/dotnet/deploy-ios-device.sh --list
|
||||
```
|
||||
|
||||
## Build, install, and launch
|
||||
|
||||
Set the Apple developer team explicitly. The .NET host and Xcode-built extension use separate
|
||||
build systems, and Xcode needs the team to select the extension profile.
|
||||
|
||||
```bash
|
||||
export VOICECAT_DEVELOPMENT_TEAM=FJV8L966W4
|
||||
clients/apple/dotnet/build-ios-device.sh --configuration Debug
|
||||
clients/apple/dotnet/deploy-ios-device.sh \
|
||||
--device "Talon’s iPhone" \
|
||||
--configuration Debug \
|
||||
--no-build
|
||||
```
|
||||
|
||||
The verified bundle is staged at `dist/ios-managed-device/VoiceCat.iOS.app`. Omit `--no-build`
|
||||
to build and deploy in one command. Add `--console` to attach the launch to device logs.
|
||||
|
||||
Confirm that the process stayed alive:
|
||||
|
||||
```bash
|
||||
xcrun devicectl device info processes --device "Talon’s iPhone" | rg VoiceCat
|
||||
```
|
||||
|
||||
## Signing findings
|
||||
|
||||
The development team ID is `FJV8L966W4`. Do not infer it from the parenthesized suffix in an
|
||||
Apple Development certificate name; that value can identify the certificate holder and need
|
||||
not equal a profile's `TeamIdentifier`.
|
||||
|
||||
The extension wrapper uses automatic signing with installed profiles. If a profile is absent
|
||||
and Xcode has a valid signed-in developer account, permit Xcode to create or download it:
|
||||
|
||||
```bash
|
||||
export VOICECAT_ALLOW_PROVISIONING_UPDATES=1
|
||||
```
|
||||
|
||||
Leave that variable unset when valid profiles are already installed. A stale command-line Xcode
|
||||
account can otherwise make provisioning updates fail even though offline signing can succeed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Stale CMake source path
|
||||
|
||||
After moving the native shim from `dotnet/native` to `native/media`, an old CMake cache can
|
||||
report a source-directory mismatch. Move the generated directories aside and rebuild:
|
||||
|
||||
```bash
|
||||
mv dotnet/artifacts/native-build-ios-arm64-cmake /tmp/
|
||||
mv dotnet/artifacts/native-build-iossimulator-arm64-cmake /tmp/
|
||||
```
|
||||
|
||||
### Locked restore reports changed runtime identifiers
|
||||
|
||||
The managed libraries' lock files must include `ios-arm64` and `iossimulator-arm64`. Regenerate
|
||||
them after runtime changes, then verify that dependency versions did not change:
|
||||
|
||||
```bash
|
||||
/usr/local/share/dotnet/dotnet restore \
|
||||
clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj \
|
||||
-p:VoiceCatIosStatic=true \
|
||||
--force-evaluate
|
||||
```
|
||||
|
||||
### Developer disk image cannot be mounted
|
||||
|
||||
CoreDevice errors 10003 or 12040 mean the phone locked. Unlock it, keep the display awake, and
|
||||
rerun deployment with `--no-build`.
|
||||
|
||||
## Verified hardware result
|
||||
|
||||
On 2026-09-19, the Debug build completed with .NET 10.0.401 and Xcode 27.0. The host and
|
||||
ReplayKit extension were signed under team `FJV8L966W4`, installed wirelessly on Talon's iPhone,
|
||||
launched as `me.iamtalon.voicecat`, and remained in the device process list. Audio,
|
||||
background/lock behavior, Bluetooth, ReplayKit, ScreenCaptureKit, and VoiceOver remain separate
|
||||
manual hardware gates.
|
||||
@@ -1,965 +0,0 @@
|
||||
# Porting VoiceCat to pure .NET / C#
|
||||
|
||||
**Status:** the managed protocol, crypto, server, CLI, audio/client core, Windows client and
|
||||
macOS functional surface are implemented under `dotnet/`, with C++ interoperability retained
|
||||
as a migration oracle. The managed macOS client is awaiting its manual VoiceOver, live-call and
|
||||
credentialed notarization gates before it replaces the Swift release client. The iOS rewrite
|
||||
remains planned.
|
||||
See `dotnet/README.md`, `docs/api-dotnet.md`, and `PROGRESS.md` for verification and next steps.
|
||||
**Target runtime:** .NET 10 LTS (in-service to Nov 2028), with .NET 11 as the follow-on.
|
||||
**Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the
|
||||
Swift macOS/iOS clients with a single C# codebase. The Windows WinForms client is already C#
|
||||
and is mostly *kept*.
|
||||
|
||||
This document is the map for that work: what maps 1:1, what has no .NET equivalent, what has
|
||||
to stay native, and the order to do it in so the tree is testable at every step.
|
||||
|
||||
---
|
||||
|
||||
## 0. Executive summary
|
||||
|
||||
**The port is feasible.** Roughly 80 % of the C++ core is protocol/state-machine/buffer code
|
||||
that translates to C# almost mechanically and gets *shorter*. The risk is concentrated in
|
||||
four places, and only one of them is a genuine design change:
|
||||
|
||||
| Risk | Verdict |
|
||||
|------|---------|
|
||||
| **TLS keying-material exporter (RFC 5705)** — the media-key derivation the whole UDP path depends on | ⚠️ **`SslStream` cannot do this.** [The API is an unapproved proposal](https://github.com/dotnet/runtime/issues/112529) targeting "Future", and SChannel structurally can't export secrets. **Must** use BouncyCastle's managed TLS stack, or change the protocol. See §3. |
|
||||
| **Real-time audio + GC** | Manageable, but needs deliberate design. Native audio callbacks must not enter managed code. See §5. |
|
||||
| **Opus 1.6 / DRED, RNNoise** | No managed equivalent exists. Stay native via P/Invoke. See §4. |
|
||||
| **iOS ReplayKit Broadcast Upload Extension** | ⚠️ **Keep this in Swift.** 50 MB jetsam cap + a managed runtime in an appex that .NET for iOS does not officially support. See §8.4. |
|
||||
|
||||
Everything else — protobuf, SQLite, sockets, Argon2id, ChaCha20-Poly1305, X.509 generation,
|
||||
jitter buffers, the mixer, the session model, the server — is either built into .NET or
|
||||
covered by a permissive, well-maintained NuGet package.
|
||||
|
||||
**Net effect on native dependencies:** from 8 vcpkg deps + 1 vendored, down to **3 native
|
||||
libraries** (libopus, RNNoise, miniaudio) — all three tiny, all three already vendored or
|
||||
trivially buildable, and all three optional to *replace* later.
|
||||
|
||||
---
|
||||
|
||||
## 1. What exists today (baseline inventory)
|
||||
|
||||
Sizes are source bytes, to calibrate effort.
|
||||
|
||||
### Core — `core/` (~310 KB C++)
|
||||
|
||||
| File | Bytes | What it is | Port difficulty |
|
||||
|------|-------|-----------|-----------------|
|
||||
| `core/src/core/client.cpp` + `.h` | 107 K | `vc_client` — the entire client state machine: connect/auth/TOFU, channel + user model, stream lifecycle, reframing, encode path, event queue | **Medium**, but large. Mostly mechanical. |
|
||||
| `core/src/audio/audio_engine.cpp` + `.h` | 69 K | miniaudio devices, `JitterBuffer`, per-ssrc decode, DRED/FEC/PLC ladder, mixer, level meters, talk detection, external feed/tap/mixed-sink | **Hard** — the RT-sensitive part |
|
||||
| `core/src/net/transport.cpp` + `.h` | 25 K | Asio TCP framing `[u32 len][payload]`, UDP socket | **Easy** — `Socket`/`System.IO.Pipelines` is nicer |
|
||||
| `core/src/crypto/` | 27 K | mbedTLS TLS 1.3 wrapper, exporter, self-signed cert gen, Ed25519 identity, ChaCha20-Poly1305 + anti-replay, TOFU pin store | **Hard** — see §3 |
|
||||
| `core/src/codec/opus_codec.*` | 9 K | libopus encode/decode wrapper incl. DRED | **Easy** — P/Invoke shim |
|
||||
| `core/src/protocol/`, `core/src/session/` | 12 K | Envelope (de)serialize, dispatch, channel/user/stream registry | **Easy** |
|
||||
| `core/src/audio/apm_processor.*` | 5 K | RNNoise wrapper + energy VAD | **Easy** |
|
||||
| `core/src/voicecat.cpp` | 13 K | C ABI façade over `vc_client` | **Deleted** — no ABI needed any more |
|
||||
| `core/include/voicecat.h` | 27 K | The C ABI | **Becomes a C# interface**, not an ABI |
|
||||
| `core/proto/voicecat.proto` | 9 K | Wire format, source of truth | **Unchanged** |
|
||||
|
||||
### Server — `server/` (~80 KB C++)
|
||||
|
||||
`conn_session.cpp` (34 K, per-connection protocol handling), `db.cpp` (26 K, SQLite:
|
||||
accounts, channels, bans), `session_registry.cpp` (20 K), `media_relay.cpp` (8 K, the SFU
|
||||
relay), `server.cpp`, `identity.cpp`. All **easy-to-medium** — this is ordinary async network
|
||||
server code and translates very well.
|
||||
|
||||
### Clients
|
||||
|
||||
| Client | Today | After the port |
|
||||
|--------|-------|----------------|
|
||||
| Windows | C#, WinForms, `net10.0-windows`, ~40 files | **Kept.** Swap `VoiceCat.Interop` P/Invoke for a direct project reference. |
|
||||
| macOS | Swift + AppKit, `MainWindowController.swift` alone is 70 K | Rewrite as C# AppKit on `net10.0-macos` — near-mechanical, AppKit maps 1:1 |
|
||||
| iOS | Swift + SwiftUI, ~150 K across views/audio | Rewrite as C# UIKit on `net10.0-ios` (or MAUI — see §8.3). **No 1:1 SwiftUI equivalent.** |
|
||||
| iOS broadcast appex | Swift, 12 K (`SampleHandler.swift` + `BroadcastAudioRing.swift`) | **Stays Swift.** See §8.4 |
|
||||
| `tools/vccli` | C++, 32 K | Rewrite as a C# console app — this becomes the primary conformance harness |
|
||||
|
||||
### Tests — `tests/` (29 files, ~290 KB, all green)
|
||||
|
||||
These are the real specification. Every one of them must be ported to xUnit and stay green;
|
||||
the milestone exit criteria in `docs/roadmap.md` are encoded here.
|
||||
|
||||
---
|
||||
|
||||
## 2. Target solution layout
|
||||
|
||||
```
|
||||
voice-cat/
|
||||
├── proto/voicecat.proto # unchanged, single source of truth
|
||||
├── native/ # the only C left
|
||||
│ ├── opus/ # libopus 1.6 build scripts
|
||||
│ ├── rnnoise/ # moved from third_party/
|
||||
│ ├── miniaudio/ # miniaudio.h + voicecat_audio_shim.c (§5.2)
|
||||
│ └── build-native.{ps1,sh} # produces per-RID binaries
|
||||
├── src/
|
||||
│ ├── VoiceCat.Protocol/ # Google.Protobuf codegen + Envelope framing
|
||||
│ ├── VoiceCat.Crypto/ # TLS, media AEAD, anti-replay, identity, TOFU store
|
||||
│ ├── VoiceCat.Codec/ # libopus P/Invoke + OpusEncoder/OpusDecoder
|
||||
│ ├── VoiceCat.Dsp/ # RNNoise P/Invoke, energy VAD, resample helpers
|
||||
│ ├── VoiceCat.Audio/ # devices, jitter buffer, mixer, RT ring buffers
|
||||
│ ├── VoiceCat.Core/ # VoiceCatClient — replaces vc_client + the C ABI
|
||||
│ ├── VoiceCat.Server/ # replaces server/
|
||||
│ ├── VoiceCat.Cli/ # replaces tools/vccli + voicecat-admin
|
||||
│ └── clients/
|
||||
│ ├── VoiceCat.Windows/ # net10.0-windows, WinForms (kept, retargeted)
|
||||
│ ├── VoiceCat.Mac/ # net10.0-macos, AppKit
|
||||
│ ├── VoiceCat.iOS/ # net10.0-ios, UIKit
|
||||
│ └── VoiceCatBroadcast/ # Swift appex — the one non-C# artifact
|
||||
└── tests/VoiceCat.Tests/ # xUnit, ports all 29 ctest cases
|
||||
```
|
||||
|
||||
**Target frameworks.** `VoiceCat.Core` and everything below it target plain `net10.0` — no
|
||||
platform TFM — so the same assembly loads into the server, the WinForms app, the macOS app,
|
||||
the iOS app, and the test host. Only the four leaf client projects carry a platform TFM.
|
||||
|
||||
**Why not one big assembly:** the layering is what keeps the "core owns audio, UI is thin"
|
||||
rule enforceable. It also lets the server reference `VoiceCat.Protocol` + `VoiceCat.Crypto`
|
||||
without dragging in miniaudio.
|
||||
|
||||
---
|
||||
|
||||
## 3. The TLS problem — read this before anything else
|
||||
|
||||
### 3.1 What breaks
|
||||
|
||||
`docs/security.md` §2 is built on one mbedTLS call:
|
||||
|
||||
```c
|
||||
mbedtls_ssl_export_keying_material("voicecat media v1", ...) → media keys
|
||||
```
|
||||
|
||||
**.NET has no equivalent.** `SslStream` exposes no RFC 5705 exporter. The API proposal
|
||||
([dotnet/runtime#112529](https://github.com/dotnet/runtime/issues/112529)) is labelled
|
||||
`api-suggestion` ("NOT ready for implementation"), milestone *Future*, and the platform notes
|
||||
on it are discouraging: *"Windows – needs verification"* (SChannel runs TLS in a separate
|
||||
privileged process and deliberately refuses to hand back secrets), *"OSX – Not implemented
|
||||
for Secure Transport"*. Do not plan around this shipping.
|
||||
|
||||
This is not a small dependency swap. The media key derivation is the root of the entire UDP
|
||||
path: AEAD keys, nonce discipline, anti-replay, and the UDP binding token all hang off it.
|
||||
|
||||
### 3.2 Option A (recommended) — BouncyCastle managed TLS
|
||||
|
||||
[BouncyCastle for .NET](https://www.nuget.org/packages/BouncyCastle.Cryptography) (MIT,
|
||||
actively maintained) ships a **complete managed TLS 1.3 client and server** in
|
||||
`Org.BouncyCastle.Tls`, and `TlsContext` exposes exactly the method we need:
|
||||
|
||||
```csharp
|
||||
byte[] ExportKeyingMaterial(string asciiLabel, byte[] contextValue, int length);
|
||||
// "Export keying material according to RFC 5705" — TLS 1.3 (RFC 8446 §7.5) aware
|
||||
```
|
||||
|
||||
*(verified against [bc-csharp `crypto/src/tls/TlsContext.cs`](https://github.com/bcgit/bc-csharp/blob/master/crypto/src/tls/TlsContext.cs); the C# port is feature-matched to bc-java here.)*
|
||||
|
||||
**Consequences — mostly good:**
|
||||
|
||||
- ✅ **Byte-identical wire compatibility with the existing C++ implementation.** This is the
|
||||
single biggest de-risking factor in the whole project: it means a .NET client can talk to
|
||||
the shipped C++ server (and vice versa) at *every* step of the port, and the C++ side
|
||||
becomes a conformance oracle. See §11.
|
||||
- ✅ **Identical TLS behaviour on all five platforms.** No SChannel-vs-OpenSSL-vs-
|
||||
SecureTransport variance, no per-OS cipher-suite policy surprises, no "TLS 1.3 on macOS
|
||||
only since .NET 10" caveat. For a self-hosted product shipping to unknown machines this is
|
||||
worth a lot on its own.
|
||||
- ✅ MIT, permissive — satisfies the hard no-GPL rule.
|
||||
- ✅ Also gives us **Ed25519** and **BLAKE2b** (see §4), which .NET lacks.
|
||||
|
||||
**Costs:**
|
||||
|
||||
- ⚠️ Pure-managed TLS is slower than SChannel/OpenSSL. **This does not matter here** — the
|
||||
TLS channel carries only control messages (a handshake plus a few KB/s of protobuf). Media
|
||||
is UDP + ChaCha20-Poly1305, which uses the fast built-in .NET AEAD, not BouncyCastle.
|
||||
- ⚠️ You implement `TlsClient` / `TlsServer` callback subclasses yourself (~200–300 lines for
|
||||
both sides): cipher-suite selection, certificate handling, `TlsCrypto` provider. Well-trodden
|
||||
— BC ships `DefaultTlsClient`/`DefaultTlsServer` bases and `BcTlsCrypto`.
|
||||
- ⚠️ You own the cert-validation logic (that's actually a *plus* for TOFU — see §3.4).
|
||||
- ⚠️ One more external dependency in the trust base. It's Bouncy Castle; acceptable.
|
||||
|
||||
### 3.3 Option B — `SslStream` + in-band media keys (protocol v3)
|
||||
|
||||
Abandon the exporter. Since the TLS 1.3 channel is already confidential, authenticated and
|
||||
forward-secret, the server can simply **generate the media keys and send them inside it**:
|
||||
|
||||
```proto
|
||||
message AuthResult {
|
||||
// ...
|
||||
bytes udp_token = 6;
|
||||
bytes media_key_c2s = 7; // 32 bytes, server-generated CSPRNG (NEW, v3)
|
||||
bytes media_key_s2c = 8; // 32 bytes (NEW, v3)
|
||||
}
|
||||
```
|
||||
|
||||
This is what Mumble does (its OCB2/AES key exchange happens inside its TLS control channel),
|
||||
and it is what SDES-SRTP-over-TLS does. Security posture is equivalent: an attacker who can
|
||||
read these can already read everything.
|
||||
|
||||
- ✅ Uses only built-in `System.Net.Security.SslStream` — zero TLS dependencies.
|
||||
- ✅ Simplest possible code; best per-platform TLS performance.
|
||||
- ❌ **Breaks wire compatibility** → no cross-testing against the C++ implementation during
|
||||
the port, which throws away the best safety net available.
|
||||
- ❌ Protocol version bump to 3, forced-update for the shipped iOS/macOS/Windows clients.
|
||||
- ❌ Loses the exporter's nice property that media keys are never *transmitted* at all.
|
||||
- ⚠️ Server-side `SslStream` on Windows has a real footgun: a cert created with
|
||||
`CertificateRequest.CreateSelfSigned` must be round-tripped through
|
||||
`X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null)` before SChannel
|
||||
will accept it — an ephemeral/CNG-only key produces a confusing handshake failure.
|
||||
|
||||
### 3.4 Recommendation
|
||||
|
||||
**Take Option A (BouncyCastle) for the port. Keep Option B as an optional later
|
||||
simplification** once the C++ tree is retired and you no longer need it as an oracle — at
|
||||
which point it's a contained protocol-v3 change, not a rewrite.
|
||||
|
||||
TOFU is *easier* under Option A: BouncyCastle hands you the peer's DER certificate chain
|
||||
directly in `TlsAuthentication.NotifyServerCertificate`, so
|
||||
`SHA256.HashData(leafDer)` — the exact value `docs/security.md` §1.1 says is pinned — falls
|
||||
out with no ceremony. (Under `SslStream` you'd get it from
|
||||
`RemoteCertificateValidationCallback` via `cert.GetRawCertData()`; also fine, just less
|
||||
direct.)
|
||||
|
||||
**Worth fixing while you're in here:** `security.md` §1.1 documents a known limitation — the
|
||||
Ed25519 identity key is not bound to the TLS cert, so it's display-only. When generating the
|
||||
self-signed cert in C#, **embed the Ed25519 public key as a X.509 extension or SAN URI**.
|
||||
`CertificateRequest.CertificateExtensions` makes this trivial, and it closes the gap the doc
|
||||
has been carrying.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dependency map — C++ → .NET
|
||||
|
||||
| Concern | Today | .NET replacement | License | Notes / pitfalls |
|
||||
|---------|-------|------------------|---------|------------------|
|
||||
| Sockets, timers | Asio | **`System.Net.Sockets`** + `System.IO.Pipelines` + `PeriodicTimer` | built-in | Strictly better. `Socket.ReceiveFromAsync(SocketAddress)` (net8+) is the allocation-free UDP receive path — use it, not `UdpClient`. |
|
||||
| Control framing | hand-rolled `[u32 len]` | `System.IO.Pipelines` `SequenceReader` | built-in | Removes a class of bugs. Keep the same 16 MiB frame cap. |
|
||||
| TLS 1.3 | mbedTLS | **BouncyCastle `Org.BouncyCastle.Tls`** | MIT | See §3. **Not `SslStream`.** |
|
||||
| Media AEAD | libsodium ChaCha20-Poly1305 | **`System.Security.Cryptography.ChaCha20Poly1305`** | built-in | ⚠️ Check `ChaCha20Poly1305.IsSupported` at startup — it is OS-backed (Windows 10 1903+ / OpenSSL 1.1+). Fall back to BouncyCastle's `ChaCha20Poly1305` if false. Same 12-byte nonce, 16-byte tag → identical wire bytes. |
|
||||
| Anti-replay window | hand-rolled 64-bit | port verbatim | — | ~40 lines. Keep the RFC 3711 §3.3 ordering (replay-check → authenticate → *then* advance). This ordering is load-bearing; `test_media_aead.cpp` covers it. |
|
||||
| Argon2id | libsodium `crypto_pwhash` | **BouncyCastle `Argon2BytesGenerator`** | MIT | Implemented with a strict libsodium PHC parser and original costs; native database tests prove existing-account import and managed-account verification by C++. See `docs/api-dotnet.md` for cost bounds. |
|
||||
| BLAKE2b (channel passwords) | libsodium `crypto_generichash` | **`Blake2Fast`** (MIT) or BouncyCastle `Blake2bDigest` | MIT | Salted BLAKE2b-256, must produce identical digests to keep existing channel passwords working. Blake2Fast is SIMD and fast enough for the net thread, preserving the reason BLAKE2b was chosen over Argon2 here. |
|
||||
| Ed25519 identity | libsodium | **BouncyCastle `Ed25519Signer`** | MIT | ⚠️ **Not in .NET 10.** [dotnet/runtime#63174](https://github.com/dotnet/runtime/issues/63174) is api-approved but milestoned **11.0.0**. Since Option A already pulls in BouncyCastle, this is free. |
|
||||
| Self-signed cert gen | mbedTLS x509write | **`CertificateRequest.CreateSelfSigned`** | built-in | Much nicer than the C++ version. ECDSA-P256, same as today. Add the Ed25519 SAN (§3.4). |
|
||||
| CSPRNG | libsodium | **`RandomNumberGenerator`** | built-in | — |
|
||||
| Opus codec | libopus 1.6 | **P/Invoke libopus 1.6** | BSD | **Keep native.** [Concentus](https://github.com/lostromb/concentus) is a pure-C# Opus port but it tracks **Opus 1.1** — it has no DRED, no 1.6 features. `docs/voice.md` §4 and `test_dred_toggle.cpp` depend on DRED. Concentus is a viable *fallback* for a future platform where native linking is impossible, not the primary. |
|
||||
| Noise suppression | RNNoise (vendored) | **P/Invoke RNNoise** | BSD-3 + CC0 | **Keep native.** No managed port exists. It's ~5 exported functions; the binding is trivial. Already vendored at `third_party/rnnoise/`. |
|
||||
| Audio capture/playback | miniaudio | **P/Invoke miniaudio via a shim** | MIT-0/PD | **Keep native**, see §5.2. Managed alternatives exist ([SoundFlow](https://www.nuget.org/packages/SoundFlow), [MiniaudioSharp](https://www.nuget.org/packages/MiniaudioSharp), NAudio/CSCore for Windows-only) but auto-generated bindings marshal the callback into managed code, which is exactly what you must avoid (§5.1). Write the shim yourself. |
|
||||
| Energy VAD | hand-rolled | port verbatim | — | ~60 lines. Trivial. |
|
||||
| Protobuf | protobuf-lite (C++) | **`Google.Protobuf`** + `Grpc.Tools` | BSD | ⚠️ Reference `Grpc.Tools` for the `protoc` MSBuild integration even though there is no gRPC here — it is the standard way to codegen `.proto` in a `.csproj`. `<Protobuf Include="../../proto/voicecat.proto" GrpcServices="None" />`. The `.proto` needs **zero changes**. |
|
||||
| SQLite | sqlite3 | **`Microsoft.Data.Sqlite.Core` + SQLitePCLRaw** | MIT / public domain | Implemented with provider 10.0.5, bundle 3.0.2 and pinned SQLite 3.50.4.2. Same schema/file; native database import is tested. NativeAOT publishing remains to be validated. |
|
||||
| Logging | spdlog | **`Microsoft.Extensions.Logging`** (+ Serilog console sink) | MIT/Apache | Use `LoggerMessage` source generators on any path near the hot loop. Never log from an audio path. |
|
||||
| Server config | `server.toml` | **`Tomlyn`** (MIT) or switch to JSON + `System.Text.Json` | MIT | Tomlyn keeps `server.toml` compatible; recommended, since operator-facing config shouldn't churn. |
|
||||
| CLI arg parsing | hand-rolled | **`System.CommandLine`** | MIT | For `VoiceCat.Cli` and the server. |
|
||||
| Build | CMake + vcpkg | **`dotnet build`** + a small native build script | — | vcpkg disappears entirely except for the 3 native libs, which you can vendor as sources and build with a 20-line CMakeLists or even `cl`/`gcc` directly. |
|
||||
|
||||
### 4.1 License check
|
||||
|
||||
Every replacement is MIT / BSD / Apache-2.0 / built-in. **The hard no-GPL/LGPL rule in
|
||||
`docs/tech-stack.md` §5 holds.** BouncyCastle is MIT. Konscious is MIT. Blake2Fast is MIT.
|
||||
Tomlyn is MIT. The .NET runtime itself is MIT.
|
||||
|
||||
---
|
||||
|
||||
## 5. Real-time audio — the hard part
|
||||
|
||||
This is where a naive port fails. `docs/architecture.md` §3 states the rule: *"Audio
|
||||
(real-time) threads must not allocate, lock, log, or do syscalls."* A managed runtime adds a
|
||||
second rule: **they must not be subject to GC pauses, and they must not be managed threads at
|
||||
all if avoidable.**
|
||||
|
||||
### 5.1 Why you cannot just P/Invoke miniaudio and use `[UnmanagedCallersOnly]`
|
||||
|
||||
miniaudio calls your `ma_device_data_proc` on an OS-owned real-time audio thread (WASAPI's
|
||||
MMCSS thread, a CoreAudio IOThread, an ALSA thread). If that callback is a managed method:
|
||||
|
||||
1. **The thread must attach to the runtime.** First entry does thread registration; every
|
||||
entry does a managed↔native transition.
|
||||
2. **The thread becomes GC-suspendable.** A gen-0 collection anywhere in the process can
|
||||
suspend it mid-callback. WASAPI in exclusive/low-latency mode will glitch on a 2 ms stall;
|
||||
a gen-2 blocking collection is fatal to the audio.
|
||||
3. **Delegate lifetime.** Even with `[UnmanagedCallersOnly]` (which correctly avoids the
|
||||
marshalling stub and the `GCHandle` dance) the *reachability* problem is solved but the
|
||||
suspension problem is not.
|
||||
|
||||
The existing Windows client sidesteps all of this by keeping the entire audio pipeline in
|
||||
C++. A pure-.NET port has to solve it deliberately.
|
||||
|
||||
### 5.2 Recommended design: a native shim that owns the RT thread
|
||||
|
||||
Write **one small C file** (`native/miniaudio/voicecat_audio_shim.c`, est. 300–400 lines)
|
||||
that compiles miniaudio and exposes a *pull/push ring-buffer API* instead of a callback API:
|
||||
|
||||
```c
|
||||
// The audio callback lives entirely in C. It only ever touches lock-free ring buffers.
|
||||
// Managed code polls. No managed frame is ever on an RT stack.
|
||||
|
||||
vcsh_device* vcsh_capture_open (const char* device_id, uint32_t channels, uint32_t rate);
|
||||
size_t vcsh_capture_read (vcsh_device*, int16_t* dst, size_t frames); // non-blocking
|
||||
vcsh_device* vcsh_playback_open(const char* device_id, uint32_t channels, uint32_t rate);
|
||||
size_t vcsh_playback_write(vcsh_device*, const int16_t* src, size_t frames);
|
||||
int vcsh_playback_wait(vcsh_device*, int timeout_ms); // eventfd/Event, wakes the mixer
|
||||
void vcsh_enumerate(int capture, vcsh_device_info** out, size_t* n);
|
||||
```
|
||||
|
||||
Managed side then runs a **normal, dedicated, non-RT `Thread`** at
|
||||
`ThreadPriority.Highest`, woken by `vcsh_playback_wait`, that does: drain jitter buffers →
|
||||
Opus decode → NR → gain/mute → mix → `vcsh_playback_write`. The ring absorbs GC pauses; size
|
||||
it for ~120 ms (6 × 20 ms frames), which is well within the latency budget the jitter buffer
|
||||
already targets (`target_depth_ms_` starts at 40).
|
||||
|
||||
This is *the same architecture the code already has* — `AudioEngine` already runs a
|
||||
`mixer_timer_thread_` for the iOS external-playback path and already has per-stream ring
|
||||
buffers (`RemoteStream::ring`). You are generalising the iOS path to every platform. That is
|
||||
a pleasing simplification, and it means the iOS design needs no special case at all.
|
||||
|
||||
**Bonus:** it makes `vc_set_external_playback` / `vc_set_mixed_output_sink` disappear as
|
||||
special modes. Everything is external playback; the shim is just one more sink.
|
||||
|
||||
### 5.3 GC and allocation discipline in the managed audio path
|
||||
|
||||
Even off the RT thread, the decode/mix loop runs 50×/second per stream and must not churn:
|
||||
|
||||
- `<ServerGarbageCollector>false</ServerGarbageCollector>` and
|
||||
`<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>` on client apps.
|
||||
Set `GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency` while a call is active.
|
||||
- **Preallocate everything at stream init**, exactly as `RemoteStream::init_ring` does today.
|
||||
Use `int16[]` fields, not `new` per frame.
|
||||
- Use `Span<short>` / `ReadOnlySpan<short>` throughout the DSP; `ArrayPool<short>.Shared` for
|
||||
the rare variable-size case. Never LINQ, never `IEnumerable`, never `List<T>` growth on
|
||||
this path.
|
||||
- Marshal to native with `fixed` + raw pointers, or declare P/Invokes as
|
||||
`[LibraryImport]` taking `ref short` / `ReadOnlySpan<short>` — the source generator emits
|
||||
pinning without a marshalling stub. **Do not** use `Marshal.Copy` per frame.
|
||||
- **Add an allocation regression test.** `GC.GetAllocatedBytesForCurrentThread()` before/after
|
||||
1000 simulated mix cycles must be ~0. This is a cheap, high-value test the C++ code can't
|
||||
even express.
|
||||
- The mixer's soft limiter, the RMS level meter, and the RNNoise call are all
|
||||
fixed-work-per-frame — they port directly.
|
||||
|
||||
### 5.4 Jitter buffer
|
||||
|
||||
`JitterBuffer` uses `std::map<uint32_t, Frame>` keyed by timestamp with wraparound handling,
|
||||
plus `try_lock` everywhere so the RT thread never blocks. In C#:
|
||||
|
||||
- `SortedDictionary<uint,Frame>` allocates per insert. Prefer a **fixed-capacity circular
|
||||
array of pre-allocated frame slots** indexed by `(ts / frameSamples) % capacity` — the
|
||||
buffer is bounded at 500 ms anyway (`kLateDropSamples`), so a ring is the natural shape and
|
||||
removes all allocation. This is a genuine improvement over the current C++.
|
||||
- Replace `try_lock` with `Monitor.TryEnter` or, better, make the ring single-producer
|
||||
(net thread) / single-consumer (mixer thread) with `Volatile`/`Interlocked` indices and drop
|
||||
the lock entirely.
|
||||
- Keep the EWMA jitter estimation, the leading-edge reseed, and the frame-skip catch-up logic
|
||||
**verbatim** — that logic is subtle, hard-won, and covered by `test_jitter_depth.cpp`.
|
||||
|
||||
### 5.5 Opus P/Invoke
|
||||
|
||||
```csharp
|
||||
[LibraryImport("opus")]
|
||||
internal static partial int opus_encode(IntPtr st, ReadOnlySpan<short> pcm, int frameSize,
|
||||
Span<byte> data, int maxDataBytes);
|
||||
```
|
||||
|
||||
- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. The implemented
|
||||
desktop binding uses fixed C entry points in `dotnet/native/media.c`; C calls the
|
||||
varargs function with the correct ABI. This also handles Apple arm64's different
|
||||
varargs calling convention. Only whitelisted single-int controls are accepted.
|
||||
- DRED (`opus_dred_alloc`, `opus_dred_parse`, `opus_decoder_dred_decode`) binds the same way.
|
||||
Guard with a runtime feature check as `opus_codec.cpp` does today.
|
||||
- **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link
|
||||
`libopus.a` via `<NativeReference>` in the `.csproj`. Multi-target the DllImport name with a
|
||||
`const string` behind `#if IOS`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Core client port — `VoiceCat.Core`
|
||||
|
||||
`vc_client` (107 KB) is the biggest single unit. It becomes `VoiceCatClient : IAsyncDisposable`.
|
||||
|
||||
### 6.1 The C ABI goes away — and the API gets much better
|
||||
|
||||
The 60-odd `vc_*` functions were shaped by C ABI constraints. In C#:
|
||||
|
||||
| C ABI pattern | C# replacement |
|
||||
|---------------|----------------|
|
||||
| `vc_result` enum returns | Exceptions for programmer errors; `VoiceCatResult` for protocol outcomes |
|
||||
| `vc_callbacks.on_event` + `vc_event` union-ish struct | **`IAsyncEnumerable<VoiceCatEvent>`** or typed `event` handlers per event type. Kill the `u32a` generic-payload field — use a discriminated hierarchy (`record UserJoined(uint UserId, uint ChannelId, string Nick)`). |
|
||||
| `VC_EVENT_JOIN_RESULT` correlating with `vc_join_channel` | **`Task<JoinResult> JoinChannelAsync(uint id, string? pw, CancellationToken ct)`** — request/response correlation via `TaskCompletionSource` keyed on `Envelope.request_id`. This removes an entire class of "which reply was mine" bugs and shrinks every client's code. |
|
||||
| `vc_list_channels` + `vc_free_channel_list` | `IReadOnlyList<Channel> Channels { get; }` — no ownership contract at all |
|
||||
| `vc_get_server_identity_display(buf, cap, out len)` two-call idiom | `string ServerIdentityDisplay { get; }` |
|
||||
| `vc_set_pcm_sink` / `vc_set_mixed_output_sink` / `vc_stream_feed_pcm` | Keep as-is conceptually — they're the bot/extension API. `Action<PcmFrame>` or a `ChannelWriter<T>`. Document the no-blocking rule just as loudly. |
|
||||
| `vc_test_inject_capture` | `internal` test hook, not public API |
|
||||
|
||||
**Do this deliberately, not accidentally.** Write `docs/api-dotnet.md` as the successor to
|
||||
`voicecat.h`, and keep the same rule from `CLAUDE.md`: changing it is a versioned act.
|
||||
|
||||
### 6.2 Threading model in C#
|
||||
|
||||
| C++ | C# |
|
||||
|-----|-----|
|
||||
| Asio `io_context` on `io_thread_` | A single `async` read loop over `PipeReader` per connection; no explicit thread |
|
||||
| `WorkerPool` (blocking work) | Default `ThreadPool` — `Task.Run` for Argon2id, SQLite, DNS |
|
||||
| Event queue drained by UI | `System.Threading.Channels.Channel<VoiceCatEvent>` (already what the Windows client does) |
|
||||
| Mixer timer thread | Dedicated `Thread` (§5.2) — **not** a `Task`, the thread pool is not for this |
|
||||
| Capture/encode thread | Dedicated `Thread`, fed by the shim's capture ring |
|
||||
|
||||
`WorkerPool` (861 bytes) simply deletes.
|
||||
|
||||
### 6.3 State model
|
||||
|
||||
`client.cpp` holds channel/user/stream maps guarded by mutexes and exposes them through
|
||||
pull-based `vc_list_*`. In C#, hold them as immutable snapshots swapped with
|
||||
`Volatile.Write` — readers get a consistent view with no locking, and the UI can bind to it
|
||||
directly. `VC_EVENT_CHANNEL_LIST` becomes "a new snapshot is available", which is what it
|
||||
already means.
|
||||
|
||||
---
|
||||
|
||||
## 7. Server port — `VoiceCat.Server`
|
||||
|
||||
The most mechanical part of the project. Straight `async`/`await` network code.
|
||||
|
||||
| Component | Port notes |
|
||||
|-----------|-----------|
|
||||
| `server.cpp` — accept loop | `Socket.AcceptAsync` loop + `Task` per connection. Trivial. |
|
||||
| `conn_session.cpp` (34 K) — per-conn protocol | The bulk. A big `switch` on `Envelope.BodyCase`. Mechanical; write it against the ported xUnit tests. |
|
||||
| `session_registry.cpp` | `ConcurrentDictionary<ulong, Session>` + a channel-membership index. Simpler than the C++. |
|
||||
| `media_relay.cpp` — the SFU | **The server hot path.** Authenticate/decrypt using the sender's directional key, then reseal for each recipient with its directional key and next counter. Preserve SSRC, timestamp, flags, and encoded Opus bytes; replace sequence and ciphertext/tag. Use pooled buffers and `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)`. Never decode audio. Benchmark fan-out and allocations. |
|
||||
| `db.cpp` (26 K) — SQLite | `Microsoft.Data.Sqlite`, same schema, same file. Keep raw SQL — do not introduce EF Core; the schema is 4 tables and EF's startup cost hurts the "single binary, instant start" goal. |
|
||||
| `identity.cpp` | `CertificateRequest` + BouncyCastle Ed25519. Reads the same on-disk files. |
|
||||
| Keepalive reaper | `PeriodicTimer` — cleaner than the `asio::steady_timer`. |
|
||||
| `server.toml` | Tomlyn, unchanged format. |
|
||||
|
||||
### 7.1 Deployment — keeping the "single static binary" promise
|
||||
|
||||
`docs/deployment.md` promises a single statically-linked executable with no runtime to
|
||||
install. **NativeAOT preserves this:**
|
||||
|
||||
```xml
|
||||
<PublishAot>true</PublishAot>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<StripSymbols>true</StripSymbols>
|
||||
```
|
||||
|
||||
- ✅ SQLitePCLRaw, Google.Protobuf, and BouncyCastle are all AOT-compatible.
|
||||
- ✅ Startup drops to ~5 ms; binary lands around 15–25 MB (vs. the current C++ static binary
|
||||
— comparable order of magnitude).
|
||||
- ⚠️ **No reflection-based JSON/config.** Use `System.Text.Json` source generators
|
||||
(`JsonSerializerContext`) if you use JSON anywhere. Tomlyn's model binding uses reflection —
|
||||
either use its low-level `DocumentSyntax` API or add trim descriptors.
|
||||
- ⚠️ Cross-compilation is per-RID; you need a build machine per target (`linux-x64`,
|
||||
`linux-arm64`, `win-x64`, `osx-arm64`). Same as today with vcpkg, so no regression.
|
||||
- The Docker image gets *simpler*: `FROM scratch`-ish with a NativeAOT binary, or
|
||||
`mcr.microsoft.com/dotnet/runtime-deps:10.0-noble`.
|
||||
|
||||
**Alternative if AOT fights you:** self-contained single-file publish
|
||||
(`PublishSingleFile` + `SelfContained`) — bigger (~70 MB) and slower to start, but no AOT
|
||||
constraints. Keep as a fallback per-RID, not the default.
|
||||
|
||||
---
|
||||
|
||||
## 8. Clients
|
||||
|
||||
### 8.1 Windows — the easy one
|
||||
|
||||
The `net10.0-windows` WinForms app is already C# and already structured around
|
||||
`Channel<VoiceCatEvent>` + a 30 ms UI-thread pump. The port is:
|
||||
|
||||
1. Delete `VoiceCat.Interop` (the P/Invoke layer) and `VoiceCat.Interop.Tests`.
|
||||
2. `<ProjectReference Include="VoiceCat.Core" />`.
|
||||
3. Update ~40 call sites from `VcResult r = Native.vc_join_channel(...)` to
|
||||
`await client.JoinChannelAsync(...)`. Mostly a find/replace plus `async void` →
|
||||
`async Task` hygiene on event handlers.
|
||||
4. `Audio/ProcessLoopbackCapture.cs`, `ProcessAudioMixer.cs`, `AudioSessionEnumerator.cs`
|
||||
(WASAPI process loopback, `AUDIOCLIENT_ACTIVATION_PARAMS`) — **unchanged**. They already
|
||||
feed `vc_stream_feed_pcm`; they'll feed `client.FeedPcm(...)`.
|
||||
5. `Native/RawInput.cs` (PTT hotkeys), `Models/PasswordProtector.cs` (DPAPI),
|
||||
`Notifications/*` (SAPI announcer, sound pool) — **unchanged**.
|
||||
|
||||
**WinForms accessibility (the reason it was chosen over WinUI 3) is unaffected.** Keep it.
|
||||
|
||||
**Estimated effort: 1–2 weeks.** This client is nearly free.
|
||||
|
||||
### 8.2 macOS — AppKit in C#
|
||||
|
||||
`net10.0-macos` gives full AppKit bindings via [dotnet/macios](https://github.com/dotnet/macios).
|
||||
The Swift AppKit code maps almost line-for-line:
|
||||
|
||||
| Swift | C# |
|
||||
|-------|-----|
|
||||
| `NSWindowController`, `NSOutlineView`, `NSTableViewDataSource` | Same types, same selectors, PascalCase |
|
||||
| `@objc func handleClick(_ sender: Any)` | `[Export("handleClick:")] void HandleClick(NSObject sender)` |
|
||||
| `accessibilityLabel`, `NSAccessibility.post(.announcement)` | `AccessibilityLabel`, `NSAccessibility.PostNotification(...)` — **full VoiceOver parity, the reason AppKit was chosen holds** |
|
||||
| `ScreenAudioCapture.swift` — ScreenCaptureKit | ✅ **ScreenCaptureKit is bound** in `net10.0-macos` (`SCStream`, `SCContentFilter`, `SCStreamConfiguration` incl. `CapturesAudio` / `ExcludesCurrentProcessAudio`). Per-app include/exclude filters and the VoiceOver-exclusion set port directly. |
|
||||
| `InputDeviceCapture.swift` — CoreAudio | AVFoundation/CoreAudio bound; or just use the miniaudio shim on macOS |
|
||||
|
||||
`MainWindowController.swift` is 70 KB — this is the single largest UI rewrite. Budget for it.
|
||||
|
||||
⚠️ **Distribution:** a `net10.0-macos` app bundle needs codesigning + notarization, and
|
||||
NativeAOT for macOS app bundles is supported but adds a step. Nothing blocking; just not
|
||||
free.
|
||||
|
||||
### 8.3 iOS — the SwiftUI gap (decision implemented 2026-09-19)
|
||||
|
||||
This is the only client with no mechanical path, because **SwiftUI has no C# equivalent.**
|
||||
Three options:
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|--------|------|------|
|
||||
| **A. UIKit in C#** (`net10.0-ios`, hand-written) | Full API access, best accessibility control, matches the macOS/AppKit approach, no extra framework | The ~150 KB of SwiftUI views (`SettingsView`, `ChannelTreeView`, `ChatView`, …) must be re-authored as UIKit — a real rewrite, not a translation |
|
||||
| **B. .NET MAUI** | Fastest to write; XAML declarative style is closest in spirit to SwiftUI; one codebase could later cover macOS too (Mac Catalyst) | ⚠️ Accessibility is weaker than native UIKit — and the project *explicitly* chose native toolkits for screen-reader quality (`roadmap.md` §2). Extra abstraction layer over the audio-sensitive app lifecycle. |
|
||||
| **C. Avalonia** | One UI codebase for Windows + macOS + iOS | ⚠️ Same accessibility objection as MAUI, *and* it would mean abandoning WinForms/AppKit — contradicts two settled decisions |
|
||||
|
||||
**Recommendation: Option A (UIKit).** It's more work but it is the only choice consistent
|
||||
with the accessibility commitments already made twice in the docs. Budget it as the largest
|
||||
single client task.
|
||||
|
||||
The decision is now implemented in `clients/apple/dotnet/VoiceCat.iOS`: a `net10.0-ios`
|
||||
UIKit host uses the managed core, statically links the Opus/RNNoise shim through
|
||||
`__Internal`, and embeds the retained Swift ReplayKit extension. The Swift app remains the
|
||||
migration oracle until the UIKit client completes its device, live-call and VoiceOver gates.
|
||||
|
||||
What ports cleanly regardless:
|
||||
- `IOSAudioRouter.swift` (31 KB) — `AVAudioSession` is fully bound. `SetPreferredDataSource`,
|
||||
`SetPreferredPolarPattern`, `AllowBluetoothA2DP`, `MeasurementMode` all exist in C#. The
|
||||
re-entrancy guards and route-change filtering (`.categoryChange` / `.routeConfigurationChange`
|
||||
/ `.override`) port verbatim. **Keep the invariants in `voice.md` §8 exactly.**
|
||||
- `IOSVoiceProcessingEngine.swift` (24 KB) — `AVAudioEngine`, `AVAudioSourceNode`,
|
||||
`SetVoiceProcessingEnabled(true)`, `VoiceProcessingAgcEnabled` are all bound.
|
||||
- Under §5.2's design, iOS stops being a special case: the core is *always* externally
|
||||
driven, and `AVAudioEngine` is simply the iOS "shim" implementation.
|
||||
|
||||
⚠️ **iOS + NativeAOT:** .NET for iOS ships Mono AOT by default; NativeAOT for iOS is
|
||||
[still experimental](https://learn.microsoft.com/en-us/dotnet/maui/deployment/nativeaot).
|
||||
Mono AOT is fine for the host app (it's what every Xamarin/MAUI app ships). Do not depend on
|
||||
NativeAOT on iOS.
|
||||
|
||||
⚠️ **App Store:** you already ship `me.iamtalon.voicecat`. A runtime change is invisible to
|
||||
review, but re-validate background-audio behaviour (`UIBackgroundModes: audio`) under Mono —
|
||||
managed finalizers and the GC must not stall the audio render callback while backgrounded.
|
||||
§5.2's native-ring design is what protects you here.
|
||||
|
||||
### 8.4 iOS screen sharing — **keep this in Swift**
|
||||
|
||||
You anticipated this correctly. The ReplayKit Broadcast Upload Extension should **not** be
|
||||
ported.
|
||||
|
||||
**Why:**
|
||||
1. **The 50 MB jetsam cap.** A managed runtime (Mono AOT + metadata + GC heap) inside a
|
||||
separate appex process eats a meaningful fraction of that before your code runs. The
|
||||
current Swift `SampleHandler` is 4.4 KB and does one `AVAudioConverter` call per buffer.
|
||||
2. **.NET for iOS does not officially support broadcast upload extensions.** Microsoft's own
|
||||
guidance is that this is a [known gap with no documentation](https://learn.microsoft.com/en-sg/answers/questions/2006706/issues-with-bundling-ios-broadcast-extension-in-ne);
|
||||
the supported extension types are enumerated and this isn't reliably among them.
|
||||
3. **There is nothing to gain.** The extension deliberately does *not* link the core
|
||||
(`voice.md` §9) — it converts PCM and writes to a shared ring. It is already a
|
||||
language-agnostic boundary.
|
||||
|
||||
**The boundary is already clean.** `BroadcastAudioRing.swift` is an mmap'd file in an App
|
||||
Group with an SPSC ring layout. C# reads it with `MemoryMappedFile.CreateFromFile` +
|
||||
`MemoryMappedViewAccessor`, and `CFNotificationCenter` (Darwin notifications) is bound in
|
||||
`net10.0-ios`. **Action: freeze `BroadcastAudioRing`'s binary layout as a documented struct**
|
||||
(magic, version, capacity, head, tail, activeFlag, sample format) in
|
||||
`docs/broadcast-ring-format.md`, so the Swift writer and the C# reader are contractually
|
||||
pinned. The C# `BroadcastAudioPump` is then ~150 lines.
|
||||
|
||||
This leaves the repo with exactly **one Swift file plus one shared Swift ring** — an
|
||||
acceptable, well-justified exception to "pure C#", and dramatically less than the current
|
||||
three Swift codebases.
|
||||
|
||||
### 8.5 What about a Linux client?
|
||||
|
||||
Not in scope today, but worth noting: once the core is `net10.0` with a miniaudio shim
|
||||
(which has ALSA/PulseAudio backends), a Linux client becomes a UI-only problem for the first
|
||||
time. Avalonia would be the natural choice *there specifically*, without disturbing the
|
||||
Windows/macOS/iOS decisions. Mention it in `roadmap.md`; don't build it now.
|
||||
|
||||
---
|
||||
|
||||
## 9. Tests
|
||||
|
||||
The 29 ctest cases **are** the specification. Port every one to xUnit in
|
||||
`tests/VoiceCat.Tests/`. Grouping:
|
||||
|
||||
| Group | Tests | Notes |
|
||||
|-------|-------|-------|
|
||||
| Wire format | `test_envelope`, `test_voice_frame`, `test_frame_codec`, `test_frame_ms_reframe` | Port first. These are pure functions — fastest possible feedback on the protobuf + framing layers. |
|
||||
| Crypto | `test_media_aead`, `test_tls_loopback`, `test_tofu_flow` | The AEAD test must produce **byte-identical** ciphertext to the C++ for a fixed key+nonce+AAD. Add that as a golden-vector test — it's your proof the port is wire-compatible. |
|
||||
| Codec/DSP | `test_opus_codec`, `test_dred_toggle`, `test_noise_suppression`, `test_recv_noise_reduction`, `test_plc_cap` | Depend on the native P/Invokes; run them as soon as those exist. |
|
||||
| Audio engine | `test_jitter_depth`, `test_channel_samplerate`, `test_external_pcm`, `test_external_playback`, `test_vad_ptt_devices` | The subtle ones. `test_jitter_depth` guards the bounded-depth invariant — do not weaken it. |
|
||||
| Integration | `test_m1_integration`, `test_m2_voice`, `test_m3_multistream`, `test_tcp_loopback`, `test_disconnect_left`, `test_reaper_timeout` | Real client + real server in-process. |
|
||||
| Moderation/admin | `test_m5_permissions`, `test_m5_kick_ban_move_mute`, `test_m5_channel_crud`, `test_m5_admin_accounts` | Server-side; port with `VoiceCat.Server`. |
|
||||
| ABI surface | `test_voice_client_abi`, `test_channel_user_list_abi` | These test the C ABI specifically — **rewrite as API-shape tests** against the new C# surface, don't port literally. |
|
||||
|
||||
**New tests the port should add:**
|
||||
- Allocation regression on the mix loop (§5.3).
|
||||
- AEAD golden vectors vs. C++ output.
|
||||
- A **cross-implementation test**: C# client ↔ C++ server, and C++ `vccli` ↔ C# server, run
|
||||
in CI for as long as both trees exist (§11).
|
||||
|
||||
`ctest --preset dev` → `dotnet test`. The house rule in `CLAUDE.md` ("every commit builds and
|
||||
passes") carries over unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 10. Documentation changes
|
||||
|
||||
Per the `CLAUDE.md` rule that docs and code stay in sync:
|
||||
|
||||
| Doc | Change |
|
||||
|-----|--------|
|
||||
| `docs/architecture.md` | Rewrite §1 (shared-core model — it's now a shared *assembly*), §4 (C ABI → C# API), §3 (threading — the shim design). Keep §5 (server) and §2 (layers) nearly as-is. |
|
||||
| `docs/tech-stack.md` | Replace the whole dependency table. Re-run the license audit (§5) — the no-GPL rule still passes. |
|
||||
| `docs/security.md` | ⚠️ **§2 needs rewriting** to describe BouncyCastle's exporter rather than mbedTLS's, and §1.1 should be updated when the Ed25519↔cert binding lands (§3.4). §3–§6 unchanged. |
|
||||
| `docs/voice.md` | §5 (jitter), §8 (pipeline), §10 (NR) get implementation-detail updates. The *protocol* sections (§2 frame format, §3 config, §4 loss resilience) are **unchanged** — that's the point. |
|
||||
| `docs/protocol.md` | Unchanged unless you take Option B (§3.3), which adds two `AuthResult` fields and bumps to v3. |
|
||||
| `docs/building.md` | Full rewrite: `dotnet build` + the native build script replace the CMake preset matrix. **Delete the "run ctest in PowerShell not Git Bash" warning** — that MinGW pathology disappears with the toolchain. |
|
||||
| `docs/deployment.md` | §1.B/C update for NativeAOT publish; Docker base image changes. The zero-config promises hold. |
|
||||
| `docs/roadmap.md` | Add the port as its own milestone; note the Linux-client possibility (§8.5). |
|
||||
| `CLAUDE.md` | New build commands, new subsystem map, new house rules (no allocation in the audio path becomes explicit). |
|
||||
| **new** `docs/api-dotnet.md` | The successor to `voicecat.h` — the versioned client API contract. |
|
||||
| **new** `docs/broadcast-ring-format.md` | The frozen Swift↔C# App Group ring layout (§8.4). |
|
||||
|
||||
---
|
||||
|
||||
## 11. Migration strategy — the actual step-by-step
|
||||
|
||||
The guiding principle: **the C++ tree stays working and becomes the conformance oracle.** Do
|
||||
not delete anything until the C# equivalent passes the same test against it. This is only
|
||||
possible because Option A (§3.2) preserves wire compatibility — which is the main reason to
|
||||
choose it.
|
||||
|
||||
The rewrite lives under `dotnet/`; initial implementation branch: `dotnet/foundations`,
|
||||
created from `cs-port`. Keep the existing schema at `core/proto/voicecat.proto` during migration.
|
||||
Native packaging is deferred until the codec/audio phase rather than blocking the wire slice.
|
||||
Each phase ends with a green build,
|
||||
green tests, and an updated `PROGRESS.md` entry.
|
||||
|
||||
---
|
||||
|
||||
### Phase 0 — Foundations (est. 1 week)
|
||||
|
||||
1. Create the solution skeleton from §2. `Directory.Build.props` with
|
||||
`net10.0`, `<Nullable>enable</Nullable>`, `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`,
|
||||
`<AnalysisLevel>latest-all</AnalysisLevel>`, `<InvariantGlobalization>true</InvariantGlobalization>`.
|
||||
2. `native/build-native.{ps1,sh}`: build libopus 1.6, RNNoise, and the (empty for now)
|
||||
miniaudio shim into `runtimes/{rid}/native/`. Vendor the sources — drop vcpkg.
|
||||
3. `VoiceCat.Protocol`: add `Google.Protobuf` + `Grpc.Tools`, point at the **existing**
|
||||
`core/proto/voicecat.proto`, verify generated C# types compile.
|
||||
4. CI: GitHub Actions matrix building both trees (C++ and C#) side by side.
|
||||
|
||||
**Exit criterion:** `dotnet build` produces empty-but-real assemblies; generated protobuf
|
||||
types are present; native libs land in the right RID folders.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Wire format, provably compatible (est. 1 week)
|
||||
|
||||
1. Port `envelope.cpp` (frame `[u32 len][payload]`) using `System.IO.Pipelines`.
|
||||
2. Port `voice_frame.h` — the 20-byte big-endian header. Use
|
||||
`BinaryPrimitives.WriteUInt64BigEndian` etc.
|
||||
3. Port `SodiumMediaCrypto` → `MediaCrypto` on `System.Security.Cryptography.ChaCha20Poly1305`,
|
||||
including the nonce scheme and the sliding replay window. **Preserve the RFC 3711 §3.3
|
||||
ordering.**
|
||||
4. Port `test_envelope`, `test_voice_frame`, `test_frame_codec`, `test_media_aead`.
|
||||
5. **Generate golden vectors from the C++ build** (a throwaway C++ main dumping sealed frames
|
||||
for fixed inputs) and assert the C# produces identical bytes.
|
||||
|
||||
**Exit criterion:** golden-vector tests green. From here on, byte-compatibility is measured,
|
||||
not assumed.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — TLS + the exporter (est. 1.5 weeks — highest-risk phase, do it early)
|
||||
|
||||
1. Spike first, in isolation: a BouncyCastle `TlsClientProtocol` ↔ `TlsServerProtocol`
|
||||
loopback over a `Socket` pair, both calling
|
||||
`ExportKeyingMaterial("voicecat media v1", ...)` and asserting the two sides agree.
|
||||
2. **Then the real proof:** a C# BouncyCastle client handshaking against the **existing C++
|
||||
mbedTLS server**, both exporting with the same label, and asserting the derived media keys
|
||||
are identical. *This is the single most important checkpoint in the whole project.* If it
|
||||
fails, stop and reconsider Option B before writing anything else.
|
||||
3. Port `ServerCert`/`ServerIdentity` (`CertificateRequest` + BC Ed25519), the TOFU pin store
|
||||
(`tofu_store.cpp` — a trivial text file), and cert-fingerprint pinning.
|
||||
4. Port `test_tls_loopback`, `test_tofu_flow`, `test_tcp_loopback`.
|
||||
|
||||
**Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives
|
||||
matching media keys, and pins the leaf fingerprint.
|
||||
|
||||
**Checkpoint (2026-09-15):** implemented nonblocking managed TLS, handshake-time
|
||||
exporters, explicit certificate acceptance, persisted TOFU, and native-compatible
|
||||
credentials. The C++ TLS oracle authenticates a media challenge in both directions
|
||||
over an actual socket, proving exporter compatibility. Tests also cover managed
|
||||
fragmented loopback, first-connect acceptance, changed-pin rejection, TLS 1.2 rejection,
|
||||
close_notify/abrupt EOF, restart persistence, and import of C++ credential files.
|
||||
Socket orchestration remains a transport-owner responsibility; the complete managed
|
||||
server and client are later phases. See `dotnet/README.md` for the required native
|
||||
interoperability test command.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Codec + DSP (est. 1 week)
|
||||
|
||||
1. `VoiceCat.Codec`: libopus `[LibraryImport]`, `OpusEncoder`/`OpusDecoder`, the varargs-CTL
|
||||
workaround (§5.5), DRED.
|
||||
2. `VoiceCat.Dsp`: RNNoise binding, `EnergyVadProcessor`.
|
||||
3. Port `test_opus_codec`, `test_dred_toggle`, `test_noise_suppression`.
|
||||
|
||||
**Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery
|
||||
test green; RNNoise output matches the C++ within tolerance.
|
||||
|
||||
**Checkpoint (2026-09-15):** implemented `VoiceCat.Codec`, `VoiceCat.Dsp`, safe native
|
||||
handles, fixed-signature C bindings, and independent desktop native staging. The native
|
||||
build pins the upstream Opus 1.5.2 release/checksum (matching the actual vcpkg baseline,
|
||||
despite older code comments referring to 1.6), enables DRED, and shares the existing
|
||||
vendored RNNoise sources/model. Tests cover 40 rate/channel/frame-size round trips,
|
||||
PLC, 40 dropped-frame DRED recovery formats, C++ denoising within one PCM unit, VAD
|
||||
hang time, and zero managed allocations over 1,000 combined processing cycles.
|
||||
At 8/12 kHz, DRED tests encode at 16 kHz and decode at the requested rate: this pinned
|
||||
encoder's activity analysis cannot emit DRED at 8/12 kHz. These encoding configurations
|
||||
are explicitly rejected. Its redundancy floor is 30 ms because two chunks are required
|
||||
to emit DRED; actual redundancy remains adaptive. Desktop CI builds/stages the library
|
||||
before testing. iOS static native packaging and device audio remain later phases.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Server (est. 3–4 weeks)
|
||||
|
||||
Do the server before the client: it lets you point the **existing, trusted C++ `vccli`** at
|
||||
it, which is a far better test client than a half-built C# one.
|
||||
|
||||
**Implemented checkpoint (2026-09-15):** bounded async TLS socket orchestration,
|
||||
guest/password authentication, existing SQLite account/channel import, snapshots,
|
||||
unprotected channel joins, channel/private/server text, ping and disconnect events.
|
||||
The existing C++ CLI authenticates and sends text through this server. Argon2id uses
|
||||
the existing BouncyCastle dependency with a strict libsodium PHC parser, not a new
|
||||
Konscious dependency. Native database tests prove password compatibility in both
|
||||
directions without resets. SQLite uses `Microsoft.Data.Sqlite.Core` 10.0.5,
|
||||
SQLitePCLRaw bundle 3.0.2 and explicitly pinned SourceGear SQLite 3.50.4.2.
|
||||
See `docs/api-dotnet.md` for limits. This first checkpoint did not include UDP voice,
|
||||
streams, protected joins, moderation, admin handlers or production configuration.
|
||||
The subsequent voice checkpoint is described below.
|
||||
|
||||
**Voice checkpoint:** the managed server now advertises UDP, issues session-bound
|
||||
tokens, implements voice subscription and multi-stream signaling, and relays encrypted
|
||||
Opus with recipient-specific counters. The first UDP endpoint is fixed for the session;
|
||||
reconnect for endpoint changes. Immutable routing snapshots separate control handlers
|
||||
from the UDP crypto owner. Real-socket tests cover replay/forgery/SSRC rejection,
|
||||
channel/subscription isolation, stream stop and disconnect. A native client oracle
|
||||
exercises bidirectional microphone and screen audio in mono and stereo. The fan-out
|
||||
core has a 50-subscriber allocation regression test; transport scheduling and the
|
||||
BouncyCastle crypto fallback are excluded from its zero-allocation guarantee.
|
||||
Two real C++ `vccli` processes also pass join/text/bidirectional voice tests using
|
||||
finite `--test-tone-ms` external capture/playback. The transport load test delivers
|
||||
all 2,500 recipient packets from a sender paced at 50 pps to 50 subscribers.
|
||||
**Reaper checkpoint:** configurable 45-second idle expiry / 15-second sweep replaces
|
||||
the TCP-only idle timeout. Control envelopes, authenticated voice and bound-endpoint
|
||||
keepalives refresh shared monotonic activity; invalid media does not. Reaping removes
|
||||
presence and media routing, and can be disabled. Tests inject a clock to cover silent
|
||||
clients, UDP-only activity, forged media, single departure events and disabled expiry.
|
||||
**Channel/administration checkpoint:** protected joins and channel CRUD now persist using
|
||||
the native BLAKE2b password format (native verification in both directions). Permissions
|
||||
gate moderation and account create/reset/delete/list. Mute/deafen/move update encrypted
|
||||
routing; kick/ban retire media and emit one reason-bearing departure. Guest bans use
|
||||
addresses, account bans use usernames, and wire milliseconds convert to database seconds.
|
||||
Temporary-channel permission only creates temporary channels and only administrators
|
||||
grant permissions; these deliberately tighten native policy. Tree validation and Lobby
|
||||
protection prevent invalid mutations. Existing streams stop on channel edits/moves/deletion.
|
||||
The C++ CLI creates protected channels and administers accounts against the managed server;
|
||||
its channel argument lifetimes and default audio config were corrected. See api-dotnet.md
|
||||
for limits, persistence and policy differences. Production configuration/publishing and
|
||||
the remaining server readiness checks still precede Phase 4 completion.
|
||||
|
||||
1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry.
|
||||
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
|
||||
question here** (§4).
|
||||
3. `MediaRelay` — the allocation-free SFU fan-out.
|
||||
4. Keepalive reaper, moderation/admin handlers.
|
||||
5. Port `test_m1_integration`, `test_m5_*`, `test_disconnect_left`, `test_reaper_timeout`.
|
||||
|
||||
**Exit criterion:** ▶ **C++ `vccli` connects to the C# server, authenticates, joins a
|
||||
channel, sends text, and exchanges voice with a second C++ `vccli`.** That is the M1+M2 exit
|
||||
criterion from `roadmap.md`, re-proven against the new server.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Audio engine (est. 4–5 weeks — the hardest phase)
|
||||
|
||||
1. Write and validate `voicecat_audio_shim.c` standalone (a C test that loops mic→speaker
|
||||
through the rings, no .NET involved).
|
||||
2. `VoiceCat.Audio`: device enumeration, the allocation-free jitter buffer (§5.4), per-ssrc
|
||||
decode with the **DRED → FEC → PLC** ladder, per-stream NR/gain/mute, the mixer, level
|
||||
meters, talk-state edge detection.
|
||||
3. The dedicated mixer thread + capture thread.
|
||||
4. External feed/tap/mixed-sink — now the *normal* path, not special modes.
|
||||
5. Port `test_jitter_depth`, `test_external_pcm`, `test_external_playback`,
|
||||
`test_channel_samplerate`, `test_plc_cap`, `test_recv_noise_reduction`,
|
||||
`test_frame_ms_reframe`.
|
||||
6. **Add the allocation-regression test.**
|
||||
|
||||
**Exit criterion:** `test_jitter_depth`'s bounded-depth invariant holds; zero allocations per
|
||||
mix cycle; real multi-human calls on Windows and macOS have no reproducible audio-quality
|
||||
defects once each client is feature-complete.
|
||||
|
||||
**Checkpoint (2026-09-16):** `VoiceCat.Audio` now owns local Opus streams, reframing at every
|
||||
protocol frame size, VAD/PTT/DTX, DRED → FEC → PLC receive recovery, bounded jitter, per-stream
|
||||
controls, RNNoise and stereo mixing. Its normal encode/decode/NR/mix cycle allocates zero
|
||||
managed bytes. Capture inputs use bounded non-waiting PCM rings. The Windows implementation
|
||||
uses direct C# WASAPI capture, loopback and playback instead of the proposed miniaudio device
|
||||
shim; the codec/DSP shim remains the only native component. A real device smoke passed;
|
||||
final listen validation is performed with real multi-human calls after feature completion,
|
||||
not a synthetic ten-minute sine-wave gate.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Client core (est. 3–4 weeks)
|
||||
|
||||
1. `VoiceCatClient`: connect/TOFU/auth state machine, request/response correlation via
|
||||
`TaskCompletionSource`, channel/user/stream snapshots, stream lifecycle, reframing, the
|
||||
send path, the event stream.
|
||||
2. `VoiceCat.Cli` — the `vccli` replacement, plus `voicecat-admin`.
|
||||
3. Port `test_m2_voice`, `test_m3_multistream`, `test_vad_ptt_devices`; rewrite the two ABI
|
||||
tests as API-shape tests.
|
||||
|
||||
**Exit criterion:** ▶ **Two C# `vccli` instances hold a multi-channel voice + text
|
||||
conversation through the C# server**, and a C# `vccli` interoperates with a C++ `vccli` on
|
||||
the same server. This is the full M0–M3 criterion re-proven end to end.
|
||||
|
||||
**Complete (2026-09-16):** `VoiceCat.Core` implements TOFU-gated TLS, concurrent correlated
|
||||
requests, snapshots/events, reconnects, encrypted UDP binding and send/receive stream
|
||||
lifecycle. Two managed clients exchange text and decoded PCM through the managed server.
|
||||
`VoiceCat.Cli` supports interactive channel text and deterministic headless text/voice runs.
|
||||
Process tests prove two managed CLIs converse with decoded voice in both configured channels,
|
||||
and a managed CLI exchanges text and bidirectional voice with the existing C++ CLI.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Windows client (est. 1–2 weeks)
|
||||
|
||||
Per §8.1. Ship this first of the three GUIs — it's the cheapest and it validates the C# API
|
||||
shape against a real, complete UI before you commit to two rewrites.
|
||||
|
||||
**Exit criterion:** feature parity with the current WinForms build, NVDA smoke-tested.
|
||||
|
||||
**Checkpoint (2026-09-16):** the shipped WinForms project references `VoiceCat.Managed`, not
|
||||
the P/Invoke core. The compatibility facade preserves UI-thread event pumping and stable
|
||||
capture IDs while delegating all protocol and audio state to the idiomatic managed projects.
|
||||
Channel moves automatically renegotiate active streams. A published build passed real WASAPI
|
||||
capture/playback and form-startup smoke tests and contains `voicecat_media.dll` but no
|
||||
`voicecat.dll`. Automated text, bidirectional PCM voice, multi-frame audio and stream-move
|
||||
tests pass. NVDA and the manual endurance/listen pass remain before the Phase 7 exit criterion.
|
||||
|
||||
---
|
||||
|
||||
### Phase 8 — macOS client (est. 4–5 weeks)
|
||||
|
||||
Per §8.2. AppKit port, ScreenCaptureKit per-app audio selection, VoiceOver parity.
|
||||
|
||||
**Exit criterion:** feature parity with `VoiceCatMac`, VoiceOver smoke-tested, notarized
|
||||
build produced.
|
||||
|
||||
**Checkpoint (2026-09-18):** `clients/apple/dotnet/VoiceCat.Mac` is a separate .NET 10
|
||||
AppKit application that consumes `VoiceCat.Core` directly. It implements guest and account
|
||||
connection, validated saved server profiles whose JSON never contains passwords,
|
||||
interactive TOFU approval, protected-channel password prompts, channel browsing, roster,
|
||||
channel/private text,
|
||||
voice subscription, native accessibility labels and default-device microphone/playback.
|
||||
Account passwords can be stored in macOS Keychain after successful authentication and are
|
||||
removed when the user disables remembering or deletes the profile.
|
||||
Core Audio capture is resampled
|
||||
and converted through `AVAudioConverter` before entering the managed audio engine. Stereo
|
||||
playback uses `AVAudioSourceNode` and the shared bounded PCM ring; its render callback does
|
||||
not allocate, lock or block. Voice stream lifetime follows subscription, disconnect and
|
||||
channel changes. The app now targets Microsoft's Xcode 27 preview bindings from workload
|
||||
set 10.0.401. On Apple Silicon it selects a native Homebrew `protoc`, deduplicates the
|
||||
transitive media dylib before AppKit bundling, uses the macOS 27 error-returning Core Audio
|
||||
overloads, and keeps hardened runtime Release-only so local ad-hoc Debug builds can load
|
||||
their runtime libraries. It enumerates and selects Core Audio input/output devices, requests
|
||||
microphone permission explicitly, and renders through Core Audio's native planar Float32
|
||||
layout. The arm64 app builds, signs, launches and displays on macOS 27. Live testing proved
|
||||
physical-microphone transmission and clean peer playback; Lobby's expected DTX comfort noise
|
||||
was distinguished from corruption by repeating the tone in the DTX-disabled Music Room. A
|
||||
macOS CI job builds the native codec/DSP shim and Apple solution. The existing Swift app
|
||||
remains the release client until the manual release gates below are complete.
|
||||
|
||||
**Functional-parity checkpoint (2026-09-19):** the managed app now covers persistent audio
|
||||
and notification settings, VAD/configurable focus-scoped PTT/always-on input, stereo capture,
|
||||
RNNoise, input/output/auxiliary gain, selectable auxiliary capture, self mute/deafen, speaking
|
||||
state, event sounds and speech, modeless private conversations, full channel configuration,
|
||||
per-user receive tuning, moderation, permissions and account administration. ScreenCaptureKit
|
||||
publishes desktop audio with whole-desktop, application-only and application-exclusion scopes
|
||||
and can exclude VoiceOver/speech processes. Legacy Swift profiles, TOFU pins and Keychain
|
||||
passwords migrate without discarding the original profile JSON. A publishing script validates
|
||||
an isolated ad-hoc distribution copy without mutating incremental build output and supports
|
||||
Developer ID signing, notarization, stapling and Gatekeeper assessment. Debug and Release
|
||||
builds, 190 managed tests, 29 native CTests and a
|
||||
strictly verified/running ad-hoc Release bundle pass. Do not remove the Swift macOS app yet:
|
||||
cutover still requires a manual VoiceOver navigation/announcement pass, ScreenCaptureKit plus
|
||||
real multi-human call validation, and an actual credentialed notarization run.
|
||||
|
||||
---
|
||||
|
||||
### Phase 9 — iOS client (est. 5–7 weeks)
|
||||
|
||||
Per §8.3/§8.4. UIKit rewrite, `AVAudioSession` router port, `AVAudioEngine` VPIO path, and
|
||||
the C# `BroadcastAudioPump` reading the **unchanged Swift** extension's ring.
|
||||
|
||||
**Exit criterion:** feature parity with `VoiceCatiOS`; screen-audio sharing works with the
|
||||
Swift extension untouched; A2DP/stereo/VPIO preset matrix re-verified (this is where the
|
||||
known stereo-A2DP class of bug lives — re-test it explicitly).
|
||||
|
||||
---
|
||||
|
||||
### Phase 10 — Cutover (est. 1–2 weeks)
|
||||
|
||||
1. Run both trees in CI for one full release cycle.
|
||||
2. Delete `core/`, `server/`, `tools/`, `clients/apple/` (except `VoiceCatBroadcast/` and
|
||||
`Shared/BroadcastAudioRing.swift`), `vcpkg/`, `CMakePresets.json`, root `CMakeLists.txt`.
|
||||
3. Update every doc per §10.
|
||||
4. Tag the last C++ commit so the oracle stays reachable.
|
||||
|
||||
---
|
||||
|
||||
### 11.5 Total estimate
|
||||
|
||||
**~7–9 months of focused single-developer work**, front-loaded with risk (Phase 2) and
|
||||
back-loaded with volume (Phases 8–9). The server + core (Phases 0–6) is roughly 4 months and
|
||||
is the part that removes the most complexity; the three GUIs are roughly half the calendar
|
||||
time and almost none of the difficulty.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risk register
|
||||
|
||||
| # | Risk | Severity | Mitigation |
|
||||
|---|------|----------|------------|
|
||||
| 1 | **BouncyCastle's exporter doesn't interoperate with mbedTLS's** | 🔴 Critical | Prove it in Phase 2 step 2, before any other work depends on it. Both implement RFC 8446 §7.5, so it should — but *verify*, don't assume. Fallback: Option B (§3.3). |
|
||||
| 2 | **GC pauses cause audio glitches** | 🔴 High | Native shim owns the RT thread (§5.2); ~120 ms ring; allocation-regression test; `SustainedLowLatency`. This is the design's whole answer. |
|
||||
| 3 | **iOS audio regressions under Mono AOT** | 🟠 Medium-High | The iOS audio path is already the most delicate part of the product (see the A2DP/VPIO invariants in `voice.md` §8). Re-test the full preset × route matrix. Do not port the invariants "roughly". |
|
||||
| 4 | **Argon2id hashes don't verify → existing accounts locked out** | 🟠 Medium | Decide in Phase 4. Preferred: parse libsodium's PHC string and pass m/t/p to Konscious; verify against real hashes from an existing `voicecat.db` *before* writing the rest of `Db`. |
|
||||
| 5 | **SFU relay throughput regression** | 🟠 Medium | Benchmark early (Phase 4): N=50 subscribers × 50 pps. Allocation-free `SocketAddress` receive + pooled buffers. .NET's socket layer is good; this should be fine, but measure. |
|
||||
| 6 | **`ChaCha20Poly1305.IsSupported == false`** on some target | 🟡 Low | Startup check + BouncyCastle fallback. One-line risk. |
|
||||
| 7 | **`opus_encoder_ctl` varargs breaks on a future ABI** | 🟡 Low | Only single-`int` CTLs are used; document it, add a test that exercises every CTL used. |
|
||||
| 8 | **NativeAOT trimming breaks protobuf/SQLite reflection** | 🟡 Low | All three are AOT-tested upstream. Add an AOT-published smoke test to CI from Phase 4. |
|
||||
| 9 | **macOS/iOS bindings lag a new Xcode** | 🟡 Low | dotnet/macios tracks Xcode closely (bindings exist through Xcode 26). Pin the workload version. |
|
||||
| 10 | **Scope creep — "while we're rewriting, let's also…"** | 🟠 Medium | The port is a *translation*. The API-shape improvements in §6.1 are the only sanctioned redesign. Everything else goes in `roadmap.md`. |
|
||||
|
||||
---
|
||||
|
||||
## 13. What you gain
|
||||
|
||||
Worth being explicit, since this is 7+ months:
|
||||
|
||||
- **One language, one toolchain, one debugger.** No more CMake presets, vcpkg triplets,
|
||||
MinGW-vs-PowerShell execution pathologies, XCFramework fat-static-lib packaging, or a C ABI
|
||||
that has to be hand-mirrored into both Swift and C#.
|
||||
- **Three UI codebases instead of three UI codebases *plus* a core plus two binding layers.**
|
||||
The `VoiceCat.Interop` P/Invoke layer, the `VoiceCatCore` Swift wrapper, the module map, and
|
||||
the whole `voicecat.h` ABI surface all cease to exist.
|
||||
- **Better API.** `await client.JoinChannelAsync()` instead of "call this, then wait for
|
||||
`VC_EVENT_JOIN_RESULT`, and hope it's yours."
|
||||
- **Memory safety** across the entire protocol-parsing surface — the part most exposed to
|
||||
hostile input.
|
||||
- **8 native dependencies → 3**, each small and vendored.
|
||||
- **Tests that can assert things C++ couldn't**, notably zero-allocation invariants.
|
||||
|
||||
And what you keep: the protocol, the wire format, the security model, the audio design, the
|
||||
accessibility-first UI toolkit choices, and every single one of the 29 behavioural tests.
|
||||
+1
-1
@@ -28,7 +28,7 @@ boundaries on top of the TLS byte stream.
|
||||
|
||||
## 2. Why Protocol Buffers for control
|
||||
|
||||
- Schema-driven codegen for **C++, C#, and Swift** (all first-class) → no hand-rolled
|
||||
- Schema-driven codegen for the supported **C#** implementation → no hand-rolled
|
||||
parsers, no drift between client and server.
|
||||
- **Forward/backward compatible by construction**: unknown fields are preserved/ignored,
|
||||
new fields and new `oneof` arms are additive. This is exactly the "extensible protocol"
|
||||
|
||||
+25
-187
@@ -1,195 +1,33 @@
|
||||
# Roadmap & Open Questions
|
||||
# Roadmap
|
||||
|
||||
## 1. Milestones
|
||||
The managed rewrite is functionally complete. Work is now release validation, retirement of
|
||||
the old implementation, and focused product development.
|
||||
|
||||
### .NET port — initial slice
|
||||
## Release gates
|
||||
|
||||
**Complete 2026-09-15:** managed Release build and 34/34 xUnit tests, C++ golden
|
||||
fixtures for both crypto backends, fresh native build and 29/29 CTest tests. Native
|
||||
packaging and TLS/server/client migration remain later checkpoints.
|
||||
- Windows: published-client smoke test, NVDA navigation, and sustained real calls.
|
||||
- macOS: VoiceOver matrix, multi-person call, Developer ID signing, notarization, Gatekeeper.
|
||||
- iOS: physical-device VoiceOver, background/lock, interruption and route recovery, Bluetooth,
|
||||
ReplayKit on iOS 18–26, and ScreenCaptureKit audio on iOS 27+.
|
||||
- Server: build and run the container, validate graceful shutdown, and complete a 30-minute or
|
||||
longer concurrent text/voice soak.
|
||||
|
||||
- `dotnet/` contains .NET 10 protocol and crypto assemblies plus xUnit conformance tests.
|
||||
- Preserve the existing protobuf and 20-byte media wire formats; keep C++ as the oracle.
|
||||
- **Exit:** managed framing, headers, and ciphertext match fixtures generated by C++;
|
||||
managed tests and the existing C++ behavior suite pass.
|
||||
- **Subsequent checkpoints:** TLS/exporter and credential interoperability, codec/DSP
|
||||
desktop packaging, and managed control/UDP server slices are implemented. Two C++
|
||||
`vccli` processes authenticate, join, chat and exchange mono/stereo voice through
|
||||
the managed server. The 50-subscriber fan-out core has an allocation regression test.
|
||||
- **Media-aware reaping:** monotonic control/valid-UDP activity, configurable 45-second
|
||||
idle timeout / 15-second sweep, and graceful shutdown are implemented and tested.
|
||||
- **Next:** finish managed server administration, protected joins and production configuration,
|
||||
then audio/client core, Windows cutover, C# AppKit and UIKit.
|
||||
Keep the Swift ReplayKit extension and its shared ring; defer C++ removal until parity.
|
||||
- See `docs/porting-to-dotnet.md` and `dotnet/README.md`.
|
||||
## Cleanup
|
||||
|
||||
Each milestone is shippable/testable on its own. The headless C++ test client (`vccli`)
|
||||
exists from M1 so the protocol can be exercised long before any GUI.
|
||||
- Remove the retired C++ core, server, CLI, tests, CMake presets, and vcpkg tree.
|
||||
- Remove the old Swift macOS/iOS applications after retained assets are detached.
|
||||
- Keep `native/media`, `native/rnnoise`, and `native/apple/broadcast`.
|
||||
- Remove the old Windows P/Invoke project after shared model types are moved into the managed
|
||||
compatibility facade.
|
||||
- Continue reducing historical documentation to current contracts and operating instructions.
|
||||
|
||||
### M0 — Scaffolding
|
||||
- Repo layout (see architecture.md §6), CMake + vcpkg manifest, CI matrix.
|
||||
- `core/proto/` skeleton; `protoc` codegen wired for C++ (C#/Swift later).
|
||||
- Empty `libvoicecat` with the C ABI header and stub implementations.
|
||||
- **Exit:** core + server + `vccli` compile and link on Linux/macOS/Windows.
|
||||
## Deferred product features
|
||||
|
||||
### M1 — Control plane (TCP/TLS, no audio yet)
|
||||
- TLS 1.3 transport; framing; Envelope; ClientHello/ServerHello negotiation.
|
||||
- Auth: **guest + admin-provisioned local account** (Argon2id, SQLite); server identity
|
||||
(TOFU/Ed25519); `voicecat-admin` account add/reset/del/list.
|
||||
- Channel tree: snapshot + deltas; join/leave; create/edit/delete (perm-checked).
|
||||
- **Text chat (ephemeral):** channel + private messages, acks; live relay, no history store.
|
||||
- `vccli` can connect, auth, browse channels, and chat.
|
||||
- **Exit:** two `vccli` instances chat through a real server over TLS.
|
||||
- file transfer
|
||||
- end-to-end media encryption beyond the server-terminated transport encryption
|
||||
- persistent server-side text history
|
||||
- key-based user identities
|
||||
- multi-node/federated servers
|
||||
- PushKit/CallKit incoming-call behavior
|
||||
|
||||
### M2 — Voice, single stream
|
||||
- UDP transport with exported-key + ChaCha20-Poly1305 AEAD (mandatory, no plaintext path);
|
||||
UDP token binding; anti-replay.
|
||||
- miniaudio capture/playback; libopus encode/decode; one `MIC` stream per user.
|
||||
- **Send-side webrtc APM** (AEC + NS + AGC + VAD) and a **VAD/PTT input gate** (both modes,
|
||||
client-configurable) — AEC is in from the start, not deferred.
|
||||
- Per-ssrc adaptive jitter buffer; mixer; FEC/PLC/DTX.
|
||||
- Per-channel `AudioConfig` enforced (incl. server `max_bitrate_bps` ceiling); SFU relay.
|
||||
- **Exit:** talk between two `vccli`/early-GUI clients in a channel; loss resilience visible.
|
||||
|
||||
### M3 — Multi-stream & per-channel tuning
|
||||
- Multiple concurrent streams per user (`MIC`, `SCREEN_AUDIO`, `AUX_DEVICE`).
|
||||
- Per-stream receiver gain/mute; **listener-side per-user noise reduction** (APM NS on the
|
||||
receive path, per ssrc, local-only); talk indicators.
|
||||
- Full per-channel Opus configurability (mono/stereo, bitrate, frame size, FEC/DTX).
|
||||
- **Exit:** a user shares mic + desktop audio; listeners control each independently.
|
||||
|
||||
### M4 — Native clients
|
||||
|
||||
**Windows (C#/WinForms, .NET 10) ✓ complete 2026-06-17:**
|
||||
- Connect, saved-server list (JSON, DPAPI-encrypted passwords), TOFU identity dialog.
|
||||
- Channel tree (`TreeView`), user list, join (incl. password-protected channels).
|
||||
- Voice: mic start/stop, mute/deafen, VAD/PTT/**always-on** mode, **VAD sensitivity slider**
|
||||
(live threshold update via `vc_set_vad_threshold`), device picker, level meter.
|
||||
- Per-user gain/mute/NR tuning (`PerUserTuningDialog`).
|
||||
- Channel + private text chat. Activity log (screen-reader primary path).
|
||||
- Explicit `AccessibleName`/`AccessibleDescription` on every control; `&` mnemonics;
|
||||
`AutomationNotification` curated live announcements.
|
||||
- Focus-scoped PTT (documented limitation — no system-wide hook in v1).
|
||||
- Admin/moderation UI **out of scope** — needs server-side dispatch first (M5).
|
||||
|
||||
**macOS (Swift/AppKit) — in progress:**
|
||||
- Same feature set as Windows over the same C ABI (now stable and complete).
|
||||
- **Shared Swift core (`VoiceCatCore` package) ✓ complete 2026-06-18** — wraps all 38 C ABI
|
||||
functions; 6/6 XCTest smoke tests pass against a real server (connect → TOFU → auth →
|
||||
channels → moderation → admin CRUD → per-stream recv controls). See
|
||||
`clients/apple/README.md`.
|
||||
- **UI: AppKit** (not SwiftUI) — chosen for the most mature VoiceOver accessibility story
|
||||
(per-control `accessibilityLabel`/`accessibilityHelp`/`accessibilityRole`,
|
||||
`NSAccessibility.post(.announcement)` for live announcements). Same rationale as the
|
||||
Windows client's WinForms-over-WinUI-3 decision (see §2 below). macOS 14 (Sonoma)
|
||||
deployment target.
|
||||
- AVAudioSession not needed on macOS (CoreAudio via the core directly).
|
||||
|
||||
**iOS (.NET/UIKit) — replacement in progress 2026-09-19:**
|
||||
- Native C# UIKit app consuming `VoiceCat.Core`; UIKit was chosen over MAUI for direct audio
|
||||
lifecycle control and the strongest VoiceOver surface.
|
||||
- ~~AVAudioSession, mic permission, foreground voice.~~ ✓ Done — `IOSAudioRouter` drives
|
||||
all iOS audio routing (input ports, orientation/polar patterns, HFP/A2DP, Standard/Raw
|
||||
mic mode, stereo capture), `vc_audio_suspend`/`vc_audio_resume` for interruptions.
|
||||
- The small Swift ReplayKit broadcast extension remains and writes its versioned App Group
|
||||
PCM ring; the C# host drains it into `SCREEN_AUDIO`. See `broadcast-ring-format.md`.
|
||||
|
||||
**Exit:** non-technical user installs a client, saves a server, and joins.
|
||||
|
||||
### M5 — Moderation, polish, and beyond
|
||||
- Permissions/roles, kick/ban/server-mute, channel passwords UI. Windows WinForms UI complete
|
||||
(channel CRUD with full Opus config, user moderation, server account management); macOS/iOS
|
||||
Swift UI pending.
|
||||
- DRED toggle, audio-quality polish. (AEC and VAD/PTT already shipped in M2.)
|
||||
- **External PCM feed/tap API** (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) ✓ shipped
|
||||
(2026-06-20) — promotes `vc_test_inject_capture` to a public, stereo-capable API and adds
|
||||
a symmetric PCM sink. Enables ReplayKit (iOS), ScreenCaptureKit (macOS), bots, and custom
|
||||
clients. See architecture.md §4 "External PCM feed/tap" and protocol.md §8 for the
|
||||
no-protocol-change rationale. One new C++ ctest binary (`test_external_pcm`) covering 3
|
||||
sub-tests (`test_feed_pcm_round_trip`, `test_feed_pcm_stereo`, `test_pcm_sink`) — ctest
|
||||
23/23; Swift wrapper + 4 XCTest smoke tests; C# wrapper + 4 xUnit smoke tests.
|
||||
- **Then (post-v1, protocol already reserves space):** file transfer, E2EE option,
|
||||
CallKit/PushKit background voice, key-based identity, server-side text history,
|
||||
multi-node server.
|
||||
|
||||
## 2. Resolved decisions
|
||||
|
||||
Settled and reflected throughout the docs:
|
||||
|
||||
- **Media crypto:** exported-key + ChaCha20-Poly1305 AEAD from day one, **mandatory** — no
|
||||
DTLS, no plaintext mode. (security.md §2)
|
||||
- **TLS library / licensing:** **mbedTLS** (Apache-2.0) + **libsodium** (ISC). **No GPL/LGPL**
|
||||
anywhere; code is redistributable closed-source. wolfSSL is rejected. (tech-stack.md §5)
|
||||
- **iOS screen/system audio:** supported via a ReplayKit Broadcast Upload Extension
|
||||
(`.audioApp`); audio-only stays within the extension memory cap. (voice.md §9)
|
||||
- **Self-host UX:** zero-config, encrypted-by-default; Docker / single binary / source build.
|
||||
(deployment.md)
|
||||
- **DSP engine:** **webrtc-audio-processing (APM)** — AEC in from the start, plus NS/AGC/VAD.
|
||||
(voice.md §8)
|
||||
- **Input activation:** **VAD *and* PTT**, both modes client-configurable. (voice.md §11)
|
||||
- **Noise reduction is two-sided:** sender can denoise its mic, *and* each listener can apply
|
||||
NS to a **specific** other user, locally, with no protocol traffic. (voice.md §10)
|
||||
- **Accounts:** **admin-provisioned** (no self-serve registration) via `voicecat-admin` or the
|
||||
in-app admin interface. (security.md §4, protocol.md §3, deployment.md §3a)
|
||||
- **Text:** **ephemeral** — live relay, no server-side history in v1. (protocol.md §5)
|
||||
- **Connect UX:** pure direct-connect with a **client-side saved-server list** (no central
|
||||
directory). (deployment.md §3)
|
||||
- **Bitrate ceiling:** server-config `opus.limits.max_bitrate_bps`. (deployment.md §2)
|
||||
- **Name:** "VoiceCat" stays as the internal placeholder.
|
||||
- **DSP engine, superseded (2026-06-16):** the "webrtc-audio-processing (APM)" decision above
|
||||
(AEC + NS/AGC/VAD in one module) could not be carried out — it has no working Windows/MSVC
|
||||
build upstream (GCC-only Meson build, MinGW support unfinished, hard `abseil-cpp` dependency,
|
||||
Linux-tested only). v1 ships a lightweight, dependency-free energy/RMS VAD instead, behind
|
||||
the same `ApmProcessor` interface; there is **no AEC/NS/AGC implementation at all** yet. Real
|
||||
`webrtc-audio-processing` stays a tracked future swap (e.g. if/when a Linux build target
|
||||
exists). (voice.md §8, §11)
|
||||
- **Windows client UI framework (2026-06-17):** **WinForms** (.NET 10), not WinUI 3 or
|
||||
Avalonia. Reason: Win32 HWND controls have the most mature, predictable screen-reader
|
||||
(NVDA/JAWS/Narrator) support of any current .NET UI stack. WinUI 3's accessibility UIA
|
||||
tree has known rough edges on .NET 10; Avalonia's accessibility story is thinner still.
|
||||
This overrides the earlier WinUI/Avalonia mention in `docs/tech-stack.md §2` and
|
||||
`docs/architecture.md §4`. (clients/windows/)
|
||||
- **TOFU pins TLS leaf cert, not Ed25519 (2026-06-17):** the original design said to pin the
|
||||
server's declared Ed25519 identity fingerprint from `ServerHello`. This is circular — the
|
||||
Ed25519 key and the TLS cert are generated independently with no cryptographic binding, so
|
||||
accepting/rejecting based on a value sent *inside* the channel being trust-decided is
|
||||
meaningless. **Decision:** pin the TLS leaf certificate's own SHA-256 fingerprint, which is
|
||||
verifiable directly from the TLS handshake before any application data is trusted. The Ed25519
|
||||
value is still shown in the identity dialog for human-readable display only (informational).
|
||||
See `docs/security.md §1.1`. (core/src/crypto/tofu_store.*, vc_confirm_server_identity)
|
||||
- **PTT is focus-scoped in v1 (2026-06-17):** PTT hotkey capture uses `Form.KeyDown`/`KeyUp`
|
||||
(works only while the VoiceCat window has focus), not a system-wide `WH_KEYBOARD_LL` hook.
|
||||
Reason: a low-level keyboard hook requires escalated permissions, risks AV flagging, and is
|
||||
disproportionate complexity for a v1 client. Documented in the UI as a known limitation.
|
||||
Can be revisited for v2 if users request it. (clients/windows/VoiceCat.App/Forms/MainForm.cs)
|
||||
- **macOS client UI framework (2026-06-18):** **AppKit**, not SwiftUI. Reason: AppKit has the
|
||||
most mature, granular **VoiceOver** accessibility story on macOS — per-control
|
||||
`accessibilityLabel`/`accessibilityHelp`/`accessibilityRole`, `NSAccessibility.post(.announcement)`
|
||||
for curated live announcements, and decades of real-world screen-reader usage. This is the
|
||||
same rationale that drove the Windows client to WinForms over WinUI 3 (screen-reader
|
||||
support is the deciding factor). SwiftUI's VoiceOver support has improved but still has
|
||||
gaps in complex AppKit-bridged control surfaces (outline views, data-table column
|
||||
headers, live-region announcements). iOS stays SwiftUI — its control surface is narrower
|
||||
and SwiftUI's VoiceOver support is sufficient there. This overrides the earlier
|
||||
"SwiftUI for both macOS + iOS" mention in `docs/tech-stack.md §2` and `docs/architecture.md
|
||||
§4`. macOS 14 (Sonoma) deployment target. (clients/apple/)
|
||||
|
||||
## 3. Open questions
|
||||
|
||||
All initial open questions are resolved (§2). Two **second-order considerations** to keep in
|
||||
mind during implementation — not blockers:
|
||||
|
||||
- **APM in constrained contexts.** webrtc-audio-processing is a heavier build; confirm it
|
||||
static-links cleanly for the single-binary goal, and note the iOS **broadcast extension only
|
||||
does Opus encode + send** (no APM), so it stays under the ~50 MB cap. Listener-side per-user
|
||||
NS runs only in the full host app.
|
||||
- **Receive-side NR cost at scale.** A per-ssrc APM NS instance per flagged user adds CPU on
|
||||
busy channels; instantiate lazily (only for flagged streams) and cap concurrent instances.
|
||||
|
||||
## 4. What's intentionally deferred
|
||||
|
||||
To keep v1 focused (voice + text), these are designed-for but not built: file transfer,
|
||||
end-to-end encryption, key-based identities, multi-node/federated servers, mobile
|
||||
background VoIP push, and any server-side audio mixing/transcoding. The protocol's
|
||||
versioning + feature negotiation + reserved tag ranges (protocol.md §8) ensure each can be
|
||||
added without breaking deployed clients.
|
||||
These are not blockers for the current release.
|
||||
|
||||
+3
-3
@@ -53,7 +53,7 @@ future improvement. Until then, clients display both values but gate on the cert
|
||||
captures directional exporters during handshake completion. Its client requires an
|
||||
explicit leaf-fingerprint acceptance callback; PKI validation remains unimplemented.
|
||||
New managed server certificates include the Ed25519 public key in SAN URI
|
||||
`urn:voicecat:identity:ed25519:<lowercase-public-key-hex>`. Existing C++ credentials
|
||||
`urn:voicecat:identity:ed25519:<lowercase-public-key-hex>`. Existing pre-rewrite credentials
|
||||
are imported unchanged. Verifying that URI against the declared ServerHello identity
|
||||
is still deferred to the managed session layer; leaf-certificate TOFU remains the
|
||||
trust gate. Missing members of a persisted credential set cause startup rejection
|
||||
@@ -82,7 +82,7 @@ mandatory from the first build. This was chosen over DTLS after weighing two fin
|
||||
`0x01` for server→client. Each export yields a 32-byte directional media key.
|
||||
No second handshake, no certificates on the UDP path — the UDP channel inherits the
|
||||
authenticated, MITM-resistant TLS session's trust.
|
||||
2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (libsodium in C++;
|
||||
2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (managed platform crypto;
|
||||
platform cryptography with a BouncyCastle fallback in .NET).
|
||||
3. The full 20-byte header is AEAD **associated data**. The server authenticates/decrypts
|
||||
inbound media and reseals for each recipient, replacing the sequence with that
|
||||
@@ -124,7 +124,7 @@ Binding works as:
|
||||
accepts the first endpoint only; further bootstrap packets cannot replace it.
|
||||
Endpoint changes require a new authenticated session. The token remains available
|
||||
for TLS confirmation but cannot establish a second binding. Session removal retires
|
||||
its endpoint, token and directional keys. The C++ oracle currently permits rebinding
|
||||
its endpoint, token and directional keys. Unsupported old releases permitted rebinding
|
||||
with the same token; this differs in policy, not in the packet format.
|
||||
4. Thereafter, frames are accepted only on that bound tuple; ssrcs are checked against the
|
||||
streams the session announced. Source-address spoofing can't hijack a session because the
|
||||
|
||||
+31
-101
@@ -1,113 +1,43 @@
|
||||
# Tech Stack & Dependencies
|
||||
# Technology stack
|
||||
|
||||
## Initial .NET rewrite
|
||||
## Managed runtime
|
||||
|
||||
The parallel rewrite under `dotnet/` targets .NET 10. Its initial dependencies are
|
||||
Google.Protobuf 3.36.1 (BSD-3-Clause), build-only Grpc.Tools 2.83.0 (Apache-2.0), and
|
||||
BouncyCastle.Cryptography 2.6.2 (MIT). Media AEAD prefers the platform implementation;
|
||||
BouncyCastle provides the managed fallback and is the planned TLS/exporter provider.
|
||||
No managed server or audio replacement is shipped yet.
|
||||
VoiceCat targets .NET 10. Shared libraries use plain `net10.0`; WinForms, AppKit, and UIKit are
|
||||
leaf platform projects.
|
||||
|
||||
Project files and NuGet lock files pin versions. `dotnet/check-licenses.ps1` checks
|
||||
all restored direct/transitive packages against a permissive license allowlist in CI;
|
||||
unknown or copyleft licenses fail. See `dotnet/README.md` for build and test commands.
|
||||
The existing implementation's dependency choices follow below.
|
||||
| Area | Technology | Notes |
|
||||
|---|---|---|
|
||||
| Control schema | Protocol Buffers / Google.Protobuf | `proto/voicecat.proto` is authoritative |
|
||||
| TLS 1.3/exporters | BouncyCastle.Cryptography | Managed TLS is required for exporter-derived media keys |
|
||||
| Media AEAD | System.Security.Cryptography ChaCha20Poly1305 | Managed fallback is behavior-tested |
|
||||
| Passwords | Konscious Argon2id | Bounded parameters, PHC strings |
|
||||
| Persistence | Microsoft.Data.Sqlite | Server-local SQLite with explicit migrations |
|
||||
| Tests | xUnit | Unit, socket integration, allocation, CLI, and production-package checks |
|
||||
|
||||
Concrete library choices with versions and rationale. Everything in the **core** is C++
|
||||
(C++20). UIs are Swift and C#. Build is CMake + vcpkg.
|
||||
NuGet packages and their lock files are committed. `dotnet/check-licenses.ps1` enforces the
|
||||
permissive-license policy.
|
||||
|
||||
## 1. Core library (`libvoicecat`, C++20)
|
||||
## Native media
|
||||
|
||||
| Concern | Choice | Version (as of 2026-06) | Why / notes |
|
||||
|---------|--------|-------------------------|-------------|
|
||||
| Sockets, timers, async | **Standalone Asio** | 1.30.x | Header-only, no Boost dependency, cross-platform TCP+UDP+timers, one reactor for client and server. (Boost.Asio is interchangeable if we already pull Boost.) |
|
||||
| TLS 1.3 (control) | **mbedTLS 3.6 LTS** | 3.6.x (LTS ≥ Mar 2027) | **Apache-2.0** (permissive — clean for eventual closed-source distribution). TLS 1.3 client+server, plus `mbedtls_ssl_export_keying_material()` to seed the media AEAD. **Static-links cleanly → single self-host binary.** OpenSSL 3.x (Apache-2.0) is an interchangeable alternative. No DTLS/wolfSSL (GPL) — see [security.md](security.md) §2. |
|
||||
| Crypto primitives + password hashing + media AEAD | **libsodium** | 1.0.20 | **ISC.** Argon2id (`crypto_pwhash`), ChaCha20-Poly1305 (per-frame media encryption), Ed25519 server identity, X25519, CSPRNG. Audited, hard to misuse. |
|
||||
| Audio codec | **libopus** | **1.6** (2025-12) | Per-channel mono/stereo, bitrate, frame size; in-band FEC, DTX, PLC, and optional **DRED** deep redundancy; Opus HD/96 kHz available. The whole reason the design is codec-flexible. |
|
||||
| Audio capture/playback | **miniaudio** | 0.11.x | Single-header, public-domain, backends for **WASAPI / CoreAudio / ALSA / PulseAudio**. One real-time abstraction across all desktop targets; keeps the RT path identical. |
|
||||
| Audio DSP — noise suppression (NS) | **RNNoise** (vendored, `third_party/rnnoise/`) | xiph @ `70f1d25` (2026-06) | **BSD-3-Clause + CC0-1.0** (model). Hybrid DSP/RNN speech denoiser, mono/48 kHz, ~60× real time, no deps. The shipped NS backend behind `ApmProcessor` (`RnnoiseProcessor`), used by both send-side mic NR (`vc_set_input_noise_reduction`) and per-listener receive NR (`vc_set_remote_stream`). Vendored (not vcpkg) because the vcpkg port is `!windows !arm`. See [voice.md](voice.md) §10. |
|
||||
| Audio DSP — AEC/AGC/VAD | **webrtc-audio-processing** (APM) — **planned, not built** | 1.x (standalone APM) | **BSD-3**, but has no working Windows/MSVC build upstream (GCC-only Meson, MinGW support unfinished, hard `abseil-cpp` dep — see roadmap.md §2). v1 ships a lightweight, dependency-free energy/RMS VAD (`EnergyVadProcessor`, `core/src/audio/apm_processor.cpp`); **NS now exists via RNNoise (row above)**, but there is still **no AEC or AGC** (iOS gets AEC/NS/AGC natively from VPIO). Real APM stays a tracked future swap behind the same `ApmProcessor` interface. |
|
||||
| Resampling + jitter ref | **speexdsp** | 1.2.x | BSD. Resampler for non-48 kHz devices; lightweight jitter-buffer reference. (No longer the NS/AGC/VAD source — APM replaces it.) |
|
||||
| Control serialization | **Protocol Buffers** (protobuf-lite) | 5.x (proto3) | Codegen for C++/C#/Swift; additive, forward/backward compatible; `oneof` envelopes. `nanopb` is a fallback if footprint matters. |
|
||||
| Server persistence | **SQLite** | 3.4x | Accounts, channels, bans, config. Zero-admin, single file, ships everywhere. |
|
||||
| Logging | **spdlog** | 1.14.x | Fast, async-capable; off the RT path. |
|
||||
`native/media` builds one narrow library named `voicecat_media`:
|
||||
|
||||
Resampling note: Opus runs internally at 48 kHz; miniaudio can deliver 48 kHz directly, so
|
||||
explicit resampling (speexdsp/libsamplerate) is only needed when a device can't do 48 kHz.
|
||||
- Opus 1.5.2, checksum-pinned and built with DRED/Deep PLC support.
|
||||
- RNNoise, vendored under `native/rnnoise` at the repository-pinned source/model revision.
|
||||
|
||||
## 2. Clients
|
||||
The shim contains codec and denoiser entry points only. Managed code owns networking, crypto,
|
||||
jitter, mixing, state, and lifecycle. CMake is retained solely for this boundary and its Apple
|
||||
static archives.
|
||||
|
||||
### Apple clients — .NET native UI with a Swift ReplayKit exception
|
||||
## Platform clients
|
||||
|
||||
| Concern | Choice | Notes |
|
||||
|---------|--------|-------|
|
||||
| Language | **Swift 5.9+** | Direct **Swift↔C interop** — the C ABI (`voicecat.h`) is imported as a Clang module (`import VoiceCatC`) via a module map in the XCFramework headers; no manual struct/function redeclaration (unlike the C# P/Invoke layer). A Swift wrapper (`VoiceCatCore` package) provides Swift-idiomatic types on top. |
|
||||
| UI — macOS | **AppKit** | Chosen over SwiftUI for the most mature, granular **VoiceOver** accessibility story (per-control `accessibilityLabel`/`accessibilityHelp`/`accessibilityRole`, `NSAccessibility.post(.announcement)` for live announcements) — the same rationale that drove the Windows client to WinForms over WinUI 3 for screen-reader (NVDA/JAWS/Narrator) UIA support (resolved decision in `docs/roadmap.md`). macOS 14 (Sonoma) deployment target. |
|
||||
| UI — iOS | **C# / UIKit (`net10.0-ios`)** | Native UIKit keeps direct lifecycle/audio control and predictable VoiceOver semantics. MAUI was rejected because a cross-platform abstraction provides no benefit for this platform-specific client. iOS 18.0 deployment target. |
|
||||
| Shared core | **VoiceCatCore** Swift Package | One Swift library wrapping the C ABI, consumed by both the macOS AppKit app and the iOS SwiftUI app. Mirrors the C# `VoiceCat.Interop` layer. Events delivered on `@MainActor` via a coalesced `DispatchQueue.main` drain (the Swift analog of C#'s `Channel<VoiceCatEvent>` + 30ms WinForms Timer pump). |
|
||||
| Audio session (iOS) | **AVAudioSession + AVAudioEngine from C#** | The UIKit host owns category, permission, interruptions, routes, VPIO capture and planar playback. Converted PCM crosses bounded managed rings; callbacks allocate no managed objects, lock, or block. |
|
||||
| Packaging | .NET Apple workloads + Xcode appex | The managed iOS host statically links merged Opus/RNNoise archives. MSBuild invokes Xcode to build/embed the small Swift ReplayKit extension, which communicates through the versioned App Group PCM ring. |
|
||||
| Future | CallKit / PushKit | For background VoIP + incoming-call UX on iOS. Post-v1. |
|
||||
- Windows: .NET WinForms for mature UI Automation and screen-reader behavior.
|
||||
- macOS: .NET AppKit for direct accessibility and Core Audio/ScreenCaptureKit control.
|
||||
- iOS: .NET UIKit for direct VoiceOver and AVAudioSession lifecycle control.
|
||||
- iOS broadcast upload: Swift/ReplayKit under `native/apple/broadcast`; it writes PCM to the
|
||||
frozen App Group ring and deliberately does not host .NET or the protocol stack.
|
||||
|
||||
### Windows — C# (shipped in M4, 2026-06-17)
|
||||
## Licensing
|
||||
|
||||
| Concern | Choice | Notes |
|
||||
|---------|--------|-------|
|
||||
| Runtime | **.NET 10 LTS** (`net10.0-windows`) | In-service until 2028. |
|
||||
| Interop | **`[LibraryImport]`** (source-gen P/Invoke) over the C ABI | `[UnmanagedCallersOnly]` static methods for `on_event`/`on_level`; `VoiceCatClientHandle : SafeHandle` owns the `vc_client*` lifetime. |
|
||||
| Event delivery | **`System.Threading.Channels.Channel<VoiceCatEvent>`** | Single-writer/reader, unbounded; drained by a 30ms `System.Windows.Forms.Timer` on the UI thread. Simpler than a message-only HWND with no meaningful latency cost. |
|
||||
| UI | **WinForms** | Chosen over WinUI 3 / Avalonia for mature, predictable NVDA/JAWS/Narrator UIA support. Win32 HWND controls have the most complete accessibility story on .NET 10 today. See roadmap.md §2. |
|
||||
| Persistence | **`System.Text.Json`** (`servers.json`), **`ProtectedData`** (DPAPI) | Saved-server list in `%AppData%\VoiceCat\`; passwords DPAPI-encrypted at rest, opt-in, `CurrentUser` scope. |
|
||||
| Audio | Handled by the core (miniaudio/WASAPI) | C# only drives device selection + meters. |
|
||||
|
||||
## 3. Server (`voicecat-server`)
|
||||
|
||||
- Pure C++ linking the core; **no GUI**. Runs on **Linux** (primary), **macOS**, **Windows**.
|
||||
- Config via a `server.toml` (`allow_guests`, ports, channel defaults, Opus policy, TLS cert
|
||||
paths or auto-self-signed + Ed25519 identity, Argon2id cost params, rate limits).
|
||||
- SQLite for state. Single process for v1; interfaces drawn so a multi-node build is
|
||||
*possible* later but explicitly out of scope.
|
||||
- Packaging: static-ish binary per OS; systemd unit + Docker image for Linux.
|
||||
|
||||
## 4. Build & tooling
|
||||
|
||||
| Tool | Use |
|
||||
|------|-----|
|
||||
| **CMake** (3.25+) | One build graph for core + server + test CLI; UI projects consume the built core. |
|
||||
| **vcpkg** (manifest mode) | Pin C/C++ deps (opus, libsodium, mbedtls, protobuf, sqlite3, spdlog, asio, miniaudio — see `vcpkg.json`). `webrtc-audio-processing`/`speexdsp` are **not** in the manifest: no working vcpkg port / no working Windows/MSVC build exists upstream for the former; the latter was never actually wired up (the lightweight VAD needs no resampler). Reproducible across OSes. Triplet auto-resolved from the host platform by [`cmake/voicecat-toolchain.cmake`](../cmake/voicecat-toolchain.cmake) — `x64-mingw-static` on Windows, `x64-linux` on Linux, `arm64-osx` on Apple Silicon. Apple platform scaffolding presets (`apple-dev`/`apple-ios`/`apple-ios-sim`) produce static `libvoicecat.a` slices for XCFramework consumption. vcpkg itself is bundled as a git submodule at `vcpkg/`, pinned to `vcpkg.json`'s `builtin-baseline` commit — `VCPKG_ROOT` overrides it for an external checkout. See [building.md §2](building.md#2-one-time-setup-for-the-real-deps-presets). |
|
||||
| **protoc** | Generate C++/C#/Swift from `core/proto/*.proto` (single source of truth). |
|
||||
| **clang-format / clang-tidy** | Style + static analysis on the core. |
|
||||
| **CTest + a fuzz target** | Unit/integration tests; fuzz the frame parser and protobuf boundary (security-sensitive). |
|
||||
| **GitHub Actions** (or similar) | Matrix CI: Linux/macOS/Windows core+server; Xcode build for Apple; `dotnet` build for Windows. |
|
||||
|
||||
## 5. Licensing — permissive only (hard rule)
|
||||
|
||||
The code will eventually be distributed in **closed-source** form, so **no GPL/LGPL
|
||||
dependencies are permitted.** Every dependency below is BSD / MIT / ISC / Apache-2.0 /
|
||||
public-domain:
|
||||
|
||||
- **mbedTLS** — Apache-2.0 ✅ · **libsodium** — ISC ✅ · **libopus** — BSD ✅ ·
|
||||
**miniaudio** — public domain / MIT-0 ✅ · **protobuf** — BSD ✅ ·
|
||||
**SQLite** — public domain ✅ · **Asio** (standalone) — Boost ✅ · **spdlog** — MIT ✅ ·
|
||||
**RNNoise** — BSD-3-Clause (code) + CC0-1.0 (model) ✅, vendored in `third_party/rnnoise/`
|
||||
(not vcpkg — the port is `!windows !arm`; see [`third_party/README.md`](../third_party/README.md)).
|
||||
**webrtc-audio-processing** would be BSD-3 ✅ if/when it's actually built in (see §1) —
|
||||
not a live dependency today, so not part of the resolved vcpkg graph the license scanner
|
||||
below checks.
|
||||
- **Explicitly rejected:** **wolfSSL** (GPLv2/commercial) and any DTLS stack that would drag
|
||||
in copyleft. The exported-keys + AEAD media design (security.md §2) removes the need for
|
||||
one entirely.
|
||||
- CI runs a license scanner over the resolved vcpkg graph and **fails the build on any
|
||||
GPL/LGPL transitive dependency**, so this rule can't silently regress.
|
||||
|
||||
## 6. Why not the obvious alternatives
|
||||
|
||||
- **WebRTC** — explicitly rejected: ICE/SDP/TURN complexity, huge dependency, opaque. We
|
||||
want plain TCP+UDP we fully control.
|
||||
- **QUIC** — capable (reliable streams + datagrams + TLS 1.3 in one), but heavier and drifts
|
||||
toward the complexity we're avoiding. Revisit only if NAT traversal/multiplexing pain
|
||||
appears.
|
||||
- **gRPC** for control — pulls HTTP/2 and a lot of surface for what is a simple framed
|
||||
message stream over TLS. Plain protobuf-over-framed-TLS is enough.
|
||||
- **A Rust core** — viable and memory-safe, but the user prefers C++ and the Swift/C#
|
||||
binding story is marginally simpler from C++ (Swift can even consume C++ directly).
|
||||
GPL and LGPL dependencies are forbidden. Current production dependencies are permissively
|
||||
licensed; RNNoise code is BSD-3-Clause and its model data is CC0. Preserve license notices in
|
||||
published native artifacts.
|
||||
|
||||
+4
-4
@@ -324,7 +324,7 @@ independent.
|
||||
chose not to denoise his mic, Sam can locally suppress Alex's background noise without
|
||||
affecting how anyone else hears Alex.
|
||||
|
||||
**Backend: RNNoise** (vendored in [`third_party/rnnoise/`](../third_party/rnnoise), BSD-3 + CC0).
|
||||
**Backend: RNNoise** (vendored in [`native/rnnoise/`](../native/rnnoise), BSD-3 + CC0).
|
||||
The original plan was WebRTC's APM, but `webrtc-audio-processing` has no working Windows/MSVC
|
||||
build (see §8). RNNoise is a small, dependency-free C library — a hybrid DSP/RNN speech denoiser
|
||||
that runs ~60× faster than real time. Both NR paths share one `ApmProcessor` implementation
|
||||
@@ -437,14 +437,14 @@ user (exactly like macOS/Windows), and no credentials are ever persisted to disk
|
||||
|
||||
- The user starts a broadcast from Control Center's screen-record button; we surface it via
|
||||
`RPSystemBroadcastPickerView` from inside the app (`VoiceControlsView`) for one-tap start.
|
||||
- The **Broadcast Upload Extension** (`clients/apple/iOS/VoiceCatBroadcast/SampleHandler.swift`)
|
||||
- The **Broadcast Upload Extension** (`native/apple/broadcast/SampleHandler.swift`)
|
||||
receives `RPSampleBufferType.audioApp` (system/app audio), `.audioMic`, and `.video`. We
|
||||
consume **`.audioApp`** only and drop video + mic — video is what blows the **~50 MB**
|
||||
extension memory budget, so an audio-only consumer stays comfortably inside it. The extension
|
||||
does **not** link `libvoicecat`.
|
||||
does **not** link the managed host or native media shim.
|
||||
- The extension converts each chunk to the core's canonical format (48 kHz int16 stereo, via
|
||||
`AVAudioConverter`) and writes it into a lock-free single-producer/single-consumer ring in a
|
||||
shared **App Group** mmap'd file (`clients/apple/iOS/Shared/BroadcastAudioRing.swift`). It
|
||||
shared **App Group** mmap'd file (`native/apple/broadcast/BroadcastAudioRing.swift`). It
|
||||
posts Darwin notifications on start/stop so the host reacts promptly.
|
||||
- The **host app** owns the stream: its `BroadcastAudioPump` announces the `SCREEN_AUDIO`
|
||||
stream over the control channel (`StreamAnnounce`), drains the ring, and calls
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"powershell": {
|
||||
"version": "7.6.6",
|
||||
"commands": [
|
||||
"pwsh"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-166
@@ -1,182 +1,62 @@
|
||||
# VoiceCat .NET rewrite
|
||||
# VoiceCat .NET implementation
|
||||
|
||||
The .NET 10 implementation includes protocol, TLS/TOFU and media crypto, the control and
|
||||
UDP server, client state, real-time audio, a console client and the Windows application.
|
||||
The existing C++ implementation remains the conformance oracle while Apple clients move.
|
||||
This is the supported VoiceCat implementation. It contains the protocol, TLS and media
|
||||
cryptography, server, client state, codec/DSP bindings, audio engine, and headless CLI.
|
||||
|
||||
Codec/DSP wrappers now cover Opus, DRED recovery, RNNoise, and energy VAD. Build
|
||||
the desktop native library before running their tests (CMake and a C compiler required):
|
||||
## Build and test
|
||||
|
||||
Stage the small native Opus/RNNoise library, then build the managed solution:
|
||||
|
||||
```powershell
|
||||
./dotnet/build-native.ps1
|
||||
```
|
||||
|
||||
The script downloads upstream Opus 1.5.2 with a pinned SHA-256, builds DRED-enabled
|
||||
Opus and the existing vendored RNNoise model, and stages `voicecat_media` plus license
|
||||
notices under `dotnet/artifacts/native/`. It builds independently of the C++ core and
|
||||
vcpkg. On Windows, Visual Studio's C++ workload works with the default generator;
|
||||
for this repository's MinGW toolchain use:
|
||||
|
||||
```powershell
|
||||
./dotnet/build-native.ps1 -Generator Ninja -CCompiler C:/tools/msys64/ucrt64/bin/cc.exe
|
||||
```
|
||||
|
||||
Linux/macOS can run the same script with PowerShell, or use CMake directly:
|
||||
|
||||
```sh
|
||||
cmake -S dotnet/native -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build dotnet/artifacts/native-build --target voicecat_media --parallel 2
|
||||
cmake --install dotnet/artifacts/native-build --component DotnetMedia --prefix dotnet/artifacts/native
|
||||
```
|
||||
|
||||
MSBuild copies the staged library into managed build/publish output for the selected
|
||||
RID. Override `VoiceCatNativeRid` or `VoiceCatNativeDirectory` for explicit staging;
|
||||
`RuntimeIdentifier` takes priority over the SDK's host RID. Cross-compilation is not
|
||||
automatic. iOS static linking and audio-device shims belong to later client phases.
|
||||
Native codec/DSP tests require this library; they do not silently skip.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
./dotnet/check-licenses.ps1
|
||||
```
|
||||
|
||||
Run the managed console client interactively:
|
||||
The native source lives in `native/media` and `native/rnnoise`; build output is staged under
|
||||
`dotnet/artifacts/native`. The shim exposes only fixed Opus/DRED and RNNoise entry points. It
|
||||
does not contain protocol, networking, cryptography, client state, or server behavior.
|
||||
|
||||
For an explicit native build:
|
||||
|
||||
```bash
|
||||
cmake -S native/media -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build dotnet/artifacts/native-build --target voicecat_media --parallel 2
|
||||
cmake --install dotnet/artifacts/native-build --component DotnetMedia \
|
||||
--prefix dotnet/artifacts/native
|
||||
```
|
||||
|
||||
## Projects
|
||||
|
||||
- `VoiceCat.Protocol` — generated protobuf types and bounded control framing.
|
||||
- `VoiceCat.Crypto` — BouncyCastle TLS 1.3/exporters, TOFU, identity, AEAD, replay protection,
|
||||
and Argon2id.
|
||||
- `VoiceCat.Codec` / `VoiceCat.Dsp` — managed owners of the narrow native media ABI.
|
||||
- `VoiceCat.Audio` — jitter, loss recovery, mixing, input activation, and PCM rings.
|
||||
- `VoiceCat.Core` — managed client connection and session state.
|
||||
- `VoiceCat.Server` — TLS control, encrypted UDP relay, SQLite state, administration, and CLI.
|
||||
- `VoiceCat.Cli` — supported interactive and deterministic headless client.
|
||||
- `VoiceCat.Tests` — managed unit, integration, allocation, and end-to-end behavior tests.
|
||||
|
||||
`proto/voicecat.proto` is the only protobuf schema. Generated C# is build output.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
dotnet run --project dotnet/src/VoiceCat.Cli -- --host 127.0.0.1 --port 8384 --nickname Alice --trust-first
|
||||
dotnet run --project dotnet/src/VoiceCat.Server -- --data-dir ./voicecat-data
|
||||
dotnet run --project dotnet/src/VoiceCat.Cli -- \
|
||||
--host 127.0.0.1 --port 8384 --nickname Alice --trust-first
|
||||
```
|
||||
|
||||
Plain input sends channel text; `/join ID` changes channel and `/quit` exits. Headless
|
||||
conformance options include `--voice`, `--send-text`, `--expect-text`, `--expect-voice`,
|
||||
`--start-delay-ms` and `--timeout-seconds`; `--help` lists the complete syntax.
|
||||
Use `--help` on either executable for current options. Server publishing is handled by
|
||||
`dotnet/publish-server.ps1` and the platform packaging files under `packaging/`.
|
||||
|
||||
Dependencies are pinned in project files and lock files. Generated protobuf is build
|
||||
output; the schema remains `core/proto/voicecat.proto`. Production dependencies are
|
||||
Google.Protobuf (BSD-3-Clause), BouncyCastle.Cryptography (MIT), and the build-only
|
||||
Grpc.Tools (Apache-2.0). No GPL/LGPL dependencies are permitted.
|
||||
## Compatibility policy
|
||||
|
||||
## C# conventions
|
||||
|
||||
Use file-scoped namespaces, standard .NET naming, immutable values where useful, and
|
||||
spans for binary data. Invalid arguments throw; invalid network packets use parsing
|
||||
results or protocol exceptions. Async APIs accept cancellation tokens.
|
||||
|
||||
Comments explain constraints that cannot be made clear in code. Avoid banners,
|
||||
implementation history, and narration. Keep durable design explanations in `docs/`.
|
||||
|
||||
## Regenerating C++ fixtures
|
||||
|
||||
The optional oracle target calls the existing C++ protobuf, header serializer, and
|
||||
libsodium media implementation. From the root, with the development dependencies:
|
||||
|
||||
```powershell
|
||||
cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON
|
||||
cmake --build --preset dev --target voicecat-dotnet-oracle
|
||||
New-Item -ItemType Directory -Force dotnet/tests/VoiceCat.Tests/Fixtures
|
||||
./build/dev/bin/voicecat-dotnet-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
|
||||
git diff -- dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
|
||||
```
|
||||
|
||||
On Linux/macOS, omit `.exe` and create the directory with `mkdir -p`.
|
||||
The oracle writes deterministic JSON directly, avoiding shell output encoding.
|
||||
Fixtures contain a framed ClientHello and media packets at counters 0, 1, 65535,
|
||||
and 65536. Keys contain bytes 0–31; payload bytes count upward from zero. The
|
||||
20-byte header has type 1, marker flag, codec 0, SSRC `0xcafebabe`, timestamp 960.
|
||||
Both managed crypto backends must match these bytes.
|
||||
|
||||
The DSP oracle calls the existing C++ `ApmProcessor` with 200 deterministic noise
|
||||
frames and records the final 960 samples. Regenerate its fixture with:
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-dsp-oracle
|
||||
./build/dev/bin/voicecat-dotnet-dsp-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json
|
||||
```
|
||||
|
||||
The managed test allows a one-unit PCM difference for floating-point rounding.
|
||||
|
||||
## TLS interoperability
|
||||
|
||||
The optional TLS oracle uses the existing mbedTLS context and libsodium media crypto.
|
||||
The test authenticates an encrypted challenge in both directions, proving exporter
|
||||
compatibility without sending raw keys. It also loads the C++ server's credential files.
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-tls-oracle
|
||||
$env:VOICECAT_TLS_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-tls-oracle.exe).Path
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
On Linux/macOS, set `VOICECAT_TLS_ORACLE` to the absolute executable path without
|
||||
`.exe`. Without that variable, only this native interoperability test is skipped;
|
||||
managed TLS loopback, rejection, persistence, and wire tests still run. CI's C++
|
||||
conformance job requires the native test. See `docs/api-dotnet.md` for ownership
|
||||
and certificate acceptance requirements.
|
||||
|
||||
## Managed server checkpoint
|
||||
|
||||
Run the TLS control server on loopback (optional arguments: data directory, TCP port):
|
||||
|
||||
```powershell
|
||||
dotnet run --project dotnet/src/VoiceCat.Server -c Release -- ./voicecat-data 7443
|
||||
./build/dev/bin/vccli.exe --host 127.0.0.1 --port 7443 --nick Guest --text "hello"
|
||||
```
|
||||
|
||||
It creates or imports `server_identity.key`, `server.crt`, `server.key`, and
|
||||
`voicecat.db`. An empty channel table gets Lobby and Music Room; existing channels
|
||||
are preserved. Guests are enabled by the CLI; hosting `VoiceServer` directly can
|
||||
disable them. Existing accounts authenticate without resetting passwords. Account
|
||||
creation is currently available through `AccountStore`; bootstrap/admin CLI and
|
||||
wire administration are pending.
|
||||
|
||||
Tests cover real TLS sockets, authentication retries, snapshots, channel moves,
|
||||
text routing, sender attribution, ping, and disconnect events. Enable native checks:
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-password-oracle voicecat-dotnet-database-oracle vccli
|
||||
$env:VOICECAT_DATABASE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-database-oracle.exe).Path
|
||||
$env:VOICECAT_VCCLI = (Resolve-Path build/dev/bin/vccli.exe).Path
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
The database oracle creates an account/channel using the shipped C++ database code;
|
||||
managed code imports and authenticates it, then C++ authenticates a managed-created
|
||||
account. CI also regenerates the libsodium password fixture. Native checks require
|
||||
the optional `VOICECAT_BUILD_DOTNET_ORACLE=ON` configure flag and a real-deps build.
|
||||
|
||||
The server also advertises UDP on the TCP port number, supports voice subscription
|
||||
and stream signaling, and reseals encoded audio for subscribers in the same channel.
|
||||
UDP binding fixes the first endpoint for the session; reconnect after endpoint changes.
|
||||
Protected joins, administration, moderation and production configuration remain
|
||||
before Phase 4 completion. The server's media-aware reaper defaults to 45 seconds
|
||||
of inactivity with a 15-second sweep. Parsed control envelopes, valid encrypted
|
||||
voice and keepalives from bound endpoints refresh activity; invalid media does not.
|
||||
`VoiceServerOptions` configures timeouts and capacity; zero idle timeout disables
|
||||
reaping. The constructor overload accepts `TimeProvider` for deterministic expiry tests.
|
||||
|
||||
Enable deterministic native voice interoperability (no audio hardware required):
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-voice-oracle
|
||||
$env:VOICECAT_VOICE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-voice-oracle.exe).Path
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
Two existing C++ clients authenticate, join Lobby or Music Room, publish three
|
||||
concurrent streams, feed PCM, and verify decoded energy and metadata in both directions.
|
||||
The native clients use external capture/playback to avoid device dependencies in CI.
|
||||
`MediaFanoutTests` separately verifies 50-subscriber routing/resealing without managed
|
||||
allocations after warm-up and reports throughput; socket scheduling is excluded.
|
||||
The transport load test delivers all 2,500 recipient packets from a paced 50 pps sender.
|
||||
Native `vccli --test-tone-ms 4000` runs finite external capture/playback, feeds a tone,
|
||||
and fails without decoded remote audio. Tests start two CLI processes in mono/stereo
|
||||
channels and also verify channel text. Normal `--voice` now explicitly subscribes before
|
||||
announcing its microphone stream. No C ABI or wire changes were needed.
|
||||
|
||||
The managed CLI also accepts `--test-tone-seconds N` (maximum 3600) for a finite, accurately
|
||||
paced peer used during device checks. Use a channel with Opus DTX disabled, such as the seeded
|
||||
Music Room: a steady sine is intentionally classified as non-speech by DTX and becomes comfort
|
||||
noise after its hangover period in Lobby. The finite peer prints received voice energy and exits
|
||||
normally when its requested duration completes.
|
||||
The managed implementation is the source of truth. Frozen vectors under
|
||||
`tests/VoiceCat.Tests/Fixtures` protect concrete wire, Argon2id, and RNNoise behavior, but the
|
||||
repository no longer builds or tests against the retired C++ implementation. Protocol or
|
||||
persistence changes must be versioned when current supported releases need migration; they do
|
||||
not need to retain compatibility with unsupported pre-rewrite releases.
|
||||
|
||||
@@ -10,7 +10,7 @@ build_one() {
|
||||
local compiler
|
||||
compiler=$(xcrun --sdk "$sdk" --find clang)
|
||||
local build_dir="$script_dir/artifacts/native-build-$rid-cmake"
|
||||
cmake -S "$script_dir/native" -B "$build_dir" \
|
||||
cmake -S "$script_dir/../native/media" -B "$build_dir" \
|
||||
-DCMAKE_SYSTEM_NAME=iOS \
|
||||
-DCMAKE_C_COMPILER="$compiler" \
|
||||
-DCMAKE_OSX_SYSROOT="$sdk_path" \
|
||||
|
||||
@@ -6,7 +6,7 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$configure = @('-S', "$PSScriptRoot/native", '-B', $BuildDirectory,
|
||||
$configure = @('-S', "$PSScriptRoot/../native/media", '-B', $BuildDirectory,
|
||||
'-DCMAKE_BUILD_TYPE=Release', "-DVOICECAT_DOTNET_RID=$RuntimeIdentifier")
|
||||
if ($Generator) { $configure += @('-G', $Generator) }
|
||||
if ($CCompiler) { $configure += "-DCMAKE_C_COMPILER=$CCompiler" }
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$ExpectedPath,
|
||||
[Parameter(Mandatory)][string]$ActualPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expected = (Get-Content -Raw -LiteralPath $ExpectedPath | ConvertFrom-Json).samples
|
||||
$actual = (Get-Content -Raw -LiteralPath $ActualPath | ConvertFrom-Json).samples
|
||||
if ($expected.Count -ne 960 -or $actual.Count -ne $expected.Count) { throw 'DSP fixture sample counts differ.' }
|
||||
for ($i = 0; $i -lt $expected.Count; $i++) {
|
||||
if ([Math]::Abs($expected[$i] - $actual[$i]) -gt 1) { throw "DSP fixture differs at sample $i." }
|
||||
}
|
||||
Write-Output 'C++ DSP fixture matches within one PCM unit.'
|
||||
@@ -1,29 +0,0 @@
|
||||
add_executable(voicecat-dotnet-oracle main.cpp)
|
||||
target_link_libraries(voicecat-dotnet-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-oracle PRIVATE cxx_std_20)
|
||||
|
||||
add_executable(voicecat-dotnet-tls-oracle tls.cpp)
|
||||
target_link_libraries(voicecat-dotnet-tls-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-tls-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-tls-oracle PRIVATE cxx_std_20)
|
||||
|
||||
add_executable(voicecat-dotnet-voice-oracle voice.cpp)
|
||||
target_link_libraries(voicecat-dotnet-voice-oracle PRIVATE voicecat::voicecat)
|
||||
target_compile_features(voicecat-dotnet-voice-oracle PRIVATE cxx_std_20)
|
||||
|
||||
add_executable(voicecat-dotnet-dsp-oracle dsp.cpp)
|
||||
target_link_libraries(voicecat-dotnet-dsp-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-dsp-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-dsp-oracle PRIVATE cxx_std_20)
|
||||
|
||||
find_package(unofficial-sodium CONFIG REQUIRED)
|
||||
add_executable(voicecat-dotnet-password-oracle passwords.cpp)
|
||||
target_link_libraries(voicecat-dotnet-password-oracle PRIVATE unofficial-sodium::sodium)
|
||||
target_compile_features(voicecat-dotnet-password-oracle PRIVATE cxx_std_20)
|
||||
|
||||
if(VOICECAT_BUILD_SERVER)
|
||||
add_executable(voicecat-dotnet-database-oracle database.cpp)
|
||||
target_link_libraries(voicecat-dotnet-database-oracle PRIVATE voicecat::server)
|
||||
target_compile_features(voicecat-dotnet-database-oracle PRIVATE cxx_std_20)
|
||||
endif()
|
||||
@@ -1,41 +0,0 @@
|
||||
#include "db.h"
|
||||
#include <string>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 3) return 1;
|
||||
voicecat::server::Database database(argv[2]);
|
||||
std::string error;
|
||||
if (!database.open(error)) return 1;
|
||||
if (std::string(argv[1]) == "create-protected") {
|
||||
voicecat::server::ChannelRecord channel;
|
||||
channel.name = "Native protected";
|
||||
channel.audio.set_sample_rate(48000);
|
||||
channel.audio.set_bitrate_bps(24000);
|
||||
channel.audio.set_frame_ms(20);
|
||||
return database.create_channel(channel, "channel password", error) ? 0 : 1;
|
||||
}
|
||||
if (std::string(argv[1]) == "verify-protected") {
|
||||
for (const auto& channel : database.list_channels()) {
|
||||
if (channel.name == "Managed protected")
|
||||
return database.check_channel_password(channel.id, "channel password") &&
|
||||
!database.check_channel_password(channel.id, "wrong") ? 0 : 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if (std::string(argv[1]) == "create") {
|
||||
if (!database.create_account("legacy", "legacy password", true, error)) return 1;
|
||||
voicecat::server::ChannelRecord lobby;
|
||||
lobby.name = "Lobby";
|
||||
lobby.topic = "Preserved native topic";
|
||||
lobby.max_users = 7;
|
||||
lobby.audio.set_sample_rate(48000);
|
||||
lobby.audio.set_bitrate_bps(32000);
|
||||
lobby.audio.set_frame_ms(20);
|
||||
return database.create_channel(lobby, "", error) ? 0 : 1;
|
||||
}
|
||||
if (std::string(argv[1]) == "verify") {
|
||||
auto account = database.authenticate("managed", "managed password");
|
||||
return account && account->is_admin ? 0 : 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
#include "audio/apm_processor.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) return 2;
|
||||
auto processor = voicecat::audio::ApmProcessor::create();
|
||||
if (!processor) return 1;
|
||||
std::vector<int16_t> pcm(960);
|
||||
uint32_t random = 0x12345678;
|
||||
for (int frame = 0; frame < 200; ++frame) {
|
||||
for (auto &sample : pcm) {
|
||||
random ^= random << 13;
|
||||
random ^= random >> 17;
|
||||
random ^= random << 5;
|
||||
sample = static_cast<int16_t>(static_cast<int>(random % 6001) - 3000);
|
||||
}
|
||||
if (!processor->process_capture(pcm.data(), static_cast<int>(pcm.size()), 48000)) return 1;
|
||||
}
|
||||
std::ofstream output(argv[1]);
|
||||
output << "{\"samples\":[";
|
||||
for (size_t i = 0; i < pcm.size(); ++i) {
|
||||
if (i) output << ',';
|
||||
output << pcm[i];
|
||||
}
|
||||
output << "]}\n";
|
||||
return output ? 0 : 1;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include "protocol/envelope.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
static std::string hex(const std::vector<uint8_t>& bytes) {
|
||||
std::ostringstream result;
|
||||
result << std::hex << std::setfill('0');
|
||||
for (auto byte : bytes) result << std::setw(2) << unsigned(byte);
|
||||
return result.str();
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 2 || sodium_init() < 0) return 1;
|
||||
std::ofstream output(argv[1], std::ios::binary);
|
||||
if (!output) return 1;
|
||||
voicecat::v1::Envelope envelope;
|
||||
envelope.set_request_id(42);
|
||||
auto* hello = envelope.mutable_client_hello();
|
||||
hello->set_proto_version(1);
|
||||
hello->set_client_name("test-client");
|
||||
hello->set_client_version("0.0.1");
|
||||
hello->add_features("text");
|
||||
std::vector<uint8_t> framed;
|
||||
if (!voicecat::protocol::encode_envelope(envelope, framed)) return 1;
|
||||
output << "{\n \"envelope\": \"" << hex(framed) << "\",\n \"media\": [\n";
|
||||
std::array<uint8_t, 32> key{};
|
||||
for (size_t i = 0; i < key.size(); ++i) key[i] = uint8_t(i);
|
||||
voicecat::crypto::SodiumMediaCrypto sender(key.data());
|
||||
for (uint64_t sequence = 0; sequence <= 65536; ++sequence) {
|
||||
voicecat::net::VoiceFrame header;
|
||||
header.flags = voicecat::net::kFlagMarker;
|
||||
header.ssrc = 0xcafebabe;
|
||||
header.seq = sender.peek_send_counter();
|
||||
header.timestamp = 960;
|
||||
const size_t length = sequence == 0 ? 0 : sequence == 1 ? 100 : 8;
|
||||
std::vector<uint8_t> plaintext(length);
|
||||
for (size_t i = 0; i < length; ++i) plaintext[i] = uint8_t(i);
|
||||
std::vector<uint8_t> packet(voicecat::net::kVoiceHeaderSize + length + 16);
|
||||
voicecat::net::serialize_header(header, packet.data());
|
||||
if (sender.seal(plaintext.data(), length, packet.data(), 20, packet.data() + 20, length + 16) < 0) return 1;
|
||||
if (sequence == 0 || sequence == 1 || sequence == 65535 || sequence == 65536) {
|
||||
if (sequence != 0) output << ",\n";
|
||||
output << " {\"sequence\": " << sequence << ", \"key\": \""
|
||||
<< hex(std::vector<uint8_t>(key.begin(), key.end()))
|
||||
<< "\", \"plaintext\": \"" << hex(plaintext)
|
||||
<< "\", \"packet\": \"" << hex(packet) << "\"}";
|
||||
}
|
||||
}
|
||||
output << "\n ]\n}\n";
|
||||
return output ? 0 : 1;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
#include <sodium.h>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <array>
|
||||
|
||||
static std::string base64(const unsigned char *data, size_t length) {
|
||||
std::array<char, 128> output{};
|
||||
sodium_bin2base64(output.data(), output.size(), data, length, sodium_base64_VARIANT_ORIGINAL_NO_PADDING);
|
||||
return output.data();
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2 || sodium_init() < 0) return 1;
|
||||
std::ofstream output(argv[1]);
|
||||
output << "{\"hashes\":[";
|
||||
const std::array<std::string, 3> passwords{"voicecat test", "caf\xc3\xa9", std::string("a\0b", 3)};
|
||||
std::array<unsigned char, 16> salt{};
|
||||
for (size_t i = 0; i < salt.size(); ++i) salt[i] = static_cast<unsigned char>(i);
|
||||
for (size_t i = 0; i < passwords.size(); ++i) {
|
||||
std::array<unsigned char, 32> hash{};
|
||||
if (crypto_pwhash(hash.data(), hash.size(), passwords[i].data(), passwords[i].size(), salt.data(), 2,
|
||||
64 * 1024 * 1024, crypto_pwhash_ALG_ARGON2ID13) != 0) return 1;
|
||||
if (i) output << ',';
|
||||
output << "{\"passwordBase64\":\"" << base64(reinterpret_cast<const unsigned char *>(passwords[i].data()), passwords[i].size())
|
||||
<< "\",\"hash\":\"$argon2id$v=19$m=65536,t=2,p=1$" << base64(salt.data(), salt.size()) << '$' << base64(hash.data(), hash.size()) << "\"}";
|
||||
}
|
||||
output << "]}\n";
|
||||
return output ? 0 : 1;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
using socket_type = SOCKET;
|
||||
static void close_socket(socket_type socket) { closesocket(socket); }
|
||||
#else
|
||||
#include <arpa/inet.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
using socket_type = int;
|
||||
static void close_socket(socket_type socket) { close(socket); }
|
||||
#endif
|
||||
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
|
||||
static bool transfer(voicecat::crypto::TlsContext& tls, uint8_t* data, size_t size, bool writing) {
|
||||
while (size != 0) {
|
||||
int count = writing ? tls.write(data, size) : tls.read(data, size);
|
||||
if (count <= 0) return false;
|
||||
data += count;
|
||||
size -= count;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 2 || sodium_init() < 0) return 1;
|
||||
#ifdef _WIN32
|
||||
WSADATA data{};
|
||||
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return 1;
|
||||
#endif
|
||||
try {
|
||||
auto certificate = voicecat::crypto::ServerCert::generate("dotnet-tls-oracle");
|
||||
auto directory = std::filesystem::path(argv[1]);
|
||||
socket_type listener = socket(AF_INET, SOCK_STREAM, 0);
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (bind(listener, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0 || listen(listener, 1) != 0) return 1;
|
||||
socklen_t length = sizeof(address);
|
||||
if (getsockname(listener, reinterpret_cast<sockaddr*>(&address), &length) != 0) return 1;
|
||||
certificate.save(directory / "server.crt", directory / "server.key");
|
||||
voicecat::crypto::ServerIdentity::generate().save(directory / "identity.key");
|
||||
std::ofstream(directory / "port.txt") << ntohs(address.sin_port);
|
||||
socket_type peer = accept(listener, nullptr, nullptr);
|
||||
close_socket(listener);
|
||||
if (peer == static_cast<socket_type>(-1)) return 1;
|
||||
voicecat::crypto::TlsContext tls(voicecat::crypto::TlsContext::Role::Server, &certificate);
|
||||
tls.set_read_timeout(10000);
|
||||
std::string error;
|
||||
if (!tls.handshake(static_cast<int>(peer), error)) { std::cerr << error; return 1; }
|
||||
auto sender = voicecat::crypto::SodiumMediaCrypto::derive_send(tls, false);
|
||||
auto receiver = voicecat::crypto::SodiumMediaCrypto::derive_recv(tls, false);
|
||||
if (!sender || !receiver) return 1;
|
||||
voicecat::net::VoiceFrame header;
|
||||
header.ssrc = 42;
|
||||
header.seq = sender->peek_send_counter();
|
||||
std::array<uint8_t, 41> packet{};
|
||||
voicecat::net::serialize_header(header, packet.data());
|
||||
const std::array<uint8_t, 5> message{ 'h', 'e', 'l', 'l', 'o' };
|
||||
if (sender->seal(message.data(), message.size(), packet.data(), 20, packet.data() + 20, 21) != 21) return 1;
|
||||
if (!transfer(tls, packet.data(), packet.size(), true) || !transfer(tls, packet.data(), packet.size(), false)) return 1;
|
||||
std::array<uint8_t, 5> recovered{};
|
||||
if (receiver->open(packet.data() + 20, 21, packet.data(), 20, recovered.data(), recovered.size()) != 5 || recovered != message) return 1;
|
||||
uint8_t acknowledgement = 1;
|
||||
if (!transfer(tls, &acknowledgement, 1, true)) return 1;
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
#include "voicecat.h"
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
struct ClientState {
|
||||
vc_client* client = nullptr;
|
||||
std::mutex gate;
|
||||
std::condition_variable changed;
|
||||
bool authenticated = false;
|
||||
bool subscribed = false;
|
||||
bool joined = false;
|
||||
uint32_t user = 0;
|
||||
std::vector<std::pair<uint32_t, uint32_t>> streams;
|
||||
std::array<int, 3> received{};
|
||||
long long energy = 0;
|
||||
uint32_t channels = 0;
|
||||
};
|
||||
|
||||
static void event(void* context, const vc_event* value) {
|
||||
auto& state = *static_cast<ClientState*>(context);
|
||||
if (value->type == VC_EVENT_SERVER_IDENTITY) {
|
||||
vc_confirm_server_identity(state.client, 1);
|
||||
return;
|
||||
}
|
||||
std::lock_guard lock(state.gate);
|
||||
switch (value->type) {
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
state.authenticated = value->result == VC_OK;
|
||||
state.user = value->user_id;
|
||||
break;
|
||||
case VC_EVENT_VOICE_STATE: state.subscribed = value->u32a == 1; break;
|
||||
case VC_EVENT_JOIN_RESULT: state.joined = value->result == VC_OK; break;
|
||||
case VC_EVENT_STREAM_STARTED: state.streams.emplace_back(value->user_id, value->stream_id); break;
|
||||
default: break;
|
||||
}
|
||||
state.changed.notify_all();
|
||||
}
|
||||
|
||||
static void sink(void* context, uint32_t, uint32_t stream, const int16_t* pcm,
|
||||
size_t samples, uint32_t channels, uint32_t rate) {
|
||||
auto& state = *static_cast<ClientState*>(context);
|
||||
if (rate != 48000 || stream >= state.received.size()) return;
|
||||
std::lock_guard lock(state.gate);
|
||||
++state.received[stream];
|
||||
state.channels = channels;
|
||||
for (size_t index = 0; index < samples * channels; ++index) state.energy += std::abs(static_cast<int>(pcm[index]));
|
||||
state.changed.notify_all();
|
||||
}
|
||||
|
||||
template<class Predicate>
|
||||
static bool wait(ClientState& state, Predicate predicate) {
|
||||
std::unique_lock lock(state.gate);
|
||||
return state.changed.wait_for(lock, std::chrono::seconds(8), predicate);
|
||||
}
|
||||
|
||||
struct Destroy {
|
||||
void operator()(vc_client* client) const { vc_disconnect(client); vc_client_destroy(client); }
|
||||
};
|
||||
using Client = std::unique_ptr<vc_client, Destroy>;
|
||||
|
||||
static Client connect(ClientState& state, uint16_t port, uint32_t channel, const char* nickname) {
|
||||
vc_config config{"dotnet-voice-oracle", "1", VC_LOG_OFF};
|
||||
Client client(vc_client_create(&config, {event, nullptr, &state}));
|
||||
state.client = client.get();
|
||||
if (!client || vc_set_external_playback(client.get(), 1) != VC_OK ||
|
||||
vc_connect(client.get(), "127.0.0.1", port) != VC_OK ||
|
||||
vc_authenticate_guest(client.get(), nickname) != VC_OK ||
|
||||
!wait(state, [&] { return state.authenticated; }) ||
|
||||
vc_join_channel(client.get(), channel, nullptr) != VC_OK ||
|
||||
!wait(state, [&] { return state.joined; }) ||
|
||||
vc_join_voice(client.get()) != VC_OK ||
|
||||
!wait(state, [&] { return state.subscribed; }) ||
|
||||
vc_set_pcm_sink(client.get(), sink, &state) != VC_OK) return {};
|
||||
return client;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 3) return 1;
|
||||
uint16_t port = static_cast<uint16_t>(std::strtoul(argv[1], nullptr, 10));
|
||||
uint32_t channel = static_cast<uint32_t>(std::strtoul(argv[2], nullptr, 10));
|
||||
ClientState alice, bob;
|
||||
Client a = connect(alice, port, channel, "Native Alice");
|
||||
Client b = connect(bob, port, channel, "Native Bob");
|
||||
if (!a || !b) { std::fprintf(stderr, "native authentication/join/subscription failed\n"); return 1; }
|
||||
std::array<uint32_t, 3> ids{};
|
||||
vc_stream_desc mic{};
|
||||
mic.kind = VC_STREAM_MIC;
|
||||
mic.external_feed = 1;
|
||||
vc_stream_desc screen = mic;
|
||||
screen.kind = VC_STREAM_SCREEN_AUDIO;
|
||||
if (vc_stream_start(a.get(), &mic, &ids[0]) != VC_OK ||
|
||||
vc_stream_start(a.get(), &screen, &ids[1]) != VC_OK ||
|
||||
vc_stream_start(b.get(), &mic, &ids[2]) != VC_OK ||
|
||||
!wait(alice, [&] { return alice.streams.size() >= 3; }) ||
|
||||
!wait(bob, [&] { return bob.streams.size() >= 3; })) {
|
||||
std::fprintf(stderr, "native stream signaling failed\n"); return 1;
|
||||
}
|
||||
uint32_t channels = channel == 2 ? 2 : 1;
|
||||
std::vector<int16_t> pcm(960 * channels);
|
||||
for (size_t sample = 0; sample < 960; ++sample)
|
||||
for (uint32_t side = 0; side < channels; ++side)
|
||||
pcm[sample * channels + side] = static_cast<int16_t>(12000 * std::sin(sample * (side == 0 ? 0.058 : 0.083)));
|
||||
for (int frame = 0; frame < 100; ++frame) {
|
||||
if (vc_stream_feed_pcm(a.get(), ids[0], pcm.data(), 960, channels) != VC_OK ||
|
||||
vc_stream_feed_pcm(a.get(), ids[1], pcm.data(), 960, channels) != VC_OK ||
|
||||
vc_stream_feed_pcm(b.get(), ids[2], pcm.data(), 960, channels) != VC_OK) return 1;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
bool received = wait(alice, [&] { return alice.received[ids[2]] >= 5 && alice.energy > 0; }) &&
|
||||
wait(bob, [&] { return bob.received[ids[0]] >= 5 && bob.received[ids[1]] >= 5 && bob.energy > 0; });
|
||||
{
|
||||
std::scoped_lock lock(alice.gate, bob.gate);
|
||||
std::printf("channel=%u channels=%u alice=%d bob-mic=%d bob-screen=%d energy=%lld/%lld\n",
|
||||
channel, channels, alice.received[ids[2]], bob.received[ids[0]], bob.received[ids[1]], alice.energy, bob.energy);
|
||||
received = received && alice.channels == channels && bob.channels == channels;
|
||||
}
|
||||
return received ? 0 : 1;
|
||||
}
|
||||
@@ -23,4 +23,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,4 +43,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Protobuf" Version="3.36.1" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.83.0" PrivateAssets="all" />
|
||||
<Protobuf Include="../../../core/proto/voicecat.proto" GrpcServices="None" />
|
||||
<Protobuf Include="../../../proto/voicecat.proto" GrpcServices="None" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using System.Diagnostics;
|
||||
using VoiceCat.Server.Data;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
@@ -27,71 +26,6 @@ public sealed class AccountStoreTests
|
||||
finally { File.Delete(path); File.Delete(path + "-wal"); File.Delete(path + "-shm"); }
|
||||
}
|
||||
|
||||
[NativeDatabaseFact]
|
||||
public async Task ExistingCppDatabaseAndManagedAccountsWorkInBothImplementations()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-import-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "voicecat.db");
|
||||
try
|
||||
{
|
||||
await RunOracleAsync("create", path);
|
||||
using (var store = new AccountStore(path))
|
||||
{
|
||||
Account account = Assert.IsType<Account>(await store.AuthenticateAsync("legacy", "legacy password"));
|
||||
Assert.True(account.IsAdmin);
|
||||
var channel = Assert.Single(store.LoadChannels());
|
||||
Assert.Equal("Preserved native topic", channel.Topic);
|
||||
Assert.Equal(7U, channel.MaxUsers);
|
||||
Assert.Equal(32000U, channel.Audio.BitrateBps);
|
||||
await store.CreateAccountAsync("managed", "managed password", true);
|
||||
}
|
||||
await RunOracleAsync("verify", path);
|
||||
}
|
||||
finally { Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
private static async Task RunOracleAsync(string mode, string path)
|
||||
{
|
||||
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE")!) { UseShellExecute = false, CreateNoWindow = true };
|
||||
start.ArgumentList.Add(mode);
|
||||
start.ArgumentList.Add(path);
|
||||
using var process = Process.Start(start)!;
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try { await process.WaitForExitAsync(timeout.Token); Assert.Equal(0, process.ExitCode); }
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
|
||||
[NativeDatabaseFact]
|
||||
public async Task ChannelPasswordHashesWorkInBothImplementations()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-channels-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "voicecat.db");
|
||||
try
|
||||
{
|
||||
await RunOracleAsync("create-protected", path);
|
||||
using (var store = new AccountStore(path))
|
||||
{
|
||||
var channel = Assert.Single(store.LoadChannels());
|
||||
Assert.True(store.CheckChannelPassword(channel.Id, "channel password"));
|
||||
Assert.False(store.CheckChannelPassword(channel.Id, "wrong"));
|
||||
channel.Name = "Managed protected";
|
||||
store.SaveChannel(channel, "channel password", true);
|
||||
}
|
||||
await RunOracleAsync("verify-protected", path);
|
||||
}
|
||||
finally { Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
private sealed class NativeDatabaseFactAttribute : FactAttribute
|
||||
{
|
||||
public NativeDatabaseFactAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE"))) Skip = "Set VOICECAT_DATABASE_ORACLE to the native database oracle.";
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountsSurviveRestartAndFailedAuthDoesNotChangeLastLogin()
|
||||
{
|
||||
|
||||
@@ -8,41 +8,6 @@ namespace VoiceCat.Tests;
|
||||
|
||||
public class AdministrationTests
|
||||
{
|
||||
[NativeCliFact]
|
||||
public async Task ExistingCppCliCreatesProtectedChannelsAndAdministersAccounts()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var admin = await AdminAsync(fixture);
|
||||
var start = new System.Diagnostics.ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
|
||||
{
|
||||
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||||
RedirectStandardOutput = true, RedirectStandardError = true
|
||||
};
|
||||
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--username", "Admin", "--password", "secret",
|
||||
"--create-channel", "--new-channel-name", "Native room", "--new-channel-password", "protected", "--create-account", "native", "secret", "--list-accounts", "--wait-ms", "10000" })
|
||||
start.ArgumentList.Add(argument);
|
||||
using var process = System.Diagnostics.Process.Start(start)!;
|
||||
Task<string> stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(admin.Timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await stdout + await stderr);
|
||||
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||
var room = store.LoadChannels().Single(c => c.Name == "Native room");
|
||||
Assert.True(store.CheckChannelPassword(room.Id, "protected"));
|
||||
Assert.NotNull(await store.AuthenticateAsync("native", "secret"));
|
||||
}
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
|
||||
private sealed class NativeCliFactAttribute : FactAttribute
|
||||
{
|
||||
public NativeCliFactAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountAdministrationIsPermissionGatedAndPersistsPasswordChanges()
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class DspTests
|
||||
if (frame >= 60) foreach (short value in pcm) outputEnergy += (double)value * value;
|
||||
}
|
||||
Assert.True(Math.Sqrt(outputEnergy / inputEnergy) < 0.2);
|
||||
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-noise.json")));
|
||||
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "rnnoise.json")));
|
||||
short[] expected = fixture.RootElement.GetProperty("samples").EnumerateArray().Select(value => value.GetInt16()).ToArray();
|
||||
Assert.Equal(pcm.Length, expected.Length);
|
||||
for (int i = 0; i < pcm.Length; i++) Assert.InRange(Math.Abs(pcm[i] - expected[i]), 0, 1);
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace VoiceCat.Tests;
|
||||
public class GoldenTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnvelopeMatchesCppFixture()
|
||||
public void EnvelopeMatchesCanonicalWireVector()
|
||||
{
|
||||
using var fixture = Load();
|
||||
var expected = Convert.FromHexString(fixture.RootElement.GetProperty("envelope").GetString()!);
|
||||
@@ -23,7 +23,7 @@ public class GoldenTests
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void MediaPacketsMatchCppFixtures(bool managed)
|
||||
public void MediaPacketsMatchCanonicalWireVectors(bool managed)
|
||||
{
|
||||
using var fixture = Load();
|
||||
foreach (var vector in fixture.RootElement.GetProperty("media").EnumerateArray())
|
||||
@@ -46,5 +46,5 @@ public class GoldenTests
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-wire.json")));
|
||||
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "wire.json")));
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public class IdentityTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TofuRequiresExplicitPinAndPreservesCppFileFormat()
|
||||
public void TofuRequiresExplicitPinAndPreservesFileFormat()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-pins-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
@@ -5,30 +5,6 @@ namespace VoiceCat.Tests;
|
||||
|
||||
public class ManagedCliTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ManagedCliInteroperatesWithExistingCppCli()
|
||||
{
|
||||
string? nativeCli = Environment.GetEnvironmentVariable("VOICECAT_VCCLI");
|
||||
if (string.IsNullOrEmpty(nativeCli)) return; // Full conformance runs set this explicitly.
|
||||
await using var fixture = new ServerFixture();
|
||||
string cli = Path.Combine(FindRoot(), "dotnet", "src", "VoiceCat.Cli", "bin", "Release", "net10.0", "VoiceCat.Cli.dll");
|
||||
using Process managed = Start(cli, fixture, "Managed", "Managed checkpoint", "Native checkpoint", 2);
|
||||
await Task.Delay(750); // Native vccli sends its one-shot text immediately after auth.
|
||||
var nativeStart = new ProcessStartInfo(nativeCli) { WorkingDirectory = fixture.Directory, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true };
|
||||
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Native",
|
||||
"--channel", "2", "--text", "Native checkpoint", "--test-tone-ms", "5000" }) nativeStart.ArgumentList.Add(argument);
|
||||
using Process native = Process.Start(nativeStart)!;
|
||||
Task<string> managedOut = managed.StandardOutput.ReadToEndAsync(), managedError = managed.StandardError.ReadToEndAsync();
|
||||
Task<string> nativeOut = native.StandardOutput.ReadToEndAsync(), nativeError = native.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35));
|
||||
await Task.WhenAll(managed.WaitForExitAsync(timeout.Token), native.WaitForExitAsync(timeout.Token));
|
||||
string mout = await managedOut, nout = await nativeOut;
|
||||
Assert.True(managed.ExitCode == 0, await managedError + Environment.NewLine + mout);
|
||||
Assert.True(native.ExitCode == 0, await nativeError + Environment.NewLine + nout);
|
||||
Assert.Contains("Native checkpoint", mout); Assert.Contains("Managed checkpoint", nout);
|
||||
Assert.Contains("[test-tone] received=", nout); Assert.DoesNotContain("\"voiceEnergy\":0", mout);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1u)]
|
||||
[InlineData(2u)]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using VoiceCat.Transport;
|
||||
using System.Net;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using VoiceCat.Protocol;
|
||||
using VoiceCat.Server.Transport;
|
||||
@@ -11,89 +10,6 @@ namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class MediaRelayTests
|
||||
{
|
||||
[CppCliVoiceTheory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task TwoCppCliProcessesJoinChatAndExchangeVoice(int channel)
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var observer = await fixture.ConnectAsync();
|
||||
await observer.LoginAsync("Observer");
|
||||
observer.Send(new() { JoinChannel = new() { ChannelId = checked((uint)channel) } });
|
||||
Assert.True((await observer.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await Task.WhenAll(RunAsync("Cli Alice"), RunAsync("Cli Bob"));
|
||||
var first = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||
var second = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||
Assert.Equal("CLI voice checkpoint", first.Body);
|
||||
Assert.Equal(first.Body, second.Body);
|
||||
Assert.NotEqual(first.SenderId, second.SenderId);
|
||||
|
||||
async Task RunAsync(string nickname)
|
||||
{
|
||||
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
|
||||
{
|
||||
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||||
RedirectStandardOutput = true, RedirectStandardError = true
|
||||
};
|
||||
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"--nick", nickname, "--channel", channel.ToString(System.Globalization.CultureInfo.InvariantCulture), "--text", "CLI voice checkpoint", "--test-tone-ms", "4000" })
|
||||
start.ArgumentList.Add(argument);
|
||||
using var process = Process.Start(start)!;
|
||||
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> stderr = process.StandardError.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
string log = await stdout + await stderr;
|
||||
Assert.True(process.ExitCode == 0, log);
|
||||
Assert.Contains("[test-tone] received=", log);
|
||||
}
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CppCliVoiceTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public CppCliVoiceTheoryAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
||||
}
|
||||
}
|
||||
|
||||
[VoiceOracleTheory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task ExistingCppClientsExchangeBidirectionalVoiceThroughManagedServer(int channel)
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE")!)
|
||||
{
|
||||
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||||
RedirectStandardOutput = true, RedirectStandardError = true
|
||||
};
|
||||
start.ArgumentList.Add(fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
start.ArgumentList.Add(channel.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
using var process = Process.Start(start)!;
|
||||
Task<string> output = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> error = process.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await output + await error);
|
||||
}
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
|
||||
private sealed class VoiceOracleTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public VoiceOracleTheoryAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE"))) Skip = "Set VOICECAT_VOICE_ORACLE to the native voice conformance executable.";
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ public sealed class PasswordTests
|
||||
public void VerifiesLibsodiumHashesWithoutPasswordNormalization()
|
||||
{
|
||||
var hasher = new PasswordHasher();
|
||||
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-passwords.json")));
|
||||
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "argon2id.json")));
|
||||
foreach (var item in fixture.RootElement.GetProperty("hashes").EnumerateArray())
|
||||
{
|
||||
string encodedPassword = item.GetProperty("passwordBase64").GetString()!;
|
||||
|
||||
@@ -95,43 +95,6 @@ public sealed class ServerTests
|
||||
Assert.NotEqual(0U, (await client.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Code);
|
||||
}
|
||||
|
||||
[CppCliFact]
|
||||
public async Task ExistingCppCliAuthenticatesAndChatsThroughManagedServer()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var receiver = await fixture.ConnectAsync();
|
||||
User self = await receiver.LoginAsync("Managed");
|
||||
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
|
||||
{
|
||||
WorkingDirectory = fixture.Directory, UseShellExecute = false,
|
||||
RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true
|
||||
};
|
||||
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Cpp", "--text", "native interoperability", "--wait-ms", "10000" })
|
||||
start.ArgumentList.Add(argument);
|
||||
using var process = Process.Start(start)!;
|
||||
Task<string> output = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> error = process.StandardError.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(receiver.Timeout.Token);
|
||||
string log = await output + await error;
|
||||
Assert.True(process.ExitCode == 0, log);
|
||||
TextMessage text = (await receiver.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||
Assert.Equal("native interoperability", text.Body);
|
||||
Assert.NotEqual(self.Id, text.SenderId);
|
||||
Assert.Contains("native interoperability", log);
|
||||
}
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
|
||||
private sealed class CppCliFactAttribute : FactAttribute
|
||||
{
|
||||
public CppCliFactAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ServerFixture : IAsyncDisposable
|
||||
{
|
||||
public string Directory { get; } = Path.Combine(Path.GetTempPath(), "voicecat-server-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class TlsInteropTests
|
||||
{
|
||||
[TlsOracleFact]
|
||||
public async Task ManagedClientAndCppServerAgreeOnExporterKeysAndCertificate()
|
||||
{
|
||||
string? oracle = Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE");
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tls-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
var start = new ProcessStartInfo(oracle!) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true };
|
||||
start.ArgumentList.Add(directory);
|
||||
using var process = Process.Start(start)!;
|
||||
var error = process.StandardError.ReadToEndAsync();
|
||||
var stdout = process.StandardOutput.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
int port = 0;
|
||||
while (!int.TryParse(File.Exists(Path.Combine(directory, "port.txt")) ? await File.ReadAllTextAsync(Path.Combine(directory, "port.txt"), timeout.Token) : "", out port))
|
||||
{
|
||||
Assert.False(process.HasExited, "C++ TLS oracle exited before listening.");
|
||||
await Task.Delay(20, timeout.Token);
|
||||
}
|
||||
using var certificate = X509Certificate2.CreateFromPem(await File.ReadAllTextAsync(Path.Combine(directory, "server.crt"), timeout.Token));
|
||||
string fingerprint = Convert.ToHexString(SHA256.HashData(certificate.RawData));
|
||||
using var credentials = ServerCredentials.LoadOrCreate(directory, "existing C++ identity");
|
||||
Assert.Equal(fingerprint, credentials.CertificateFingerprint);
|
||||
Assert.Equal(32, credentials.Identity.PublicKey.Length);
|
||||
using var client = TlsSession.CreateClient(value => value == fingerprint);
|
||||
using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync("127.0.0.1", port, timeout.Token);
|
||||
byte[] buffer = new byte[16384];
|
||||
async Task Flush()
|
||||
{
|
||||
while (client.PendingCiphertextBytes > 0)
|
||||
{
|
||||
int count = client.DrainCiphertext(buffer);
|
||||
int sent = 0;
|
||||
while (sent < count) sent += await socket.SendAsync(buffer.AsMemory(sent, count - sent), SocketFlags.None, timeout.Token);
|
||||
}
|
||||
}
|
||||
async Task Receive()
|
||||
{
|
||||
int count = await socket.ReceiveAsync(buffer, SocketFlags.None, timeout.Token);
|
||||
Assert.True(count > 0, "TLS oracle closed unexpectedly.");
|
||||
client.ReceiveCiphertext(buffer.AsSpan(0, count));
|
||||
}
|
||||
while (!client.IsReady) { await Flush(); await Receive(); }
|
||||
await Flush();
|
||||
byte[] packet = new byte[41];
|
||||
int received = 0;
|
||||
while (received < packet.Length)
|
||||
{
|
||||
int count = client.ReadPlaintext(packet.AsSpan(received));
|
||||
received += count;
|
||||
if (count == 0) { await Flush(); await Receive(); }
|
||||
}
|
||||
using var decryptor = client.CreateMediaDecryptor();
|
||||
byte[] plaintext = new byte[5];
|
||||
Assert.True(decryptor.TryDecrypt(packet, plaintext, out var header, out _));
|
||||
Assert.Equal("hello"u8.ToArray(), plaintext);
|
||||
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
|
||||
using var encryptor = client.CreateMediaEncryptor();
|
||||
encryptor.Encrypt(header, plaintext, packet);
|
||||
client.WritePlaintext(packet);
|
||||
await Flush();
|
||||
byte[] ack = new byte[1];
|
||||
while (client.ReadPlaintext(ack) == 0) { await Flush(); await Receive(); }
|
||||
Assert.Equal(1, ack[0]);
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await error);
|
||||
await stdout;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); }
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TlsOracleFactAttribute : FactAttribute
|
||||
{
|
||||
public TlsOracleFactAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE")))
|
||||
Skip = "Build the native TLS oracle and set VOICECAT_TLS_ORACLE to its executable path.";
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>VoiceCat Screen Audio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
@@ -13,7 +15,7 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
+1
-6
@@ -1,10 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.me.iamtalon.voicecat</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict/>
|
||||
</plist>
|
||||
@@ -0,0 +1,285 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
29834FA59EC5E9B6DF979969 /* SampleHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D24D2F62C1EF73701DF67AA6 /* SampleHandler.swift */; };
|
||||
5C0FDF35C05B25379CF645CA /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 152BBEF11B0F4FB35CCBF3A2 /* BroadcastAudioRing.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
152BBEF11B0F4FB35CCBF3A2 /* BroadcastAudioRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioRing.swift; sourceTree = "<group>"; };
|
||||
A934011B829BCAEA203893A0 /* VoiceCatBroadcast.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = VoiceCatBroadcast.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D24D2F62C1EF73701DF67AA6 /* SampleHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleHandler.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
4377E6881B8FB83F47ABA5E5 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
152BBEF11B0F4FB35CCBF3A2 /* BroadcastAudioRing.swift */,
|
||||
D24D2F62C1EF73701DF67AA6 /* SampleHandler.swift */,
|
||||
B4E33FBE3AC90EF1881C5631 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4E33FBE3AC90EF1881C5631 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A934011B829BCAEA203893A0 /* VoiceCatBroadcast.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
774C80771B891815C5E19751 /* VoiceCatBroadcast */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 8C33CB44BD31062DFD678CB0 /* Build configuration list for PBXNativeTarget "VoiceCatBroadcast" */;
|
||||
buildPhases = (
|
||||
F14685D9DD890398711192C2 /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = VoiceCatBroadcast;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = VoiceCatBroadcast;
|
||||
productReference = A934011B829BCAEA203893A0 /* VoiceCatBroadcast.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A0A4E1880BAF927B953E1583 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1430;
|
||||
TargetAttributes = {
|
||||
774C80771B891815C5E19751 = {
|
||||
DevelopmentTeam = "";
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 605496843E71431C0A32561D /* Build configuration list for PBXProject "VoiceCatBroadcast" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = 4377E6881B8FB83F47ABA5E5;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = B4E33FBE3AC90EF1881C5631 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
774C80771B891815C5E19751 /* VoiceCatBroadcast */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
F14685D9DD890398711192C2 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5C0FDF35C05B25379CF645CA /* BroadcastAudioRing.swift in Sources */,
|
||||
29834FA59EC5E9B6DF979969 /* SampleHandler.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
197C65697A3665F765F1D68C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatBroadcast.entitlements;
|
||||
INFOPLIST_FILE = Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat.broadcast;
|
||||
PRODUCT_NAME = VoiceCatBroadcast;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
63613BB5A6BD41864E5B2D5F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatBroadcast.entitlements;
|
||||
INFOPLIST_FILE = Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat.broadcast;
|
||||
PRODUCT_NAME = VoiceCatBroadcast;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F80B9026E8E0938D197AD821 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F871D6897D00889325082733 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
605496843E71431C0A32561D /* Build configuration list for PBXProject "VoiceCatBroadcast" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
F80B9026E8E0938D197AD821 /* Debug */,
|
||||
F871D6897D00889325082733 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
8C33CB44BD31062DFD678CB0 /* Build configuration list for PBXNativeTarget "VoiceCatBroadcast" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
63613BB5A6BD41864E5B2D5F /* Debug */,
|
||||
197C65697A3665F765F1D68C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = A0A4E1880BAF927B953E1583 /* Project object */;
|
||||
}
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,32 @@
|
||||
name: VoiceCatBroadcast
|
||||
options:
|
||||
deploymentTarget:
|
||||
iOS: "18.0"
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
DEVELOPMENT_TEAM: ""
|
||||
targets:
|
||||
VoiceCatBroadcast:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
sources:
|
||||
- SampleHandler.swift
|
||||
- BroadcastAudioRing.swift
|
||||
info:
|
||||
path: Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: VoiceCat Screen Audio
|
||||
CFBundleShortVersionString: 0.0.1
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.broadcast-services-upload
|
||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).SampleHandler
|
||||
RPBroadcastProcessMode: RPBroadcastProcessModeSampleBuffer
|
||||
entitlements:
|
||||
path: VoiceCatBroadcast.entitlements
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: me.iamtalon.voicecat.broadcast
|
||||
PRODUCT_NAME: VoiceCatBroadcast
|
||||
SKIP_INSTALL: YES
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
@@ -43,7 +43,7 @@ endif()
|
||||
if(NOT VOICECAT_OPUS_LICENSE)
|
||||
message(FATAL_ERROR "Set VOICECAT_OPUS_LICENSE to the imported Opus copyright file for native staging.")
|
||||
endif()
|
||||
set(RNNOISE_DIR "${CMAKE_CURRENT_LIST_DIR}/../../third_party/rnnoise")
|
||||
set(RNNOISE_DIR "${CMAKE_CURRENT_LIST_DIR}/../rnnoise")
|
||||
if(NOT TARGET rnnoise)
|
||||
add_library(rnnoise STATIC
|
||||
${RNNOISE_DIR}/src/denoise.c ${RNNOISE_DIR}/src/rnn.c
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user