8 Commits
Author SHA1 Message Date
Talon 82ad4c2811 Port managed client audio and Windows application
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-16 16:48:06 +02:00
Talon 5a226ba543 Harden managed server deployment and authentication 2026-09-15 23:24:11 +02:00
Talon 653131b876 Add managed channel administration and moderation
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-15 23:11:09 +02:00
Talon 274b85025c Add configurable media-aware managed session reaping 2026-09-15 22:58:16 +02:00
Talon 05eacb3092 Add encrypted managed UDP relay and native voice conformance 2026-09-15 22:53:54 +02:00
Talon 4067bab7c2 Add managed codec DSP and initial control server 2026-09-15 22:51:33 +02:00
Talon 2df79cdd4c Add managed TLS interoperability and persisted credentials
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-15 18:04:20 +02:00
Talon b76181d9fb Start .NET rewrite with wire and media crypto conformance
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-15 17:54:16 +02:00
149 changed files with 9678 additions and 281 deletions
+64
View File
@@ -0,0 +1,64 @@
name: .NET port
on:
push:
paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
pull_request:
paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
workflow_dispatch:
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-24.04, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: dotnet/global.json
cache: true
cache-dependency-path: dotnet/**/packages.lock.json
- name: Build and stage native codec/DSP
shell: pwsh
run: ./dotnet/build-native.ps1
- run: dotnet restore dotnet/VoiceCat.slnx --locked-mode
- run: dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
- run: dotnet test dotnet/VoiceCat.slnx -c Release --no-build
- shell: pwsh
run: ./dotnet/check-licenses.ps1
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
+4
View File
@@ -1,4 +1,8 @@
# Build output # Build output
/dotnet/**/bin/
/dotnet/**/obj/
/dotnet/**/TestResults/
/dotnet/artifacts/
/build/ /build/
/out/ /out/
+19 -1
View File
@@ -17,7 +17,7 @@ and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](
on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23). on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23).
> See [`PROGRESS.md`](PROGRESS.md). > See [`PROGRESS.md`](PROGRESS.md).
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control) 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 + 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. native clients (Swift on macOS/iOS, C# on Windows) and the server.
@@ -25,6 +25,24 @@ native clients (Swift on macOS/iOS, C# on Windows) and the server.
## Build & test commands ## 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
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. Phase 4 remains in progress;
media-aware reaping and server administration are implemented. Server deployment hardening
and the managed audio/client core plus Windows cutover are implemented. The Windows publish
contains only the permitted media shim. Accessibility/manual endurance validation and broad
server deployment work remain; Apple GUI rewrites follow Windows.
The default development preset is **`dev`** — it builds everything (server + tools + tests) 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 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. [`docs/building.md`](docs/building.md) for the full preset matrix.
+10
View File
@@ -49,6 +49,16 @@ endif()
# ── Targets ─────────────────────────────────────────────────────────────────── # ── Targets ───────────────────────────────────────────────────────────────────
add_subdirectory(core) 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)
endif()
if(VOICECAT_BUILD_SERVER) if(VOICECAT_BUILD_SERVER)
add_subdirectory(server) add_subdirectory(server)
endif() endif()
+260
View File
@@ -10,6 +10,266 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ Where we left off / next action
- **Done (2026-09-16): Managed client/audio and Windows cutover checkpoint.** Added
`VoiceCat.Core` with TOFU-gated TLS, correlated concurrent requests, immutable snapshots,
reconnects, bounded client events, UDP binding and authenticated encrypted media. Added
`VoiceCat.Audio` with allocation-free real-time cycles, bounded PCM/jitter queues,
5/10/20/40/60 ms Opus reframing, VAD/PTT, DTX, DRED → FEC → PLC, RNNoise, per-stream
controls and stereo mixing. Shared TLS/media primitives moved to `VoiceCat.Crypto`.
The WinForms app now references a managed compatibility facade over these libraries and
uses C# WASAPI capture, loopback and playback. Active streams renegotiate after channel
moves while capture-facing IDs stay stable. Per-app mixing no longer allocates or locks in
its real-time loop. The self-contained Windows publish includes only the permitted
`voicecat_media.dll`; a real default-device capture/playback plus main-form smoke passed.
Managed tests cover two-way decoded PCM voice, text, TOFU, concurrent requests, reconnects,
bounded jitter, zero-allocation cycles, recovery order and stream continuity across a
mono→stereo channel move. The old Interop project remains only as a migration oracle and
its nine native ABI tests remain green. **Verified:** 190 managed tests with every native
conformance/published-server variable enabled, nine Windows native-oracle tests, 29 CTest
tests, locked restores, both self-contained publishes and the permissive license audit.
**Next:** add the managed CLI and explicit managed
client ↔ C++ CLI conversation, then finish production deployment work. The Phase 5/7
manual ten-minute listen and NVDA gates are not yet signed off; Apple rewrites follow.
- **Done (2026-09-15): Server deployment hardening checkpoint.** Pushed existing work
through `653131b` to `origin/dotnet/foundations`. Added CLI/environment configuration,
all-interface port 8384 defaults, config/fingerprint commands, local administrator
provisioning with hidden/stdin/environment password input, JSON readiness, an exclusive
instance lock, transport-failure observation and ten-second graceful shutdown.
Password-attempt buckets are shared by IP/account across reconnects, bounded to 4096
keys and enforce configurable burst/refill plus escalating backoff before Argon2.
Added self-contained win-x64 publishing with separate checked runtime lock files;
server publishes without the audio/codec shim. Behavior tests verify precedence,
invalid input, cross-connection/account throttling, readiness, duplicate-instance refusal,
active TLS shutdown and real published-executable provisioning/fingerprint persistence.
**Verified:** 174 managed tests with all conformance/published-server variables enabled;
warning-free Release build, native build/29 CTest tests and permissive package audit.
**Next (user priority):** managed client-core and audio prerequisites, then Windows
WinForms cutover before either Apple GUI. Windows is already C# at the UI layer but
still depends on native protocol/audio. Linux publishing/container/service packaging
and operational soak remain before broad production rollout; do not claim Phase 4
production rollout or GUI parity complete without those platform/behavior checks.
- **Done (2026-09-15): Managed channel and administration checkpoint.** Reaper committed
as `274b850`. Added protected joins and capacity checks, leave-to-Lobby, persisted channel
create/edit/delete with events, parent/cycle validation and Lobby protection. Native
salted BLAKE2b channel hashes work in both directions; empty edit passwords preserve
protection. Channel edits, moves and deletion clear streams before further media routing.
Session permissions gate kick/ban/move/server-mute/deafen and account create/reset/delete/
list. Only administrators grant permissions; temporary-channel permission cannot create
permanent channels. Kick/ban retire media and send one reason-bearing LEFT. Account bans
persist by username, guest bans by address; Unix-millisecond expiry converts to database
seconds, fixing the native handler's unit mismatch. Bounded Argon2 work remains outside
the session lock; account lists exclude hashes and respect the frame limit. The existing
C++ CLI successfully creates protected channels and creates/lists accounts against the
managed server. Fixed its temporary channel-string pointers and zero audio defaults.
A native sample-rate regression intermittently measured host microphone audio alongside
its injected tone; changed that test to external capture/playback and kept callback state
alive through client shutdown, with synchronized energy reads.
**Verified:** 169/169 managed tests with all native conformance enabled, zero skips;
warning-free managed Release build, native dev build and 29/29 CTest tests; diff check.
**Next:** production configuration, administrator provisioning/publishing and remaining
server readiness checks (including auth rate limiting). Phase 4 is still in progress.
Then managed audio/core/CLI, Windows cutover, C# AppKit and UIKit clients; preserve Swift
ReplayKit extension and freeze the shared-ring contract before the iOS cutover.
- **Done (2026-09-15): Managed media-aware reaper.** Voice checkpoint committed as
`05eacb3`. Added `VoiceServerOptions` (name/guests/capacity, handshake deadline,
idle timeout and sweep interval), preserving the previous constructor overload.
Default expiry/sweep are 45 s / 15 s; zero idle timeout disables reaping. Parsed TCP
envelopes, authenticated owned-stream voice and exact bound-endpoint UDP keepalives
refresh one monotonic session timestamp. Invalid media does not refresh it. Removed
the independent 60-second TCP-only timeout so media-active sessions remain connected.
Reaping sends a fatal disconnect and retires presence/media routing with one LEFT
event. Shutdown awaits active control/media/reaper loops and unfinished handshakes.
Tests inject `TimeProvider` timestamps to verify TCP/UDP activity, rejected media,
single departure events, disabled reaping and shutdown. **Verified:** 160/160 managed
tests with all native conformance enabled; managed Release build has zero warnings;
native dev build and 29/29 CTest tests green; `git diff --check` passes.
**Next:** protected channel joins and channel CRUD, permissions/moderation/account
administration, then production configuration/publishing. Phase 4 remains in progress.
Follow with audio/core/managed CLI, switch Windows to the managed library, then C#
AppKit/UIKit clients. Keep the Swift broadcast extension and frozen shared-ring boundary.
- **Done (2026-09-15): Phase 4 encrypted voice checkpoint.** Existing pending codec/DSP
and initial server work committed as `4067bab`. Managed server now binds UDP on the
TCP port number, issues 16-byte session tokens, supports subscription and multi-stream
signaling, and authenticates/reseals encoded Opus to eligible channel subscribers.
Crypto and endpoints have one UDP-loop owner; control handlers publish immutable
routing snapshots. Rejects invalid tokens, malformed/forged/replayed media and SSRCs
not owned by the sender. Stop, unsubscribe, channel movement and disconnect update
routing; retired keys are cleared without requiring subsequent UDP traffic.
First endpoint binding is fixed for the session (reconnect to change it), unlike
the C++ oracle's permissive rebinding policy. Packet formats/protocol v2 are unchanged.
Two actual C++ `vccli` processes authenticate, join, chat and exchange mono/stereo
voice through the managed server. Native voice oracle additionally verifies three
concurrent streams with bidirectional decoded PCM energy/metadata, without hardware.
Added finite `vccli --test-tone-ms` and fixed normal `--voice` to subscribe first.
**Verified:** 154/154 managed tests, no skips with TLS/database/voice/CLI variables;
native dev build and 29/29 CTest tests; independent native media staging; warning-free
managed Release build; identical regenerated wire/password fixtures; C++ DSP within
one PCM unit; 22 permissive package licenses; `git diff --check` passes. Fan-out core
allocates zero managed bytes for 50 subscribers; transport scheduling and crypto
fallback are excluded. Transport test delivers all 2,500 packets at a paced 50 pps.
**Next:** media-aware keepalive/reaper, then protected channel joins, channel CRUD,
permissions/moderation/account administration and production configuration. Phase 4
remains in progress. Audio, managed client/CLI, Windows cutover and C# AppKit/UIKit
follow; keep Swift ReplayKit extension and freeze its ring contract before iOS.
- **In progress (2026-09-15): Phase 4 managed server control plane.** Added TLS socket
orchestration, bounded framing/queues, guest and password authentication, persisted
channels, state snapshots, channel joins, text routing, ping and disconnect events.
The existing C++ CLI authenticates and sends text through the managed server.
Managed Argon2id verification passes libsodium fixtures, including UTF-8 and embedded
NUL passwords. The C++ database oracle proves existing account/channel import and
C++ verification of managed-created accounts without password resets. **Verified:**
142/142 managed tests with all native interoperability checks enabled, warning-free
Release build, regenerated password fixtures identical, and 22 permissive package
licenses; native dev build and 29/29 CTest tests green. Locked restore passes.
CI requires CLI/database checks in its C++ conformance job. Codec/DSP and the first
server slice are committed together on `dotnet/foundations` as a validated checkpoint.
**Next:** encrypted UDP binding/SFU relay and stream signaling.
UDP voice, streams, administration, protected channel joins and production configuration
remain pending; this is the first control-plane checkpoint, not Phase 4 completion.
### .NET control-plane checkpoint handoff / discoveries (2026-09-15)
- **Working tree:** stay on `dotnet/foundations`, tracking `origin/dotnet/foundations`.
Foundation `b76181d` and TLS checkpoint `2df79cd` were committed and pushed.
The codec/DSP port and first managed server checkpoint were subsequently committed
together, including new projects, native bindings/oracles, tests and docs.
See the latest checkpoint commit; no push is requested for this session.
- **Style/scope:** write idiomatic .NET in `dotnet/`; do not copy C++ code or comment
style. The existing implementation is the behavior/wire oracle. No wire changes
were made. Read `docs/porting-to-dotnet.md`, `docs/api-dotnet.md`, `dotnet/README.md`
and the relevant protocol/security/voice sections before the next subsystem.
- **Implemented projects:** `VoiceCat.Protocol` (existing protobuf + framing),
`VoiceCat.Crypto` (media crypto/replay, TLS/exporters, identity/TOFU, password hashing),
`VoiceCat.Codec` (Opus/PLC/DRED), `VoiceCat.Dsp` (RNNoise/energy VAD), and
`VoiceCat.Server` (real TLS control server + compatible SQLite account/channel store).
`dotnet/oracle/` contains optional native wire, TLS, DSP, password and database
conformance executables. `ServerTests` exercises real sockets and the existing CLI;
`AccountStoreTests` proves native database import and password verification both ways.
- **TLS discovery:** BouncyCastle destroys exporter secrets after its handshake
callback. Export keys inside `NotifyHandshakeComplete`, not after the socket loop
notices readiness. Preserve label `voicecat media v1` and contexts `[0]` / `[1]`.
A `TlsSession` has one owner; the control connection loop owns all TLS calls.
Certificate acceptance is a synchronous leaf-SHA256 pin gate, not normal PKI.
New certificates carry the Ed25519 public key in their SAN, but verification of
the ServerHello identity against that SAN remains pending. Partial credential
sets must fail rather than silently generate a new server identity.
- **Native codec discoveries:** the actual pinned Opus is **1.5.2**, despite older
design comments referring to 1.6. Standalone builds use checksum-pinned upstream
sources with DRED/Deep PLC enabled. DRED needs a **30 ms minimum** in this release;
20 ms produces no redundancy. DRED encoding at 8/12 kHz is explicitly unsupported;
decoding works at all five rates. Recovery offset defaults to one missing frame's
samples before the next packet's start (the older C++ zero offset is not a guide).
Fixed-signature C wrappers avoid the Apple ARM64 varargs ABI issue with Opus CTLs.
Windows DLL staging must omit the MinGW `lib` prefix. MinGW and MSVC builds pass;
iOS needs later static packaging. Device audio callbacks/rings are not implemented.
- **DSP behavior:** RNNoise processes complete 480-sample mono chunks at 48 kHz;
other rates pass through, and partial chunks at 48 kHz are rejected. Native C++
conformance allows one PCM unit for rounding. VAD hang time uses monotonic
`TimeProvider` timestamps, starts closed and does not replace noise suppression.
The combined allocation test proves zero managed allocations across 1,000 cycles.
- **Password/database discoveries:** use the existing BouncyCastle Argon2 engine
with strict libsodium PHC parsing; no additional Konscious dependency or password
reset is needed. Keep UTF-8 bytes unchanged, including embedded NUL. New hashes use
Argon2id v19, 64 MiB, two iterations, parallelism one, salt 16/output 32 bytes.
Verification is bounded to 128 MiB, ten iterations, parallelism four and 1024 UTF-8
password bytes; excessive imported costs fail closed. Two per-store password
workers bound CPU/memory use. Failed login does not update `last_login`.
Keep SQLite schema v2; accept v1 migration and reject unknown versions.
**Seed both default channels only when the entire channel table is empty**;
an existing single Lobby is an intentional configuration and must be preserved.
- **SQLite dependency discovery:** the initial `Microsoft.Data.Sqlite` 10.0.5 bundle
pulled an older vulnerable SQLite native dependency, rejected by warnings-as-errors
restore. The implementation uses `Microsoft.Data.Sqlite.Core` 10.0.5,
SQLitePCLRaw bundle 3.0.2 and explicitly pinned SourceGear SQLite 3.50.4.2 instead.
SourceGear's native package lacks a NuGet license expression; the audit has an
exact-version/repository-identity exception for its public-domain SQLite build.
NativeAOT publishing/trimming has not been verified for this solution.
- **Previous control-plane checkpoint limits:** CLI binds loopback; positional arguments are data
directory and TCP port. Guests are enabled there, and the hosting API can disable
them. Accounts can be provisioned through `AccountStore` or native administration;
automatic bootstrap/admin CLI is pending. Authentication enters unprotected Lobby
id 1 subject to capacity. Server owns text sender ids/timestamps. Connections cap
at 64; queues cap at 32 incoming/64 outgoing envelopes, payloads at 64 KiB (shared
framer allows 16 MiB). Slow consumers disconnect. TLS handshake timeout is 15 s;
receive-idle timeout after handshake is 60 s. No UDP port/media features are
advertised, and voice subscription fails explicitly. Protected joins, channel CRUD,
streams, SFU, moderation/admin handlers, configuration compatibility and full reaper
behavior remain pending. **Do not mark Phase 4 or voice interoperability complete.**
To reproduce the last successful validation on Windows, run in **PowerShell**:
```powershell
./dotnet/build-native.ps1 -Generator Ninja -CCompiler C:/tools/msys64/ucrt64/bin/cc.exe
cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON
cmake --build --preset dev
ctest --preset dev
dotnet restore dotnet/VoiceCat.slnx --locked-mode
$env:VOICECAT_TLS_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-tls-oracle.exe).Path
$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
$env:VOICECAT_VOICE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-voice-oracle.exe).Path
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
./dotnet/check-licenses.ps1
```
Last results: **160/160 managed tests, no skips with those variables set; 29/29 native
CTest tests; warning-free Release build; 22 package licenses approved; locked restore
and `git diff --check` passed.** Without the variables, native interoperability tests
skip; that is not equivalent verification. Desktop CI stages codec/DSP bindings on
Windows/Linux/macOS. Its Linux C++ job requires TLS, CLI and database interoperability
and regenerates wire/password/DSP fixtures. Only Windows was run locally this session.
**Voice behavior now verified:** real clients bind UDP and exchange encrypted Opus,
preserving SSRC/timestamp/flags while resealing with recipient-specific counters.
Never decode audio on the SFU. The two-C++-client voice/text criterion passes;
the remaining Phase 4 server behaviors still need implementation and conformance tests.
- **Done (2026-09-15): Codec/DSP desktop port.** TLS checkpoint `2df79cd` committed
and pushed to `origin/dotnet/foundations`. Added span-based Opus wrappers, safe native
handle ownership, RNNoise processing, monotonic energy VAD, and fixed-signature
native bindings. Round-trip/PLC behavior passes across 40 supported formats with
the existing Opus build. Independent native staging builds checksum-pinned upstream
Opus 1.5.2 with DRED enabled and the existing RNNoise model. Actual dropped-frame
DRED recovery passes across 40 decoder formats; DRED encoding at 8/12 kHz is
explicitly rejected (tests encode those packets at 16 kHz). This release requires
a 30 ms redundancy floor; the older 20 ms setting emits no DRED. C++ denoising
conformance is within one PCM unit, and 1,000 codec/DSP cycles allocate zero managed
bytes. **Verified:** Release build with no warnings; 127/127 managed tests with
native TLS interoperability enabled; native dev build and 29/29 CTest tests green;
16 permissive NuGet licenses. MinGW and Visual Studio native builds pass. Desktop
CI now builds/stages the bindings before testing. iOS static packaging and device
audio remain later phases. **Next:** Phase 4 managed server; prove persisted
libsodium Argon2id hash compatibility before account/database implementation.
- **Done (2026-09-15): TLS exporter interoperability and persisted credentials.** Foundation commit
`b76181d` pushed to `origin/dotnet/foundations`. Added a nonblocking managed TLS 1.3
session with certificate acceptance gate and directional media factories. Managed
loopback and C++ interoperability pass; exporter keys are captured inside BouncyCastle's
handshake callback before its exporter secrets are destroyed. The C++ TLS oracle
authenticates encrypted challenges in both directions against the existing mbedTLS
context. Added explicit persisted TOFU pins, compatible Ed25519 identity/PEM import,
new certificate identity SAN, and rejection of incomplete credential sets.
**Verified:** 42/42 managed tests with the native TLS oracle enabled; native dev build
and 29/29 CTest tests green; 16 permissive package licenses verified. Complete socket
orchestration and managed server/client state remain pending.
**Next:** Phase 3 codec/DSP wrappers and native packaging.
- **Done (2026-09-15): Initial .NET wire/crypto port** on `dotnet/foundations`, from `cs-port`.
Added `dotnet/` solution, schema code generation, pipe framing, immutable voice headers,
directional media encryption/decryption, and xUnit conformance tests. Both platform
and managed crypto paths are tested. Added optional C++ fixture oracle, managed CI,
dependency lock files, license audit, and `docs/api-dotnet.md`. **Verified:** managed
Release build, 34/34 tests including C++ golden bytes, and 16 permissive package
licenses. Fresh `cmake --build --preset dev` and `ctest --preset dev` green (29/29);
regenerating the C++ fixtures produces identical bytes. Native codec/audio packaging
is deferred to its implementation phase. Next checkpoint: BouncyCastle TLS 1.3
exporter interoperability with the existing server.
- **Done (2026-07-23):** **First comment-density cleanup across core, server, and native - **Done (2026-07-23):** **First comment-density cleanup across core, server, and native
clients.** Condensed comments in the highest-noise audio, reconnect, registry, and binding clients.** Condensed comments in the highest-noise audio, reconnect, registry, and binding
files; removed implementation history and narration; retained ABI ownership, threading, files; removed implementation history and narration; retained ABI ownership, threading,
+20 -72
View File
@@ -1,95 +1,43 @@
# VoiceCat — Windows client # VoiceCat — Windows client
WinForms (.NET 10 LTS) UI over `voicecat.dll` (MinGW-built `libvoicecat` shared library). The WinForms .NET 10 client uses `VoiceCat.Core` for TLS, TOFU, protocol state and encrypted UDP, and `VoiceCat.Audio` for streams, jitter, Opus and mixing. Its only native runtime component is `voicecat_media.dll`, the narrow Opus/RNNoise C shim. The old `voicecat.dll` core is no longer loaded or published.
## Prerequisites ## Build
| Tool | Version | Notes | Stage the pinned native media dependencies once, then build the solution:
|------|---------|-------|
| .NET SDK | 10.0.x | `dotnet --version` should report `10.0.*` |
| CMake | 3.25+ | For building the C++ DLL |
| MinGW-w64 / MSYS2 UCRT64 | GCC 13+ | `C:\tools\msys64\ucrt64` is the expected location |
| vcpkg | any | `VCPKG_ROOT` env var must point to a bootstrapped clone |
## Build order
### 1. Build the server (for testing)
```powershell ```powershell
cmake --preset dev ./dotnet/build-native.ps1
cmake --build --preset dev --target voicecat-server dotnet restore clients/windows/VoiceCat.slnx --locked-mode
dotnet build clients/windows/VoiceCat.slnx -c Release --no-restore
``` ```
### 2. Build the DLL The original `VoiceCat.Interop` project remains in the repository as a migration oracle. The app references `VoiceCat.Managed`, whose compatibility facade lets the existing accessible WinForms UI keep its event-pump shape while all networking and audio state live in the idiomatic managed libraries.
## Publish
```powershell ```powershell
cmake --preset windows-client ./clients/windows/publish-client.ps1
cmake --build --preset windows-client
``` ```
Output: `build/windows-client/bin/voicecat.dll` This produces a self-contained `win-x64` distribution in `dotnet/artifacts/client/win-x64`. The script requires `voicecat_media.dll` and fails if the legacy `voicecat.dll` appears.
**Verify no MinGW runtime dependencies remain:** The noninteractive startup and real WASAPI device check is:
```powershell
& "C:\tools\msys64\ucrt64\bin\objdump.exe" -p build/windows-client/bin/voicecat.dll |
Select-String "DLL Name"
```
Expected: only Windows system DLLs (`KERNEL32.dll`, `WS2_32.dll`, `BCRYPT.dll`, etc.).
If `libgcc_s_seh-1.dll`, `libstdc++-6.dll`, or `libwinpthread-1.dll` appear, the
`-static-libgcc -static-libstdc++ -static -lwinpthread` link flags in `core/CMakeLists.txt`
are not taking effect — check the CMake log for the `VOICECAT_BUILD_SHARED+WIN32` branch.
### 3. Build the C# solution
```powershell ```powershell
cd clients/windows ./dotnet/artifacts/client/win-x64/VoiceCat.App.exe --smoke-test --audio
dotnet build VoiceCat.slnx
``` ```
The app's `Directory.Build.props` copies `voicecat.dll` from `../../build/windows-client/bin/` `--smoke-test` constructs the real main form and pumps the managed client. `--audio` additionally opens the default WASAPI capture and render endpoints, moves PCM through both for three seconds, and fails if capture produces no samples.
into the output directory automatically on every build.
## Running manually ## Run manually
```powershell ```powershell
# Terminal 1 — start the server # Terminal 1
./build/dev/bin/voicecat-server.exe --name "My Server" ./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe
# Terminal 2 — launch the client # Terminal 2
dotnet run --project clients/windows/VoiceCat.App/VoiceCat.App.csproj dotnet run --project clients/windows/VoiceCat.App/VoiceCat.App.csproj
``` ```
On first connect to a new server: Manual release validation still includes NVDA navigation and a ten-minute two-client listen test, as required by `docs/porting-to-dotnet.md`.
- Enter `127.0.0.1` as the host (not `localhost` — Windows resolves `localhost` to `::1`
first, and while the server now dual-stacks, `127.0.0.1` is cleaner for local testing).
- The server identity dialog will appear. The TLS leaf-cert SHA-256 fingerprint is shown;
accept to pin it. Subsequent connects to the same server will be silent (MATCHED).
## M5 — Moderation & admin UI
The WinForms client now exposes all M5 operations through the main menu and context menus:
- **Admin → Server accounts…** — create, reset password, and delete server accounts
(requires `can_admin_accounts`).
- **Channel tree right-click** — create, edit, and delete channels. The edit dialog exposes the
full per-channel Opus configuration: mono/stereo, sample rate, bitrate, frame size,
application mode, FEC, expected packet loss, DTX, and complexity.
- **User list right-click** — move, kick, ban, server mute/deafen, and set permissions
(items are gated by your own permissions).
- **Activity log** shows async `GenericResult` feedback for every moderation request.
- **User list** shows text indicators for self-mute, self-deafen, server-mute, and
server-deafen states.
These operations require an admin-provisioned account with the appropriate permissions; the
connect dialog already supports username/password auth.
## Known limitations
- **PTT is focus-scoped** — the push-to-talk key only works while the VoiceCat window has
focus. A system-wide `WH_KEYBOARD_LL` hook is not used in v1 (permissions + AV risk).
- **Receive-side noise reduction** checkbox in per-user tuning is wired end-to-end but is a
passthrough no-op until a real APM/NS backend is built (no working Windows/MSVC port of
`webrtc-audio-processing` upstream — see `docs/tech-stack.md §1`).
- **TOFU pins the TLS leaf cert**, not the declared Ed25519 identity fingerprint. Both are
shown in the identity dialog, but the cert fingerprint is the value that is actually
verified on reconnect. See `docs/security.md §1.1`.
@@ -16,7 +16,7 @@ public sealed record InputDeviceInfo(string Id, string Name, bool IsDefault)
/// the aux device is opened client-side and needs a WASAPI id, not a miniaudio one.</summary> /// the aux device is opened client-side and needs a WASAPI id, not a miniaudio one.</summary>
public static class InputDeviceEnumerator public static class InputDeviceEnumerator
{ {
public static IReadOnlyList<InputDeviceInfo> List() public static IReadOnlyList<InputDeviceInfo> List(bool input = true)
{ {
var result = new List<InputDeviceInfo>(); var result = new List<InputDeviceInfo>();
InputDeviceCapture.IMMDeviceEnumerator? enumerator = null; InputDeviceCapture.IMMDeviceEnumerator? enumerator = null;
@@ -29,7 +29,7 @@ public static class InputDeviceEnumerator
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!; Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
// Resolve the default capture endpoint id so the picker can flag it. // Resolve the default capture endpoint id so the picker can flag it.
if (enumerator.GetDefaultAudioEndpoint(1 /*eCapture*/, 0 /*eConsole*/, if (enumerator.GetDefaultAudioEndpoint(input ? 1 : 0, 0 /*eConsole*/,
out var defDev) == 0 && defDev != null) out var defDev) == 0 && defDev != null)
{ {
try { if (defDev.GetId(out string id) == 0) defaultId = id; } try { if (defDev.GetId(out string id) == 0) defaultId = id; }
@@ -37,7 +37,7 @@ public static class InputDeviceEnumerator
} }
// DEVICE_STATE_ACTIVE = 0x1 — only currently-usable endpoints. // DEVICE_STATE_ACTIVE = 0x1 — only currently-usable endpoints.
if (enumerator.EnumAudioEndpoints(1 /*eCapture*/, 0x1, out collectionPtr) != 0 if (enumerator.EnumAudioEndpoints(input ? 1 : 0, 0x1, out collectionPtr) != 0
|| collectionPtr == IntPtr.Zero) || collectionPtr == IntPtr.Zero)
return result; return result;
@@ -107,6 +107,7 @@ public sealed class InputDeviceCapture : IDisposable
private const int FrameSamples = 960; // 20 ms private const int FrameSamples = 960; // 20 ms
private readonly string? _deviceId; // null = system default capture endpoint private readonly string? _deviceId; // null = system default capture endpoint
private readonly bool _loopback;
private IAudioClient? _audioClient; private IAudioClient? _audioClient;
private IAudioCaptureClient? _captureClient; private IAudioCaptureClient? _captureClient;
@@ -123,8 +124,9 @@ public sealed class InputDeviceCapture : IDisposable
// Init-done signal: Set() by the capture thread after activation completes. // Init-done signal: Set() by the capture thread after activation completes.
private readonly ManualResetEventSlim _initDone = new(false); private readonly ManualResetEventSlim _initDone = new(false);
private bool _initOk; private bool _initOk;
private int _disposed;
public InputDeviceCapture(string? deviceId) => _deviceId = deviceId; public InputDeviceCapture(string? deviceId, bool loopback = false) { _deviceId = deviceId; _loopback = loopback; }
/// <summary>Starts capture. Blocks until WASAPI activation completes (typically &lt;100 ms). /// <summary>Starts capture. Blocks until WASAPI activation completes (typically &lt;100 ms).
/// Returns false if the device cannot be opened.</summary> /// Returns false if the device cannot be opened.</summary>
@@ -148,15 +150,13 @@ public sealed class InputDeviceCapture : IDisposable
{ {
_running = false; _running = false;
_bufferEvent?.Set(); _bufferEvent?.Set();
_captureThread?.Join(500); if (_captureThread is not null && !_captureThread.Join(5000)) throw new TimeoutException("Capture did not stop.");
try { _audioClient?.Stop(); } catch { /* device already gone */ }
} }
public void Dispose() public void Dispose()
{ {
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
Stop(); Stop();
if (_captureClient != null) { Marshal.ReleaseComObject(_captureClient); _captureClient = null; }
if (_audioClient != null) { Marshal.ReleaseComObject(_audioClient); _audioClient = null; }
_bufferEvent?.Dispose(); _bufferEvent?.Dispose();
_initDone.Dispose(); _initDone.Dispose();
} }
@@ -165,10 +165,19 @@ public sealed class InputDeviceCapture : IDisposable
private void CaptureThreadProc() private void CaptureThreadProc()
{ {
_initOk = ActivateAndStart(); try
_initDone.Set(); {
if (!_initOk) return; _initOk = ActivateAndStart();
CaptureLoop(); _initDone.Set();
if (_initOk && _running) CaptureLoop();
}
catch { _initOk = false; _initDone.Set(); }
finally
{
try { _audioClient?.Stop(); } catch { }
if (_captureClient != null) { Marshal.ReleaseComObject(_captureClient); _captureClient = null; }
if (_audioClient != null) { Marshal.ReleaseComObject(_audioClient); _audioClient = null; }
}
} }
private bool ActivateAndStart() private bool ActivateAndStart()
@@ -192,7 +201,7 @@ public sealed class InputDeviceCapture : IDisposable
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!; Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
int hr = _deviceId is null int hr = _deviceId is null
? enumerator.GetDefaultAudioEndpoint(1 /*eCapture*/, 0 /*eConsole*/, out device) ? enumerator.GetDefaultAudioEndpoint(_loopback ? 0 : 1, 0 /*eConsole*/, out device)
: enumerator.GetDevice(_deviceId, out device); : enumerator.GetDevice(_deviceId, out device);
if (hr != 0 || device == null) return false; if (hr != 0 || device == null) return false;
@@ -220,7 +229,7 @@ public sealed class InputDeviceCapture : IDisposable
// AUDCLNT_STREAMFLAGS_EVENTCALLBACK = 0x00040000 // AUDCLNT_STREAMFLAGS_EVENTCALLBACK = 0x00040000
// AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM = 0x80000000 // AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM = 0x80000000
// AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY = 0x08000000 // AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY = 0x08000000
const uint streamFlags = 0x00040000u | 0x80000000u | 0x08000000u; uint streamFlags = 0x00040000u | 0x80000000u | 0x08000000u | (_loopback ? 0x00020000u : 0u);
foreach (int ch in new[] { 2, 1 }) foreach (int ch in new[] { 2, 1 })
{ {
@@ -327,9 +336,7 @@ public sealed class InputDeviceCapture : IDisposable
private void FlushFrame() private void FlushFrame()
{ {
var copy = new short[_accumBuf.Length]; PcmFrameReady?.Invoke(_accumBuf, FrameSamples, _channels); // Borrowed until callback returns.
_accumBuf.AsSpan().CopyTo(copy);
PcmFrameReady?.Invoke(copy, FrameSamples, _channels);
_accumCount = 0; _accumCount = 0;
} }
@@ -1,148 +1,85 @@
using VoiceCat.Audio;
using VoiceCat.Interop; using VoiceCat.Interop;
// Owns N ProcessLoopbackCapture instances, mixes their PCM every 20 ms, and feeds
// the result to the core via vc_stream_feed_pcm. Used for per-app audio sharing.
namespace VoiceCat.App.Audio; namespace VoiceCat.App.Audio;
// Capture threads own their ring producers; the mix thread alone consumes them.
public sealed class ProcessAudioMixer : IDisposable public sealed class ProcessAudioMixer : IDisposable
{ {
private const int SampleRate = 48000; private sealed class Input
private const int FrameSamples = 960; {
private const int Channels = 2; // stereo; captures fall back to mono if needed internal readonly PcmRing Ring = new(16384);
private readonly short[] stereo = new short[1920];
private readonly List<ProcessLoopbackCapture> _captures = []; internal void Feed(short[] pcm, int channels)
{
// Per-capture latest frame, protected by _frameLock. if (channels == 2) { Ring.TryWrite(pcm); return; }
private readonly object _frameLock = new(); if (channels != 1 || pcm.Length > 960) return;
private List<short[]> _latestFrames = []; for (int i = 0; i < pcm.Length; i++) stereo[2 * i] = stereo[2 * i + 1] = pcm[i];
private int _activeChannels = Channels; Ring.TryWrite(stereo.AsSpan(0, pcm.Length * 2));
}
private Thread? _mixThread; }
private volatile bool _running; private readonly List<ProcessLoopbackCapture> captures = [];
private VoiceCatClient? _client; private Input[] inputs = [];
private uint _streamId; private Thread? thread;
private volatile bool running;
private VoiceCatClient? client;
private uint streamId;
public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId) public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId)
{ {
if (_running) return; if (running) return;
_client = client; this.client = client; this.streamId = streamId;
_streamId = streamId;
var specs = ResolveCaptures(scope); var specs = ResolveCaptures(scope);
if (specs.Count == 0) inputs = specs.Select(_ => new Input()).ToArray();
try
{ {
// nothing to capture — scope resolved to empty set for (int i = 0; i < specs.Count; i++)
return; {
Input input = inputs[i]; var (pid, mode) = specs[i];
var capture = new ProcessLoopbackCapture(pid, mode);
capture.PcmFrameReady += (pcm, _, channels) => input.Feed(pcm, channels);
captures.Add(capture);
if (!capture.Start()) throw new InvalidOperationException("Process audio capture could not start.");
}
if (inputs.Length == 0) return;
running = true;
thread = new Thread(MixLoop) { IsBackground = true, Name = "ProcessAudioMixer" }; thread.Start();
} }
catch { Stop(); throw; }
lock (_frameLock)
{
_latestFrames = new List<short[]>(new short[specs.Count][]);
_activeChannels = Channels;
}
for (int i = 0; i < specs.Count; i++)
{
int captureIndex = i;
var (pid, mode) = specs[i];
var cap = new ProcessLoopbackCapture(pid, mode);
cap.PcmFrameReady += (pcm, spc, ch) => OnCaptureFrame(captureIndex, pcm, ch);
_captures.Add(cap);
}
foreach (var c in _captures) c.Start();
_running = true;
_mixThread = new Thread(MixLoop) { IsBackground = true, Name = "ProcessAudioMixer" };
_mixThread.Start();
} }
public void Stop() public void Stop()
{ {
_running = false; running = false;
_mixThread?.Join(500); if (thread is not null && !thread.Join(5000)) throw new TimeoutException("Process audio mixer did not stop.");
foreach (var c in _captures) { c.Stop(); c.Dispose(); } foreach (var capture in captures) capture.Dispose();
_captures.Clear(); captures.Clear(); inputs = []; thread = null;
} }
public void Dispose() => Stop(); public void Dispose() => Stop();
// ── Capture callback ──────────────────────────────────────────────────────
private void OnCaptureFrame(int index, short[] pcm, int channels)
{
lock (_frameLock)
{
// Upmix mono → stereo interleave if the capture fell back to mono.
if (channels == 1 && _activeChannels == 2)
pcm = MonoToStereo(pcm);
if (index < _latestFrames.Count)
_latestFrames[index] = pcm;
}
}
// ── Mix loop (20 ms timer) ────────────────────────────────────────────────
private void MixLoop() private void MixLoop()
{ {
// Use a target period close to 20 ms; small under-shoot avoids accumulating drift. var frame = new short[1920]; var mix = new short[1920]; var sums = new int[1920];
const int periodMs = 19; long deadline = System.Diagnostics.Stopwatch.GetTimestamp();
while (_running) while (running)
{ {
Thread.Sleep(periodMs); sums.AsSpan().Clear();
if (!_running) break; foreach (Input input in inputs)
short[] mix;
lock (_frameLock)
{ {
int len = FrameSamples * _activeChannels; while (input.Ring.Count > 11520) input.Ring.Read(frame);
mix = new short[len]; frame.AsSpan().Clear(); input.Ring.Read(frame);
for (int i = 0; i < frame.Length; i++) sums[i] += frame[i];
foreach (var frame in _latestFrames)
{
if (frame == null) continue;
int frameLen = Math.Min(frame.Length, len);
for (int i = 0; i < frameLen; i++)
{
int sum = mix[i] + frame[i];
mix[i] = (short)Math.Clamp(sum, short.MinValue, short.MaxValue);
}
}
} }
for (int i = 0; i < mix.Length; i++) mix[i] = (short)Math.Clamp(sums[i], short.MinValue, short.MaxValue);
_client?.StreamFeedPcm(_streamId, mix, FrameSamples, (uint)_activeChannels); client?.StreamFeedPcm(streamId, mix, 960, 2);
deadline += System.Diagnostics.Stopwatch.Frequency / 50;
double wait = (deadline - System.Diagnostics.Stopwatch.GetTimestamp()) * 1000.0 / System.Diagnostics.Stopwatch.Frequency;
if (wait > 0) Thread.Sleep((int)Math.Ceiling(wait));
else if (wait < -100) deadline = System.Diagnostics.Stopwatch.GetTimestamp();
} }
} }
private static List<(int pid, ProcessLoopbackCapture.Mode mode)> ResolveCaptures(AppAudioScope scope) => scope switch
// ── Helpers ───────────────────────────────────────────────────────────────
// Resolve the scope to the WASAPI captures to open:
// OnlyApps → one INCLUDE capture per selected process tree.
// AllExceptApps → one EXCLUDE capture of the single selected process tree, which
// natively captures the whole system render mix minus that tree
// (dynamic — apps launched later are included automatically).
private static List<(int pid, ProcessLoopbackCapture.Mode mode)> ResolveCaptures(AppAudioScope scope)
{ {
return scope switch OnlyApps o => o.Pids.Select(p => (p, ProcessLoopbackCapture.Mode.Include)).ToList(),
{ AllExceptApps a when a.Pids.Count > 0 => [(a.Pids[0], ProcessLoopbackCapture.Mode.Exclude)],
OnlyApps o => o.Pids.Select(p => (p, ProcessLoopbackCapture.Mode.Include)).ToList(), EntireDesktop { ExcludeSelf: true } => [(Environment.ProcessId, ProcessLoopbackCapture.Mode.Exclude)],
AllExceptApps a when a.Pids.Count > 0 => _ => [],
[(a.Pids[0], ProcessLoopbackCapture.Mode.Exclude)], };
// Entire desktop minus VoiceCat itself: EXCLUDE our own process tree.
EntireDesktop { ExcludeSelf: true } =>
[(Environment.ProcessId, ProcessLoopbackCapture.Mode.Exclude)],
_ => [],
};
}
private static short[] MonoToStereo(short[] mono)
{
var stereo = new short[mono.Length * 2];
for (int i = 0; i < mono.Length; i++)
{
stereo[i * 2] = mono[i];
stereo[i * 2 + 1] = mono[i];
}
return stereo;
}
} }
@@ -64,15 +64,15 @@ public sealed class ProcessLoopbackCapture : IDisposable
{ {
_running = false; _running = false;
_bufferEvent?.Set(); _bufferEvent?.Set();
_captureThread?.Join(500); if (_captureThread is not null && !_captureThread.Join(5000))
if (_audioClientPtr != IntPtr.Zero) AC_Stop(_audioClientPtr); throw new TimeoutException("Process capture did not stop.");
} }
private int _disposed;
public void Dispose() public void Dispose()
{ {
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
Stop(); Stop();
ComRelease(ref _captureClientPtr);
ComRelease(ref _audioClientPtr);
_bufferEvent?.Dispose(); _bufferEvent?.Dispose();
_initDone.Dispose(); _initDone.Dispose();
} }
@@ -81,10 +81,23 @@ public sealed class ProcessLoopbackCapture : IDisposable
private void CaptureThreadProc() private void CaptureThreadProc()
{ {
_initOk = ActivateAndStart(); try
_initDone.Set(); {
if (!_initOk) return; _initOk = ActivateAndStart();
CaptureLoop(); _initDone.Set();
if (_initOk && _running) CaptureLoop();
}
catch
{
_initOk = false;
_initDone.Set();
}
finally
{
if (_audioClientPtr != IntPtr.Zero) AC_Stop(_audioClientPtr);
ComRelease(ref _captureClientPtr);
ComRelease(ref _audioClientPtr);
}
} }
private bool ActivateAndStart() private bool ActivateAndStart()
@@ -274,9 +287,9 @@ public sealed class ProcessLoopbackCapture : IDisposable
private void FlushFrame() private void FlushFrame()
{ {
var copy = new short[_accumBuf.Length]; // The callback borrows this buffer until it returns. This capture owner cannot
_accumBuf.AsSpan().CopyTo(copy); // overwrite it concurrently, which avoids one allocation every 20 ms.
PcmFrameReady?.Invoke(copy, FrameSamples, _channels); PcmFrameReady?.Invoke(_accumBuf, FrameSamples, _channels);
_accumCount = 0; _accumCount = 0;
} }
@@ -0,0 +1,113 @@
using System.Runtime.InteropServices;
using VoiceCat.Audio;
using static VoiceCat.App.Audio.InputDeviceCapture;
namespace VoiceCat.App.Audio;
public sealed class WasapiAudioBackend : IAudioDeviceBackend
{
public IReadOnlyList<AudioDeviceInfo> Enumerate(bool input) => InputDeviceEnumerator.List(input).Select(d => new AudioDeviceInfo(d.Id, d.Name, d.IsDefault)).ToArray();
public IAudioCapture OpenCapture(string? deviceId, bool loopback, CapturePcmHandler handler)
{
var capture = new InputDeviceCapture(NormalizeDeviceId(deviceId), loopback);
capture.PcmFrameReady += (pcm, _, channels) => handler(pcm, channels);
if (!capture.Start()) { capture.Dispose(); throw new InvalidOperationException("WASAPI capture could not start."); }
return new Capture(capture);
}
public IAudioPlayback OpenPlayback(string? deviceId = null) => new Playback(deviceId);
// The old miniaudio ABI persisted its raw device union as hex. Its Windows member
// is a null-terminated UTF-16 WASAPI endpoint id; preserve saved selections.
internal static string? NormalizeDeviceId(string? id)
{
if (string.IsNullOrEmpty(id)) return null;
if (id.StartsWith('{')) return id;
try
{
byte[] bytes = Convert.FromHexString(id);
string decoded = System.Text.Encoding.Unicode.GetString(bytes).Split('\0')[0];
return decoded.StartsWith('{') ? decoded : null;
}
catch (FormatException) { return id; }
}
private sealed class Capture(InputDeviceCapture capture) : IAudioCapture { public void Dispose() => capture.Dispose(); }
private sealed class Playback : IAudioPlayback
{
private readonly string? deviceId;
private readonly PcmRing pcm = new(32768);
private readonly ManualResetEventSlim initialized = new(false);
private readonly Thread thread;
private volatile bool running = true;
private bool ready;
private int disposed;
internal Playback(string? deviceId)
{
this.deviceId = deviceId;
thread = new Thread(Work) { IsBackground = true, Name = "VoiceCat WASAPI playback" };
thread.Start();
if (!initialized.Wait(5000) || !ready) { Dispose(); throw new InvalidOperationException("WASAPI playback could not start."); }
}
public void Write(ReadOnlySpan<short> stereoPcm) => pcm.TryWrite(stereoPcm);
private unsafe void Work()
{
IMMDeviceEnumerator? enumerator = null; IMMDevice? device = null; IAudioClient? client = null; IAudioRenderClient? render = null;
using var bufferReady = new AutoResetEvent(false);
try
{
enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
int result = deviceId is null ? enumerator.GetDefaultAudioEndpoint(0, 0, out device) : enumerator.GetDevice(deviceId, out device);
if (result < 0 || device is null) return;
Guid iid = typeof(IAudioClient).GUID;
if (device.Activate(ref iid, 0x17, 0, out object audio) < 0) return;
client = (IAudioClient)audio;
WaveFormat format = new() { Format = 1, Channels = 2, Samples = 48000, Bytes = 192000, Align = 4, Bits = 16 };
if (client.Initialize(0, 0x00040000u | 0x80000000u | 0x08000000u, 600000, 0, (nint)(&format), 0) < 0) return;
if (client.GetBufferSize(out uint capacity) < 0 || client.SetEventHandle(bufferReady.SafeWaitHandle.DangerousGetHandle()) < 0) return;
iid = typeof(IAudioRenderClient).GUID;
if (client.GetService(ref iid, out object output) < 0) return;
render = (IAudioRenderClient)output;
if (client.Start() < 0) return;
ready = true; initialized.Set();
Span<short> discard = stackalloc short[1920];
while (running)
{
bufferReady.WaitOne(20); // Scheduling wait is outside the buffer-fill cycle.
if (!running || client.GetCurrentPadding(out uint padding) < 0) break;
uint frames = capacity - Math.Min(capacity, padding);
if (frames == 0) continue;
if (render.GetBuffer(frames, out nint buffer) < 0) break;
var destination = new Span<short>((void*)buffer, checked((int)frames * 2));
// Keep queued playback bounded to ~120 ms, then fill underflow with silence.
while (pcm.Count > 11520) pcm.Read(discard);
int count = pcm.Read(destination); destination[count..].Clear();
if (render.ReleaseBuffer(frames, 0) < 0) break;
}
}
catch { ready = false; }
finally
{
initialized.Set();
try { client?.Stop(); } catch { }
if (render is not null) Marshal.ReleaseComObject(render);
if (client is not null) Marshal.ReleaseComObject(client);
if (device is not null) Marshal.ReleaseComObject(device);
if (enumerator is not null) Marshal.ReleaseComObject(enumerator);
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
running = false;
if (!thread.Join(5000)) throw new TimeoutException("WASAPI playback did not stop.");
initialized.Dispose();
}
}
[StructLayout(LayoutKind.Sequential, Pack = 2)]
private struct WaveFormat { internal ushort Format, Channels; internal uint Samples, Bytes; internal ushort Align, Bits, Extra; }
[ComImport, Guid("F294ACFC-3146-4483-A7BF-ADDCA7C260E2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IAudioRenderClient
{
[PreserveSig] int GetBuffer(uint frames, out nint data);
[PreserveSig] int ReleaseBuffer(uint frames, uint flags);
}
}
@@ -119,7 +119,7 @@ public partial class ConnectDialog : Form
Directory.CreateDirectory(tofuDir); Directory.CreateDirectory(tofuDir);
_client = new VoiceCatClient("VoiceCat-Windows", VoiceCatClient.VersionString, _client = new VoiceCatClient("VoiceCat-Windows", VoiceCatClient.VersionString,
VcLogLevel.Info, ServerListStore.TofuStorePath); VcLogLevel.Info, ServerListStore.TofuStorePath, new VoiceCat.App.Audio.WasapiAudioBackend());
_client.EventReceived += OnEvent; _client.EventReceived += OnEvent;
_identityDialogShown = false; _identityDialogShown = false;
_pumpTimer.Start(); _pumpTimer.Start();
+27 -2
View File
@@ -5,7 +5,7 @@ namespace VoiceCat.App;
internal static class Program internal static class Program
{ {
[STAThread] [STAThread]
private static void Main() private static int Main(string[] args)
{ {
// Surface exceptions that WinForms' default message-loop handling would otherwise // Surface exceptions that WinForms' default message-loop handling would otherwise
// swallow silently (or crash with no visible cause). // swallow silently (or crash with no visible cause).
@@ -16,12 +16,37 @@ internal static class Program
ApplicationConfiguration.Initialize(); ApplicationConfiguration.Initialize();
if (args.Contains("--smoke-test"))
{
try
{
using var client = new VoiceCat.Interop.VoiceCatClient("Smoke", "test");
using var form = new MainForm(client, 0, "Smoke", "Managed core");
form.CreateControl();
if (form.Controls.Count == 0 || string.IsNullOrEmpty(form.Text)) return 1;
client.PumpEvents();
if (args.Contains("--audio"))
{
var backend = new Audio.WasapiAudioBackend();
using var playback = backend.OpenPlayback();
int samples = 0;
using var capture = backend.OpenCapture(null, false, (pcm, _) => Interlocked.Add(ref samples, pcm.Length));
short[] silence = new short[1920];
for (int i = 0; i < 150; i++) { playback.Write(silence); Thread.Sleep(20); }
if (samples == 0) return 2;
}
return 0;
}
catch { return 1; }
}
using var connectDialog = new ConnectDialog(); using var connectDialog = new ConnectDialog();
var result = connectDialog.ShowDialog(); var result = connectDialog.ShowDialog();
if (result != DialogResult.OK || connectDialog.ConnectedClient is null) if (result != DialogResult.OK || connectDialog.ConnectedClient is null)
return; return 0;
Application.Run(new MainForm(connectDialog.ConnectedClient, connectDialog.SelfUserId, Application.Run(new MainForm(connectDialog.ConnectedClient, connectDialog.SelfUserId,
connectDialog.Nickname, connectDialog.ServerName)); connectDialog.Nickname, connectDialog.ServerName));
return 0;
} }
} }
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\VoiceCat.Interop\VoiceCat.Interop.csproj" /> <ProjectReference Include="..\VoiceCat.Managed\VoiceCat.Managed.csproj" />
</ItemGroup> </ItemGroup>
<!-- Prismatoid — .NET bindings for the Prism speech library, used for spoken event <!-- Prismatoid — .NET bindings for the Prism speech library, used for spoken event
@@ -42,18 +42,6 @@
as part of the Windows Desktop shared framework on net10.0-windows — no PackageReference as part of the Windows Desktop shared framework on net10.0-windows — no PackageReference
needed (one was tried and NuGet flagged it as redundant/unprunable, NU1510). --> needed (one was tried and NuGet flagged it as redundant/unprunable, NU1510). -->
<!-- voicecat.dll must exist (build the `windows-client` CMake preset first — see
clients/windows/README.md). -->
<ItemGroup>
<Content Include="$(VoiceCatNativeDir)\voicecat.dll" Condition="Exists('$(VoiceCatNativeDir)\voicecat.dll')">
<Link>voicecat.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<Target Name="VoiceCatCheckNativeDll" BeforeTargets="Build">
<Error Condition="!Exists('$(VoiceCatNativeDir)\voicecat.dll')"
Text="voicecat.dll not found at '$(VoiceCatNativeDir)'. Build it first: cmake --preset windows-client &amp;&amp; cmake --build --preset windows-client (see clients/windows/README.md)." />
</Target>
</Project> </Project>
@@ -0,0 +1,77 @@
{
"version": 1,
"dependencies": {
"net10.0-windows7.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
},
"Prismatoid": {
"type": "Direct",
"requested": "[0.3.0, )",
"resolved": "0.3.0",
"contentHash": "mUOgtmFtjbLxydrdAImkfP3u0U8qrYkvfx2NlQqdQQ8TB9qe59BobxBCcNDo21g8xSefkBJB1VdGPbVTfiE7zw=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.managed": {
"type": "Project",
"dependencies": {
"VoiceCat.Core": "[1.0.0, )"
}
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0-windows7.0/win-x64": {
"Prismatoid": {
"type": "Direct",
"requested": "[0.3.0, )",
"resolved": "0.3.0",
"contentHash": "mUOgtmFtjbLxydrdAImkfP3u0U8qrYkvfx2NlQqdQQ8TB9qe59BobxBCcNDo21g8xSefkBJB1VdGPbVTfiE7zw=="
}
}
}
}
@@ -272,6 +272,8 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result); Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000)); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000));
Assert.Equal(VcResult.Ok, client.JoinVoice());
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000));
// Give the async UDP binding handshake a moment to land before announcing a stream // Give the async UDP binding handshake a moment to land before announcing a stream
// (mirrors vccli's 500ms sleep after auth). // (mirrors vccli's 500ms sleep after auth).
Thread.Sleep(500); Thread.Sleep(500);
@@ -340,6 +342,10 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.JoinResult), 5000), Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.JoinResult), 5000),
"B did not receive VC_EVENT_JOIN_RESULT"); "B did not receive VC_EVENT_JOIN_RESULT");
Assert.Equal(VcResult.Ok, a.JoinVoice());
Assert.Equal(VcResult.Ok, b.JoinVoice());
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000));
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000));
// UDP binding handshake is async; give it a moment (mirrors ScreenAudio test). // UDP binding handshake is async; give it a moment (mirrors ScreenAudio test).
Thread.Sleep(500); Thread.Sleep(500);
@@ -9,5 +9,6 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RootNamespace>VoiceCat.Interop</RootNamespace> <RootNamespace>VoiceCat.Interop</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup><InternalsVisibleTo Include="VoiceCat.Interop.Tests" /></ItemGroup>
</Project> </Project>
@@ -0,0 +1,32 @@
using Voicecat.V1;
namespace VoiceCat.Interop;
public sealed partial class VoiceCatClient
{
public VcResult KickUser(uint id, string? reason = null) => Request(new() { Kick = new() { UserId = id, Reason = reason ?? "" } });
public VcResult BanUser(uint id, string? reason = null, ulong expiresUnixMs = 0) => Request(new() { Ban = new() { UserId = id, Reason = reason ?? "", ExpiresUnixMs = expiresUnixMs } });
public VcResult MoveUser(uint id, uint channelId) => Request(new() { MoveUser = new() { UserId = id, ChannelId = channelId } });
public VcResult SetServerMute(uint id, bool muted, bool deafened) => Request(new() { ServerMute = new() { UserId = id, Muted = muted, Deafened = deafened } });
public VcResult SetPermission(uint id, PermissionsInfo permissions) => Request(new() { SetPermission = new() { UserId = id, Permissions = new()
{ IsAdmin = permissions.IsAdmin, CanCreateTempChannel = permissions.CanCreateTempChannel, CanAdminAccounts = permissions.CanAdminAccounts, CanBan = permissions.CanBan, CanKick = permissions.CanKick, CanMoveUsers = permissions.CanMoveUsers } } });
public VcResult CreateAccount(string username, string password) => Request(new() { CreateAccount = new() { Username = username, Password = password } });
public VcResult ResetPassword(string username, string password) => Request(new() { ResetPassword = new() { Username = username, NewPassword = password } });
public VcResult DeleteAccount(string username) => Request(new() { DeleteAccount = new() { Username = username } });
public VcResult RequestAccountList() => Request(new() { ListAccounts = new() });
public VcResult CreateChannel(ChannelEditInfo info) => Request(new() { CreateChannel = new() { Channel = Channel(info), Password = info.Password ?? "" } });
public VcResult EditChannel(ChannelEditInfo info) => Request(new() { EditChannel = new() { Channel = Channel(info), Password = info.Password ?? "" } });
public VcResult DeleteChannel(uint id) => Request(new() { DeleteChannel = new() { ChannelId = id } });
public VcResult SendText(VcTextScope scope, uint targetId, string body)
{
try { core.Send(new() { TextMessage = new() { Scope = (TextScope)scope, TargetId = targetId, Body = body, ClientMsgId = Guid.NewGuid().ToString("N") } }); return VcResult.Ok; }
catch { return VcResult.NotConnected; }
}
private static Voicecat.V1.Channel Channel(ChannelEditInfo info) => new()
{
Id = info.Id, ParentId = info.ParentId, Name = info.Name, Topic = info.Topic, MaxUsers = info.MaxUsers, Order = unchecked((int)info.SortOrder),
Audio = new() { Codec = info.Audio.Codec, Mode = info.Audio.Stereo ? ChannelMode.ModeStereo : ChannelMode.ModeMono, SampleRate = info.Audio.SampleRate,
BitrateBps = info.Audio.BitrateBps, FrameMs = info.Audio.FrameMs, Application = (OpusApplication)info.Audio.Application, Complexity = info.Audio.Complexity,
Fec = info.Audio.Fec, ExpectedPacketLoss = info.Audio.ExpectedPacketLoss, Dtx = info.Audio.Dtx, Dred = info.Audio.Dred }
};
}
@@ -0,0 +1,106 @@
using VoiceCat.Audio;
using Voicecat.V1;
namespace VoiceCat.Interop;
public sealed partial class VoiceCatClient
{
private sealed record PcmRegistration(nint Callback, nint User);
private PcmRegistration? pcm;
public (VcResult Result, uint StreamId) StartStream(VcStreamKind kind, string label) => Start(kind, label, false);
public (VcResult Result, uint StreamId) StartStreamExternalFeed(VcStreamKind kind, string label) => Start(kind, label, true);
private (VcResult, uint) Start(VcStreamKind kind, string label, bool external)
{
StreamInfo? stream = null;
try
{
stream = core.StartStreamAsync((StreamKind)kind, label).GetAwaiter().GetResult();
local[stream.StreamId] = new(stream, external);
if (!external && backend is not null) captures[stream.StreamId] = Capture(stream);
return (VcResult.Ok, stream.StreamId);
}
catch (Exception exception)
{
if (stream is not null) { local.TryRemove(stream.StreamId, out _); core.StopStream(stream.StreamId); }
Queue(new(VcEventType.Error, Result: VcResult.Audio, Text: exception.Message)); return (VcResult.Audio, 0);
}
}
private IAudioCapture Capture(StreamInfo stream) => backend!.OpenCapture(devices.GetValueOrDefault(stream.StreamId), stream.Kind == StreamKind.StreamScreenAudio,
(samples, channels) => StreamFeedPcm(stream.StreamId, samples, samples.Length / channels, (uint)channels));
public VcResult StopStream(uint id)
{
if (captures.Remove(id, out var capture)) capture.Dispose();
if (!local.TryRemove(id, out LocalStream? stream)) return VcResult.InvalidArg;
try { core.StopStream(stream.Info.StreamId); return VcResult.Ok; } catch { return VcResult.NotConnected; }
}
public VcResult SetInputDevice(uint id, string? device)
{
devices[id] = device;
if (!captures.Remove(id, out var previous)) return VcResult.Ok;
previous.Dispose();
try { var info = local[id].Info.Clone(); info.StreamId = id; captures[id] = Capture(info); return VcResult.Ok; }
catch { return VcResult.Audio; }
}
public VcResult SetCaptureChannels(uint id, uint channels)
{ try { LocalStream stream = local[id]; core.Audio.SetCaptureChannels(stream.Info.StreamId, checked((int)channels)); stream.CaptureChannels = (int)channels; return VcResult.Ok; } catch { return VcResult.InvalidArg; } }
public VcResult AudioRestart()
{
foreach (uint id in captures.Keys.ToArray()) { VcResult result = SetInputDevice(id, devices.GetValueOrDefault(id)); if (result != VcResult.Ok) return result; }
return VcResult.Ok;
}
public VcResult SetInputMode(VcInputMode mode) { if (!Enum.IsDefined(mode)) return VcResult.InvalidArg; core.Audio.InputMode = (AudioInputMode)mode; return VcResult.Ok; }
public VcResult SetVadThreshold(float threshold) { if (!float.IsFinite(threshold) || threshold is < 0 or > 1) return VcResult.InvalidArg; core.Audio.VadThreshold = threshold; return VcResult.Ok; }
public VcResult SetPushToTalk(bool active) { core.Audio.PushToTalk = active; return VcResult.Ok; }
public VcResult SetSelfMute(bool muted, bool deafened) { core.Audio.MicMuted = muted; core.Audio.Deafened = deafened; return VcResult.Ok; }
public VcResult SetOutputVolume(float gain) { if (!float.IsFinite(gain) || gain is < 0 or > 4) return VcResult.InvalidArg; core.Audio.OutputGain = gain; return VcResult.Ok; }
public VcResult SetInputGain(float gain) { if (!float.IsFinite(gain) || gain is < 0 or > 4) return VcResult.InvalidArg; core.Audio.InputGain = gain; return VcResult.Ok; }
public VcResult SetInputNoiseReduction(bool enabled) { core.Audio.InputNoiseReduction = enabled; return VcResult.Ok; }
public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool nr)
{ try { core.Audio.SetRemotePlayback(userId, streamId, gain, muted, nr); return VcResult.Ok; } catch { return VcResult.InvalidArg; } }
public (VcResult Result, RemoteStreamState? State) GetRemoteStream(uint userId, uint streamId)
{
var state = core.Audio.GetRemotePlayback(userId, streamId);
return state is { } value ? (VcResult.Ok, new(value.Gain, value.Muted, value.NoiseReduction)) : (VcResult.InvalidArg, null);
}
public (VcResult Result, AudioConfigInfo? Config) GetStreamAudioConfig(uint userId, uint streamId)
{
var info = core.Users.FirstOrDefault(u => u.Id == userId)?.Streams.FirstOrDefault(s => s.StreamId == streamId);
if (core.Authentication?.Self.Id == userId && local.TryGetValue(streamId, out LocalStream? own)) info = own.Info;
return info is null ? (VcResult.InvalidArg, null) : (VcResult.Ok, Audio(info.Audio));
}
public VcResult StreamFeedPcm(uint id, ReadOnlySpan<short> samples, int samplesPerChannel, uint channels)
{
if (samplesPerChannel < 0 || channels is not (1 or 2) || samples.Length != (long)samplesPerChannel * channels) return VcResult.InvalidArg;
if (!local.TryGetValue(id, out LocalStream? stream)) return VcResult.InvalidArg;
core.Audio.FeedPcm(Volatile.Read(ref stream.Info).StreamId, samples, (int)channels); return VcResult.Ok; // A full real-time ring drops, never waits.
}
public VcResult SetPcmSink(nint callback, nint user) { Volatile.Write(ref pcm, callback == 0 ? null : new(callback, user)); return VcResult.Ok; }
private unsafe void ForwardPcm(uint userId, uint streamId, ReadOnlySpan<short> samples, int channels)
{
var target = Volatile.Read(ref pcm); if (target is null) return;
fixed (short* input = samples)
((delegate* unmanaged[Cdecl]<nint, uint, uint, short*, nuint, uint, uint, void>)target.Callback)(target.User, userId, streamId, input, (nuint)(samples.Length / channels), (uint)channels, 48000);
}
// Server-authoritative moves/edits stop old SSRCs. Reannounce with the new channel
// configuration while keeping UI/external-feed ids stable; captures continue feeding
// the alias and switch to the new audio owner only when negotiation completes.
private void ReconcileStreams()
{
var active = core.LocalStreams;
foreach (LocalStream stream in local.Values)
if (!active.Any(s => s.StreamId == stream.Info.StreamId) && Interlocked.CompareExchange(ref stream.Restarting, 1, 0) == 0) _ = RestartAsync(stream);
}
private async Task RestartAsync(LocalStream stream)
{
try
{
StreamInfo previous = stream.Info;
StreamInfo next = await core.StartStreamAsync(previous.Kind, previous.Label, stream.CaptureChannels).ConfigureAwait(false);
if (!local.ContainsKey(stream.Alias)) core.StopStream(next.StreamId);
else Volatile.Write(ref stream.Info, next);
}
catch (Exception exception) { Queue(new(VcEventType.Error, Result: VcResult.Audio, Text: exception.Message)); }
finally { Volatile.Write(ref stream.Restarting, 0); }
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
<Compile Include="../VoiceCat.Interop/Enums.cs" Link="Enums.cs" />
<Compile Include="../VoiceCat.Interop/Models.cs" Link="Models.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,193 @@
using System.Runtime.InteropServices;
using System.Threading.Channels;
using System.Collections.Concurrent;
using VoiceCat.Audio;
using VoiceCat.Core;
using VoiceCat.Crypto;
using Voicecat.V1;
using CoreClient = VoiceCat.Core.VoiceCatClient;
namespace VoiceCat.Interop;
// Preserves the shipped WinForms call/event surface while its implementation moves to
// the async managed core. Events still reach controls only through PumpEvents on the UI thread.
public sealed partial class VoiceCatClient : IDisposable
{
private readonly CoreClient core;
private readonly IAudioDeviceBackend? backend;
private readonly System.Threading.Channels.Channel<VoiceCatEvent> events = System.Threading.Channels.Channel.CreateUnbounded<VoiceCatEvent>();
private readonly Dictionary<uint, IAudioCapture> captures = [];
private readonly Dictionary<uint, string?> devices = [];
private readonly Dictionary<uint, StreamSummary[]> remoteStreams = [];
private readonly Dictionary<uint, bool> talkState = [];
private sealed class LocalStream(StreamInfo info, bool external)
{
internal readonly uint Alias = info.StreamId;
internal StreamInfo Info = info;
internal readonly bool External = external;
internal int CaptureChannels = 1;
internal int Restarting;
}
private readonly ConcurrentDictionary<uint, LocalStream> local = new();
private IAudioPlayback? playback;
private Task connecting = Task.CompletedTask;
private TaskCompletionSource<bool>? identity;
private int disposed;
private List<AccountInfo> accounts = [];
private bool audioFailureReported;
public event Action<VoiceCatEvent>? EventReceived;
public event Action<uint, float>? LevelChanged;
public CoreClient ManagedClient => core;
public VoiceCatClient(string clientName, string clientVersion, VcLogLevel logLevel = VcLogLevel.Info, string? tofuStorePath = null, IAudioDeviceBackend? audioBackend = null)
{
backend = audioBackend;
core = new(clientName, clientVersion, tofuStorePath);
core.ConnectionStateChanged += state =>
{
VcConnectionState mapped = state switch { ClientConnectionState.Connecting => VcConnectionState.Connecting, ClientConnectionState.VerifyingIdentity => VcConnectionState.VerifyingIdentity,
ClientConnectionState.Authenticating => VcConnectionState.Authenticating, ClientConnectionState.Connected => VcConnectionState.Connected, _ => VcConnectionState.Disconnected };
Queue(new(VcEventType.ConnectionState, ConnectionState: mapped));
if (mapped == VcConnectionState.Disconnected) Queue(new(VcEventType.Disconnected));
};
core.Audio.MixedPcm += pcm => Volatile.Read(ref playback)?.Write(pcm);
core.Audio.StreamPcm += ForwardPcm;
}
private void Queue(VoiceCatEvent message) => events.Writer.TryWrite(message);
public VcResult Connect(string host, ushort port)
{
if (!connecting.IsCompleted || core.State != ClientConnectionState.Disconnected) return VcResult.Already;
connecting = ConnectAsync(host, port);
return VcResult.Ok;
}
private async Task ConnectAsync(string host, ushort port)
{
try
{
await core.ConnectAsync(host, port, async (challenge, token) =>
{
identity = new(TaskCreationOptions.RunContinuationsAsynchronously);
Queue(new(VcEventType.ServerIdentity, U32a: (uint)challenge.Status, Text: challenge.CertificateFingerprint));
return await identity.Task.WaitAsync(token).ConfigureAwait(false);
}).ConfigureAwait(false);
}
catch (Exception exception) { Queue(new(VcEventType.Error, Result: VcResult.Io, Text: exception.Message)); }
}
public VcResult ConfirmServerIdentity(bool accept) { identity?.TrySetResult(accept); return VcResult.Ok; }
public VcResult AuthenticateGuest(string nickname) { _ = AuthenticateAsync(() => core.AuthenticateGuestAsync(nickname)); return VcResult.Ok; }
public VcResult AuthenticateUser(string username, string password) { _ = AuthenticateAsync(() => core.AuthenticateUserAsync(username, password)); return VcResult.Ok; }
private async Task AuthenticateAsync(Func<Task<AuthResult>> authenticate)
{
try { await connecting.ConfigureAwait(false); await authenticate().ConfigureAwait(false); }
catch (Exception exception) { Queue(new(VcEventType.AuthResult, Result: VcResult.AuthFailed, Text: exception.Message)); }
}
public VcResult Disconnect()
{
StopDevices(); core.DisconnectAsync().GetAwaiter().GetResult(); return VcResult.Ok;
}
public string GetServerIdentityDisplay() => core.ServerHello is { } hello ? Convert.ToHexString(hello.ServerIdentityFingerprint.Span) : "";
public void PumpEvents()
{
if (!audioFailureReported && core.Audio.Failure is { } failure) { audioFailureReported = true; Queue(new(VcEventType.Error, Result: VcResult.Audio, Text: failure.Message)); }
while (core.TryReadEvent(out Envelope? message)) Translate(message!);
while (events.Reader.TryRead(out VoiceCatEvent? message)) EventReceived?.Invoke(message);
foreach (LocalStream stream in local.Values)
{
var info = Volatile.Read(ref stream.Info);
var level = core.Audio.GetLocalLevel(info.StreamId); LevelChanged?.Invoke(stream.Alias, level.Level);
if (talkState.GetValueOrDefault(stream.Alias) != level.Talking)
{
talkState[stream.Alias] = level.Talking;
if (core.State == ClientConnectionState.Connected) core.Send(new() { StreamState = new() { StreamId = info.StreamId, Talking = level.Talking, Muted = core.Audio.MicMuted } });
EventReceived?.Invoke(new(VcEventType.TalkState, UserId: core.Authentication?.Self.Id ?? 0, StreamId: stream.Alias, U32a: level.Talking ? 1U : 0));
}
}
}
private void Translate(Envelope message)
{
if (message.AuthResult is not null) Queue(new(VcEventType.AuthResult, Result: message.AuthResult.Ok ? VcResult.Ok : VcResult.AuthFailed, UserId: message.AuthResult.Self?.Id ?? 0, Text: message.AuthResult.Error));
if (message.ServerState is not null) { remoteStreams.Clear(); foreach (User user in message.ServerState.Users) UpdateStreams(user); Queue(new(VcEventType.ChannelList)); }
if (message.ChannelEvent is not null) Queue(new(VcEventType.ChannelList));
if (message.UserEvent is not null)
{
var change = message.UserEvent;
if (change.User is { VoiceSubscribed: true } self && self.Id == core.Authentication?.Self.Id) ReconcileStreams();
if (change.User is not null) UpdateStreams(change.User);
if (change.Kind == UserEvent.Types.Kind.Left) remoteStreams.Remove(change.LeftId);
Queue(new(change.Kind switch { UserEvent.Types.Kind.Joined => VcEventType.UserJoined, UserEvent.Types.Kind.Left => VcEventType.UserLeft, _ => VcEventType.UserUpdated }, UserId: change.User?.Id ?? change.LeftId,
ChannelId: change.User?.ChannelId ?? 0, Text: change.Kind == UserEvent.Types.Kind.Joined ? change.User?.Nickname : change.Reason));
}
if (message.JoinChannelResult is not null) Queue(new(VcEventType.JoinResult, Result: message.JoinChannelResult.Ok ? VcResult.Ok : VcResult.InvalidArg, ChannelId: message.JoinChannelResult.ChannelId, Text: message.JoinChannelResult.Error));
if (message.VoiceSubscriptionResult is not null) Queue(new(VcEventType.VoiceState, U32a: message.VoiceSubscriptionResult.Subscribed ? 1U : 0));
if (message.TextMessage is not null) Queue(new(VcEventType.TextMessage, UserId: message.TextMessage.SenderId, ChannelId: message.TextMessage.TargetId, TextScope: (VcTextScope)message.TextMessage.Scope, Text: message.TextMessage.Body, TimestampUnixMs: message.TextMessage.SentAtUnixMs));
if (message.StreamState is not null) Queue(new(VcEventType.TalkState, UserId: message.StreamState.UserId, StreamId: message.StreamState.UserId == core.Authentication?.Self.Id ? local.Values.FirstOrDefault(s => s.Info.StreamId == message.StreamState.StreamId)?.Alias ?? message.StreamState.StreamId : message.StreamState.StreamId, U32a: message.StreamState.Talking ? 1U : 0));
if (message.GenericResult is not null) Queue(new(VcEventType.GenericResult, Result: message.GenericResult.Ok ? VcResult.Ok : message.GenericResult.Code == 6 ? VcResult.PermissionDenied : VcResult.InvalidArg, U32a: message.GenericResult.Code, Text: message.GenericResult.Message));
if (message.ListAccountsResult is not null)
{
accounts = message.ListAccountsResult.Accounts.Select(a => new AccountInfo(a.Username, a.IsAdmin, a.CreatedAtUnixMs, a.LastLoginUnixMs)).ToList();
Queue(new(VcEventType.AccountList));
}
if (message.Disconnect is not null) Queue(new(VcEventType.Error, Result: VcResult.Io, Text: message.Disconnect.Reason));
}
private void UpdateStreams(User user)
{
StreamSummary[] previous = remoteStreams.GetValueOrDefault(user.Id, []);
StreamSummary[] next = user.Id == core.Authentication?.Self.Id ? ListUserStreams(user.Id).ToArray() : user.Streams.Select(s => new StreamSummary(s.StreamId, (VcStreamKind)s.Kind, s.Label)).ToArray();
foreach (var stream in previous) if (!next.Any(s => s.StreamId == stream.StreamId)) Queue(new(VcEventType.StreamStopped, UserId: user.Id, StreamId: stream.StreamId));
foreach (var stream in next) if (!previous.Any(s => s.StreamId == stream.StreamId)) Queue(new(VcEventType.StreamStarted, UserId: user.Id, StreamId: stream.StreamId, U32a: (uint)stream.Kind, Text: stream.Label));
remoteStreams[user.Id] = next;
}
private VcResult Request(Envelope request)
{
if (core.State != ClientConnectionState.Connected) return VcResult.NotConnected;
try { _ = RequestAsync(request); return VcResult.Ok; }
catch { return VcResult.NotConnected; }
}
private async Task RequestAsync(Envelope request)
{
try { await core.RequestAsync(request).ConfigureAwait(false); }
catch (Exception exception) { Queue(new(VcEventType.Error, Result: VcResult.Io, Text: exception.Message)); }
}
public VcResult JoinChannel(uint id, string? password = null) => Request(new() { JoinChannel = new() { ChannelId = id, Password = password ?? "" } });
public VcResult LeaveChannel() => Request(new() { LeaveChannel = new() });
public VcResult JoinVoice()
{
try
{
if (backend is not null && playback is null) playback = backend.OpenPlayback();
var result = core.SubscribeVoiceAsync().GetAwaiter().GetResult();
if (!result.Ok) { Interlocked.Exchange(ref playback, null)?.Dispose(); }
return result.Ok ? VcResult.Ok : VcResult.Audio;
}
catch (Exception exception) { Queue(new(VcEventType.Error, Result: VcResult.Audio, Text: exception.Message)); return VcResult.Audio; }
}
public VcResult LeaveVoice() { StopDevices(); local.Clear(); return Request(new() { UnsubscribeVoice = new() }); }
public List<ChannelInfo> ListChannels() => core.Channels.Select(c => new ChannelInfo(c.Id, c.ParentId, c.Name, c.Topic, c.PasswordProtected, c.MaxUsers, unchecked((uint)c.Order), Audio(c.Audio))).ToList();
public List<UserInfo> ListUsers() => core.Users.Select(u => new UserInfo(u.Id, u.Nickname, u.IsGuest, u.ChannelId, u.SelfMicMuted, u.SelfDeafened, u.ServerMuted, u.ServerDeafened, u.VoiceSubscribed)).ToList();
public List<StreamSummary> ListUserStreams(uint id) => id == core.Authentication?.Self.Id
? local.Values.Select(s => new StreamSummary(s.Alias, (VcStreamKind)s.Info.Kind, s.Info.Label)).ToList()
: core.Users.FirstOrDefault(u => u.Id == id)?.Streams.Select(s => new StreamSummary(s.StreamId, (VcStreamKind)s.Kind, s.Label)).ToList() ?? [];
public PermissionsInfo GetPermissions() { Permissions p = core.Authentication?.Permissions ?? new(); return new(p.CanCreateTempChannel, p.CanKick, p.CanBan, p.CanMoveUsers, p.CanAdminAccounts, p.IsAdmin); }
public List<AccountInfo> ListAccounts() => accounts.ToList();
public List<DeviceInfo> ListDevices(VcDeviceKind kind) => backend?.Enumerate(kind == VcDeviceKind.Input).Select(d => new DeviceInfo(d.Id, d.Name, d.IsDefault)).ToList() ?? [];
public static string VersionString => "VoiceCat managed core 0.1.0 (protocol v2)";
public static string ResultString(VcResult result) => result.ToString();
private static AudioConfigInfo Audio(AudioConfig a) => new(a.Codec, a.Mode == ChannelMode.ModeStereo, a.SampleRate, a.BitrateBps, a.FrameMs, (uint)a.Application, a.Fec, a.ExpectedPacketLoss, a.Dtx, a.Complexity, a.Dred);
private void StopDevices()
{
foreach (var capture in captures.Values) capture.Dispose(); captures.Clear(); local.Clear();
IAudioPlayback? previous = Interlocked.Exchange(ref playback, null); previous?.Dispose();
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
identity?.TrySetResult(false); StopDevices(); core.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
}
@@ -0,0 +1,7 @@
namespace VoiceCat.Interop;
// Compatibility event consumed by WinForms. The managed core owns protocol and audio;
// this assembly contains no libvoicecat bindings or native handles.
public sealed record VoiceCatEvent(VcEventType Type, VcConnectionState ConnectionState = VcConnectionState.Disconnected,
VcResult Result = VcResult.Ok, uint UserId = 0, uint ChannelId = 0, uint StreamId = 0, VcTextScope TextScope = VcTextScope.Channel,
uint U32a = 0, string? Text = null, ulong TimestampUnixMs = 0);
@@ -0,0 +1,51 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}
@@ -0,0 +1,58 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/win-x64": {}
}
}
+9
View File
@@ -1,5 +1,14 @@
<Solution> <Solution>
<Folder Name="/Managed core/">
<Project Path="../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
<Project Path="../../dotnet/src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
<Project Path="../../dotnet/src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
<Project Path="../../dotnet/src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<Project Path="../../dotnet/src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<Project Path="../../dotnet/src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
</Folder>
<Project Path="VoiceCat.App/VoiceCat.App.csproj" /> <Project Path="VoiceCat.App/VoiceCat.App.csproj" />
<Project Path="VoiceCat.Interop.Tests/VoiceCat.Interop.Tests.csproj" /> <Project Path="VoiceCat.Interop.Tests/VoiceCat.Interop.Tests.csproj" />
<Project Path="VoiceCat.Interop/VoiceCat.Interop.csproj" /> <Project Path="VoiceCat.Interop/VoiceCat.Interop.csproj" />
<Project Path="VoiceCat.Managed/VoiceCat.Managed.csproj" />
</Solution> </Solution>
+15
View File
@@ -0,0 +1,15 @@
param(
[string]$RuntimeIdentifier = "win-x64",
[string]$Output = ""
)
$ErrorActionPreference = "Stop"
$repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path
if ([string]::IsNullOrWhiteSpace($Output)) { $Output = "$repoRoot/dotnet/artifacts/client/$RuntimeIdentifier" }
dotnet publish "$PSScriptRoot/VoiceCat.App/VoiceCat.App.csproj" -c Release -r $RuntimeIdentifier --self-contained true `
-p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=false `
-p:RestorePackagesWithLockFile=true -p:RestoreLockedMode=true `
"-p:NuGetLockFilePath=packages.publish.$RuntimeIdentifier.lock.json" -o $Output
if ($LASTEXITCODE -ne 0) { throw "Managed Windows client publish failed." }
if (-not (Test-Path "$Output/voicecat_media.dll")) { throw "Managed media shim was not published." }
if (Test-Path "$Output/voicecat.dll") { throw "Legacy VoiceCat native core must not be published." }
Write-Host "Published managed Windows client to $Output"
+292
View File
@@ -0,0 +1,292 @@
# Initial managed API contract
Status: initial port slice, API revision 1. No change to protobuf or media wire formats.
These are shared infrastructure APIs; the client-facing API follows with the client core.
## Protocol
`VoiceCat.Protocol` generates `Voicecat.V1` protobuf messages from the existing schema.
`ControlFraming.TryReadFrame(ref ReadOnlySequence<byte>, out ReadOnlySequence<byte>)`
extracts a payload and advances input only when a full frame exists. Returned memory
borrows the input's lifetime. Lengths above 16 MiB throw `InvalidDataException`.
Empty payloads are valid. `WriteFrame` and `WriteEnvelope` target `IBufferWriter<byte>`;
oversized outgoing payloads throw before output is written.
`ReadEnvelopesAsync(PipeReader, CancellationToken)` produces parsed envelopes and
advances consumed pipe data. It does not complete or dispose the caller's reader.
Clean EOF ends enumeration; partial EOF and oversized frames throw
`InvalidDataException`; malformed protobuf throws `InvalidProtocolBufferException`.
Cancellation propagates. A connection owner must close on protocol errors or
cancellation partway through a frame; partial frame bytes may already be consumed.
Fragments are consumed as they arrive so frames larger than pipe backpressure
thresholds make progress. Stopping enumeration between envelopes preserves the next frame.
`VoiceFrameHeader` is an immutable value with type, flags, codec, SSRC, sequence,
and timestamp. `Write(Span<byte>)` writes its 20-byte big-endian representation;
`TryRead` accepts at least 20 bytes and preserves unknown type/flag/codec values.
Higher layers decide which values they support.
## Media encryption
`MediaEncryptor` and `MediaDecryptor` each own one directional 32-byte session key
and mutable packet state. Use one owner at a time; they provide no synchronization.
Production constructs them through `TlsSession` media factories after its handshake.
Raw-key constructors support conformance tests.
`MediaEncryptor.Encrypt(VoiceFrameHeader, ReadOnlySpan<byte>, Span<byte>)` writes
the full header plus ciphertext and 16-byte tag and returns packet length. It replaces
the supplied sequence with its own counter, starting at zero. Capacity and overlap
errors throw before reserving a counter. Reserved counters are never reused after
encryption failure. At `ulong.MaxValue`, encryption throws and requires a new session.
`MediaDecryptor.TryDecrypt(ReadOnlySpan<byte>, Span<byte>, out VoiceFrameHeader,
out int)` authenticates and decrypts a complete packet. Short packets, failed tags,
replays, and packets outside the 64-packet window return false with default header
and zero bytes written. Authentication failure clears the attempted plaintext region;
structural/replay rejection leaves storage untouched. Callers must only consume
output after success. Invalid storage capacity and overlapping buffers throw.
The nonce is four zero bytes plus the big-endian header counter. All 20 header bytes
are authenticated associated data. The replay window advances after authentication.
The platform ChaCha20-Poly1305 implementation is preferred; BouncyCastle is used when
platform support is absent. Both produce the same wire bytes. The fallback currently
allocates per packet; audio and relay allocation guarantees are later checkpoints.
Dispose both objects to clear their owned key arrays and release platform crypto
resources. Use after disposal throws `ObjectDisposedException`.
## TLS sessions
`TlsSession` is a single-owner, nonblocking BouncyCastle TLS 1.3 state machine.
It owns no socket or worker thread. The transport owner feeds `ReceiveCiphertext`,
fully drains `DrainCiphertext` to its socket (including partial sends), and reads
application data through `ReadPlaintext`. Reads and drains return a byte count and
may require repeated calls. `WritePlaintext` requires `IsReady`. Socket cancellation,
backpressure, and connection lifetime belong to the transport owner.
`CreateClient(Func<string, bool>)` requires an explicit certificate acceptance
callback. It receives the uppercase SHA-256 fingerprint of the leaf certificate's
DER bytes during the handshake. Returning false rejects the session before application
data or media keys are available. This is TOFU certificate pinning; there is no PKI
chain or hostname validation. The synchronous callback must have the trust decision
available; an asynchronous first-connect prompt requires a subsequent connection
after explicit acceptance. Never automatically accept or persist an unknown pin.
`CreateServer(certificatePem, privateKeyPem)` supports ECDSA credentials; use
`ServerCredentials.CreateTlsSession()` to import persisted credentials. TLS 1.2 is
rejected. Handshake completion captures two 32-byte exporter keys using label
`voicecat media v1` and one-byte contexts 0 (client to server) and 1 (server to client).
BouncyCastle discards its exporter secrets after that callback. Media factories
select the correct direction for each role and require a ready session.
Create one encryptor and decryptor per connection and retain them for the connection's
lifetime: constructing a second encryptor resets its counter and would reuse nonces.
Dispose media objects separately from the TLS session. `Close()` queues close_notify;
drain it before disposal. On socket EOF call `CompleteInput()`; missing close_notify
throws `IOException`. TLS/protocol errors require closing the connection. Disposal
clears the session's owned exporter arrays and scratch buffer.
## Persisted trust and credentials
`TofuStore` uses the existing UTF-8 `host:port lowercase-hex-fingerprint` format.
Host matching is ordinal and case sensitive, matching native behavior. `Check`
returns `FirstConnect`, `Matched`, or `Mismatch` without changing persistence.
Only explicit `Pin` or `Remove` changes the file. Pin replacement requires an
explicit caller decision; malformed files fail closed. Changes replace the file
atomically before updating memory. Use one owner per store/file.
`ServerIdentity` reads and writes the native 96-byte Ed25519 format:
`public-key[32] || seed[32] || public-key[32]`. Loading verifies both public-key
copies against the seed. Disposal clears the owned seed.
`ServerCredentials.LoadOrCreate(directory, serverName)` imports `identity.key`,
`server.crt`, and `server.key` unchanged. If all are absent it creates an ECDSA-P256
self-signed certificate and identity. If only some exist it rejects startup rather
than rotating identity. Restore the missing files. New certificates include SAN URI
`urn:voicecat:identity:ed25519:<lowercase-public-key-hex>`; legacy certificates are
accepted unchanged. Checking this URI against ServerHello's identity is deferred
until the managed handshake/session layer is implemented; trust currently pins the
leaf certificate. Dispose credentials after their TLS sessions are created/finished
as required by the application lifetime.
Private file writes use a same-directory temporary file, flush, and atomic replacement.
On Unix new files use owner read/write permissions; Windows inherits directory ACLs.
The credential directory must have one provisioning owner. PEM strings and crypto
library internal copies are managed memory; owned-array clearing does not promise
erasure of every runtime/library copy.
## Codec and DSP
`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
wrap Opus controls so P/Invoke never calls C varargs. SafeHandle owns every native
encoder, decoder, DRED parser/state, and denoiser, including failed initialization.
`OpusOptions` is an immutable record. Supported PCM rates are 8/12/16/24/48 kHz,
one or two interleaved channels, and integral 5/10/20/40/60 ms frames. These match the
current VoiceCat protocol's integer frame duration; fractional Opus frame durations
are not exposed. Low-delay application mode requires at most 20 ms. Channel capture
bandwidth is controlled separately by `MaximumBandwidthHz`; the production audio
clock will remain 48 kHz. Options are validated before native creation, and native
control failures throw `OpusException` with the libopus error code.
`OpusEncoder.Encode(ReadOnlySpan<short>, Span<byte>)` accepts exactly one frame
and returns encoded bytes. `OpusDecoder.Decode(packet, pcm, samplesPerChannel,
recoverPreviousFrame)` returns samples **per channel**, not total interleaved samples.
An empty packet requests PLC. Passing the next packet with `recoverPreviousFrame`
requests in-band FEC; absence of FEC permits libopus's PLC fallback. Decode that next
packet normally afterward. Capacity/overlap errors throw before native processing.
DRED is explicit. Unsupported native builds reject `DeepRedundancy = true` rather
than silently disabling it. With pinned Opus 1.5.2, DRED encoding requires PCM at
16/24/48 kHz; its activity analysis cannot emit DRED at 8/12 kHz. Such configurations
are rejected. DRED packets can still be decoded at all five rates. The encoder uses
a 30 ms minimum redundancy duration because this release needs two redundancy chunks;
the old 20 ms setting produces no DRED packets. Actual redundancy remains adaptive
to bitrate, loss estimate, and activity; it is not guaranteed in every packet.
`OpusDeepRedundancy.TryRecover(audioDecoder, nextPacket, pcm, samplesPerChannel,
offset)` parses the next packet and reconstructs a missing frame. Default offset is
one missing frame's samples per channel before the next packet's start, matching
libopus's offset convention. A packet without DRED returns false; then the owner
can try FEC/PLC. Only consume recovery output on success. Parse/native errors throw.
`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
for stereo channels when the later pipeline supports stereo microphone denoising.
Noise reduction does not gate speech.
`EnergyVadProcessor.Process(ReadOnlySpan<short>)` compares normalized RMS against
`Threshold`, retains speech for `HangTime`, and starts closed. It uses monotonic
`TimeProvider` timestamps; tests inject a clock. Threshold changes are atomic; all
processing state otherwise has one owner. Codec/DSP processing methods allocate no
managed memory after initialization, verified across 1,000 combined cycles. They run
on a managed worker, never the native real-time device callback. Native device rings,
jitter, mixer, and audio scheduling remain later work.
## Initial managed server
`VoiceServer(directory, endpoint, allowGuests, name)` owns credentials, the SQLite
store, a TCP listener and its connection tasks. `EndPoint` reports the actual bound
port (zero requests an ephemeral port). Dispose asynchronously to stop the listener
and wait for all connections. The CLI currently binds loopback and accepts optional
data-directory/port positional arguments.
All control traffic uses TLS 1.3 with the existing v2 protobuf. A single async loop
owns each `TlsSession`; handlers exchange envelopes through bounded queues.
This checkpoint caps connections at 64, queued input at 32 envelopes, queued output
at 64 envelopes, and each control payload at 64 KiB (stricter than the shared framer's
16 MiB limit). Queue exhaustion disconnects slow consumers. Handshake timeout is
15 seconds by default. Completed TLS connections use the server's media-aware reaper.
The existing `VoiceServer(directory, endpoint, allowGuests, name)` constructor remains
available. An overload accepts `VoiceServerOptions` and an optional `TimeProvider`.
Options configure server name, guest access, connection limit (default 64), handshake
timeout (15 seconds), idle timeout (45 seconds) and reaper interval (15 seconds).
Zero idle timeout disables reaping; active reaping requires a positive interval.
Invalid options fail before creating credentials, databases or sockets.
Options also configure authentication burst/refill (5 attempts / one per ten seconds).
The bounded address/account limiter runs before Argon2 and survives reconnects within
the process, with escalating failure backoff. `Completion` reports unexpected termination
of listener/media/active-reaper tasks; hosts should observe it and stop on failure.
The executable supports configuration/environment, local account provisioning, JSON
readiness, exclusive instance locking and bounded graceful shutdown; see deployment.md.
Authentication starts users in unprotected Lobby (id 1), subject to its capacity.
Success returns permissions, then a cloned snapshot; peers receive joined/updated/left
events. Server-authoritative text replaces supplied sender ids/timestamps, limits
bodies to 4096 UTF-8 bytes, and acknowledges valid or rejected routing. Channel text
requires membership; private text echoes to sender and recipient. Protected joins enforce
the supplied password and capacity; `LeaveChannel` returns to Lobby. Passwords use the
native salted, keyed BLAKE2b-256 `salt_hex:hash_hex` format, verified in both directions.
Channel create/edit/delete persist before broadcasting events. Administrators can manage
all channels; `CanCreateTempChannel` permits creation of temporary channels only. Edit with
an empty password preserves the existing hash, matching native behavior; password removal
has no v2 request representation. Lobby cannot be deleted, protected or nested. Missing
parents, tree cycles and deletion of parents with children fail without mutation. Deletion
moves members to Lobby (even if full), clearing their streams. Edits stop existing streams
so clients must negotiate the updated audio configuration. Channel names/topics/passwords
are limited to 128/4096/1024 UTF-8 bytes. Audio requires Opus, 48 kHz, mono/stereo,
500–512000 bps, integral 5/10/20/40/60 ms frames and valid application/loss/complexity.
Database v2 has no DRED column; CRUD rejects DRED rather than silently losing it on restart.
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
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
identities. Ban wire expiry is Unix milliseconds, converted to database seconds rounded up;
zero means permanent. This fixes the native handler's millisecond/second mismatch.
Existing sessions on the same address/account are not swept by a target-user ban.
Create/reset/delete/list accounts require administrator or `CanAdminAccounts`. New accounts
are non-admin. Bounded Argon2 work runs outside the server state lock; authority is checked
when accepting the operation, and cancellation is checked before password writes. Reset
and deletion affect future authentication; existing sessions retain their permissions.
Lists omit password hashes and return millisecond timestamps. Oversized lists fail instead
of truncating or exceeding the 64 KiB frame limit. Privileged responses echo request ids;
generic codes are 6 for permission denied and 3 for invalid/missing/duplicate input.
`VoiceServer.MediaEndPoint` exposes the bound UDP endpoint; UDP uses the same address
and port number as TCP, and `ServerHello.udp_port` advertises it. Successful authentication
issues a 16-byte binding token. TLS confirmation echoes an acknowledgement; a protocol-v2
bootstrap packet binds the first UDP endpoint. Tokens cannot replace an established
endpoint; reconnect to change endpoints. Invalid tokens and malformed packets are ignored.
Voice subscription, unsubscribe, stream announce/stop and stream-state signaling are
implemented. Announces require subscription and support microphone, screen audio and
auxiliary device streams, with at most 16 streams per user and labels up to 128 characters.
Stream ids are monotonically assigned per user; SSRCs are assigned server-wide.
Channel audio settings are authoritative; requested bitrate may lower the channel ceiling
(nonzero requests below 500 bps fail). User updates include the actor. Stream-state updates
use the authenticated sender id and ignore unknown stream ids.
Channel movement clears active streams; joining the current channel preserves them.
Unsubscribe clears streams. Disconnect removes routing and retires media resources,
even if no UDP traffic follows. Senders must own the SSRC and be subscribed; recipients
must be subscribed, bound, in the same channel and not deafened. Server-muted senders
cannot relay. Every voice packet is authenticated with the sender's directional key;
the SFU reseals encoded bytes for each recipient without decoding, replacing only the
sequence and ciphertext/tag. Replay rejection precedes authentication; successful
authentication advances the replay window.
The UDP loop exclusively owns media crypto, endpoint mutation and packet buffers.
Control handlers publish immutable routing snapshots. Crypto is created within the
TLS owner loop and transferred once. A coalesced notification wakes retired-key cleanup.
The synchronous fan-out core allocates zero managed bytes with platform ChaCha20-Poly1305;
socket scheduling and the allocating BouncyCastle fallback are outside that guarantee.
UDP keepalives are echoed for bound endpoints. Any parsed control envelope, authenticated
voice from an active owned stream, or exact header-only keepalive from a bound endpoint
refreshes a shared monotonic activity timestamp. Invalid media does not refresh it.
The reaper sends a fatal disconnect, removes presence/routing and broadcasts one LEFT
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,
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
after its operations finish. `ResetPasswordAsync`, `DeleteAccount` and `ListAccounts`
also expose administration to hosts. Initial administrator provisioning uses this API or
the native administration CLI; there is no automatic bootstrap account.
`PasswordHasher` uses strict UTF-8 without normalization and libsodium-compatible
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
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.
SQLite's MIT provider/bundle uses the pinned public-domain SourceGear SQLite build.
The license audit checks that exact package version and repository identity because
the native package lacks a NuGet license expression; other dependencies still require
an approved permissive expression.
+9 -2
View File
@@ -1,5 +1,10 @@
# Architecture # 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.
## 1. The shared-core model ## 1. The shared-core model
All non-UI logic lives in one C++ library, **`libvoicecat`**. The same library is linked All non-UI logic lives in one C++ library, **`libvoicecat`**. The same library is linked
@@ -205,8 +210,10 @@ callback: no allocations, no blocking calls.
``` ```
- **Voice router is a relay, not a mixer.** For each incoming voice frame it looks up the - **Voice router is a relay, not a mixer.** For each incoming voice frame it looks up the
sender's channel and forwards the *unmodified Opus payload* (restamped with the sender's sender's channel and forwards the *unmodified encoded Opus bytes* to other members.
user id) to every other subscribed member. No server-side decode/transcode → low CPU, 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 low latency, and end-to-content is just Opus. Per-channel Opus params are enforced so all
members are mutually decodable. members are mutually decodable.
- **Subscriptions.** Clients implicitly subscribe to their current channel's voice; text - **Subscriptions.** Clients implicitly subscribe to their current channel's voice; text
+15
View File
@@ -1,5 +1,20 @@
# Building & Manual Testing # Building & Manual Testing
## .NET rewrite
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):
```powershell
./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
```
See `dotnet/README.md` for conformance fixtures and conventions. The C++ commands
below remain required while the existing implementation is the migration oracle.
This doc explains what each CMake preset in [`CMakePresets.json`](../CMakePresets.json) is 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 + *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 `vccli` clients against each other for manual testing. For the one-paragraph quick-start see
+55
View File
@@ -1,5 +1,60 @@
# Deployment & Self-Hosting # Deployment & Self-Hosting
## Managed server deployment checkpoint
The .NET server preserves protocol v2 and the native schema/credentials. Publish the
Windows self-contained executable (no installed .NET runtime required):
```powershell
./dotnet/publish-server.ps1
./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe --help
./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe account add Operator --admin --data-dir ./voicecat-data
./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe --data-dir ./voicecat-data --allow-guests false
```
Account add/reset use a hidden password prompt, redirected standard input, or
`VOICECAT_ADMIN_PASSWORD`. Passwords are never accepted as command arguments or logged.
Account delete/list work against the same database, including while the server runs.
Provisioning grants administrator access only through the local command's `--admin`;
in-band account creation remains non-admin. Restrict access to the data directory.
Defaults are `0.0.0.0:8384` TCP+UDP, guest access enabled, 64 connections, 15-second TLS
handshakes, 45-second idle expiry and 15-second sweeps. Override with flags or environment:
| Flag | Environment variable |
|---|---|
| `--data-dir` | `VOICECAT_DATA_DIR` |
| `--bind` | `VOICECAT_BIND_ADDRESS` |
| `--port` | `VOICECAT_BIND_PORT` |
| `--name` | `VOICECAT_SERVER_NAME` |
| `--allow-guests` | `VOICECAT_ALLOW_GUESTS` |
| `--max-connections` | `VOICECAT_MAX_CONNECTIONS` |
| `--handshake-seconds` | `VOICECAT_HANDSHAKE_TIMEOUT_SECONDS` |
| `--idle-seconds` | `VOICECAT_IDLE_TIMEOUT_SECONDS` |
| `--reaper-seconds` | `VOICECAT_REAPER_INTERVAL_SECONDS` |
| `--auth-burst` | `VOICECAT_AUTH_BURST` |
| `--auth-refill-seconds` | `VOICECAT_AUTH_REFILL_SECONDS` |
Command arguments override environment values. `--print-config` validates and prints JSON
without creating files; `--print-fingerprint` prints the persisted leaf-certificate SHA-256
pin. Startup emits one JSON `ready` event with both fingerprints and actual TCP/UDP ports.
Bind accepts IP literals; IPv6 listeners are IPv6-only. Open/forward both protocols.
An exclusive data-directory instance lock prevents duplicate managed server processes.
Ctrl+C and Unix SIGINT/SIGTERM stop all transport tasks; shutdown has a ten-second deadline.
Fatal listener/media/reaper failure exits the host rather than leaving a broken listener.
Password authentication is limited before Argon2 by source address and username across
connections: burst 5, refill one attempt per ten seconds. Starting at three failed attempts,
backoff grows from one to thirty seconds. Success clears backoff but does not restore
tokens. State is bounded to 4096 keys; idle entries retire after ten minutes when full.
Throttle and credential failures share the generic auth error. Limits are process-local.
The publish script uses separate runtime lock files so deployment and development restore
graphs remain reproducible. The checked deployment target is currently Windows x64;
Linux container publishing, service packaging, TOML/reload support and long-running
operational validation remain before broad production rollout. The native deployment
paths and planned operational features below remain available as the migration oracle.
The product goal: someone looks at this and thinks *"oh, I (or my agent) can stand this up The product goal: someone looks at this and thinks *"oh, I (or my agent) can stand this up
in a few minutes."* Everything below is in service of that. Three install paths, all in a few minutes."* Everything below is in service of that. Three install paths, all
**zero-config and encrypted by default**. **zero-config and encrypted by default**.
+104 -10
View File
@@ -1,6 +1,11 @@
# Porting VoiceCat to pure .NET / C# # Porting VoiceCat to pure .NET / C#
**Status:** proposal / plan. Nothing here is implemented yet. **Status:** wire/media crypto and TLS/exporter foundations implemented under `dotnet/`,
including C++ interoperability, persisted TOFU, compatible server credentials,
codec/DSP wrappers, desktop native staging, and the initial managed TLS control server.
Phase 4 remains in progress; complete session administration, device audio,
managed client state, and UI phases remain 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. **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 **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# Swift macOS/iOS clients with a single C# codebase. The Windows WinForms client is already C#
@@ -230,7 +235,7 @@ has been carrying.
| TLS 1.3 | mbedTLS | **BouncyCastle `Org.BouncyCastle.Tls`** | MIT | See §3. **Not `SslStream`.** | | 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. | | 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. | | 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` | **`Konscious.Security.Cryptography.Argon2`** | MIT | Pure managed. ⚠️ **Existing password hashes will not verify** — libsodium emits `$argon2id$...` PHC strings with its own tuned m/t/p. Either implement a PHC-string parser and feed those params to Konscious (doable, recommended), or force a password reset on migration. Decide early; `db.cpp` migration depends on 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. | | 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. | | 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). | | 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). |
@@ -240,7 +245,7 @@ has been carrying.
| 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. | | 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. | | 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**. | | 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`** | MIT | Bundles SQLitePCLRaw; works with NativeAOT. Same schema, same file — an existing `voicecat.db` opens unchanged. | | 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. | | 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. | | 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. | | CLI arg parsing | hand-rolled | **`System.CommandLine`** | MIT | For `VoiceCat.Cli` and the server. |
@@ -353,11 +358,10 @@ internal static partial int opus_encode(IntPtr st, ReadOnlySpan<short> pcm, int
Span<byte> data, int maxDataBytes); Span<byte> data, int maxDataBytes);
``` ```
- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. Declare one - `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. The implemented
overload per argument shape (`int`, `out int`) with `EntryPoint = "opus_encoder_ctl"`. This desktop binding uses fixed C entry points in `dotnet/native/media.c`; C calls the
works on all the ABIs we target (x64 SysV, x64 Win, arm64 AAPCS) because all the CTLs we use varargs function with the correct ABI. This also handles Apple arm64's different
take a single `int`/`int*`. **Note this explicitly in code comments** — it's a real varargs calling convention. Only whitelisted single-int controls are accepted.
portability caveat if a future CTL takes a different shape.
- DRED (`opus_dred_alloc`, `opus_dred_parse`, `opus_decoder_dred_decode`) binds the same way. - 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. Guard with a runtime feature check as `opus_codec.cpp` does today.
- **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link - **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link
@@ -418,7 +422,7 @@ The most mechanical part of the project. Straight `async`/`await` network code.
| `server.cpp` — accept loop | `Socket.AcceptAsync` loop + `Task` per connection. Trivial. | | `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. | | `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++. | | `session_registry.cpp` | `ConcurrentDictionary<ulong, Session>` + a channel-membership index. Simpler than the C++. |
| `media_relay.cpp` — the SFU | ⚠️ **The one hot path on the server.** Per inbound datagram: parse 20-byte header → look up ssrc → fan out unmodified to N subscribers. Must be allocation-free: `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)` into a pooled buffer, `SendToAsync` per subscriber. Do **not** decrypt — the design already forbids it, which is what keeps this cheap. Benchmark this specifically (§11.5). | | `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. | | `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. | | `identity.cpp` | `CertificateRequest` + BouncyCastle Ed25519. Reads the same on-disk files. |
| Keepalive reaper | `PeriodicTimer` — cleaner than the `asio::steady_timer`. | | Keepalive reaper | `PeriodicTimer` — cleaner than the `asio::steady_timer`. |
@@ -618,7 +622,10 @@ not delete anything until the C# equivalent passes the same test against it. Thi
possible because Option A (§3.2) preserves wire compatibility — which is the main reason to possible because Option A (§3.2) preserves wire compatibility — which is the main reason to
choose it. choose it.
Work on a long-lived branch (`cs-port` already exists). Each phase ends with a green build, 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. green tests, and an updated `PROGRESS.md` entry.
--- ---
@@ -672,6 +679,16 @@ not assumed.
**Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives **Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives
matching media keys, and pins the leaf fingerprint. 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) ### Phase 3 — Codec + DSP (est. 1 week)
@@ -684,6 +701,19 @@ matching media keys, and pins the leaf fingerprint.
**Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery **Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery
test green; RNNoise output matches the C++ within tolerance. 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) ### Phase 4 — Server (est. 3–4 weeks)
@@ -691,6 +721,48 @@ test green; RNNoise output matches the C++ within tolerance.
Do the server before the client: it lets you point the **existing, trusted C++ `vccli`** at 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. 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. 1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry.
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat 2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
question here** (§4). question here** (§4).
@@ -722,6 +794,14 @@ criterion from `roadmap.md`, re-proven against the new server.
mix cycle; a manual listen test on Windows and macOS with no audible glitching over 10 mix cycle; a manual listen test on Windows and macOS with no audible glitching over 10
minutes. minutes.
**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, but
the required ten-minute Windows/macOS listen test is still a manual release gate.
--- ---
### Phase 6 — Client core (est. 3–4 weeks) ### Phase 6 — Client core (est. 3–4 weeks)
@@ -737,6 +817,12 @@ minutes.
conversation through the C# server**, and a C# `vccli` interoperates with a C++ `vccli` on 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. the same server. This is the full M0–M3 criterion re-proven end to end.
**Checkpoint (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.
The remaining Phase 6 item is the managed console client and its explicit C++ CLI
interoperability scenario.
--- ---
### Phase 7 — Windows client (est. 1–2 weeks) ### Phase 7 — Windows client (est. 1–2 weeks)
@@ -746,6 +832,14 @@ shape against a real, complete UI before you commit to two rewrites.
**Exit criterion:** feature parity with the current WinForms build, NVDA smoke-tested. **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) ### Phase 8 — macOS client (est. 4–5 weeks)
+4
View File
@@ -305,6 +305,10 @@ message TextMessage {
laptop sleep) that never produce a TCP EOF are cleaned up, and peers' audio engines laptop sleep) that never produce a TCP EOF are cleaned up, and peers' audio engines
`remove_stream` and stop PLC. The timeout and sweep interval are configurable via `remove_stream` and stop PLC. The timeout and sweep interval are configurable via
`server::Config::reaper_timeout_ms` / `reaper_sweep_ms` (set to 0 to disable). `server::Config::reaper_timeout_ms` / `reaper_sweep_ms` (set to 0 to disable).
The managed server uses `VoiceServerOptions.IdleTimeout` / `ReaperInterval` with the
same 45-second / 15-second defaults (zero idle timeout disables reaping). It refreshes
activity on parsed control envelopes, authenticated owned-stream voice, and exact
bound-endpoint keepalives; rejected media does not refresh activity. Timing is monotonic.
- **UDP:** a separate lightweight keepalive on the media channel (voice.md §6) keeps NAT - **UDP:** a separate lightweight keepalive on the media channel (voice.md §6) keeps NAT
bindings alive and detects media-path failure independently of the control channel. bindings alive and detects media-path failure independently of the control channel.
- **Graceful disconnect.** A client ending its session sends `Disconnect { code = 0; - **Graceful disconnect.** A client ending its session sends `Disconnect { code = 0;
+21
View File
@@ -2,6 +2,27 @@
## 1. Milestones ## 1. Milestones
### .NET port — initial slice
**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.
- `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`.
Each milestone is shippable/testable on its own. The headless C++ test client (`vccli`) 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. exists from M1 so the protocol can be exercised long before any GUI.
+38 -17
View File
@@ -49,6 +49,16 @@ This is a known limitation of the current design. Closing it properly requires b
Ed25519 key into the TLS cert (e.g. as a SubjectAltName or extension), which is a planned Ed25519 key into the TLS cert (e.g. as a SubjectAltName or extension), which is a planned
future improvement. Until then, clients display both values but gate on the cert fingerprint. future improvement. Until then, clients display both values but gate on the cert fingerprint.
**Managed rewrite checkpoint:** `dotnet/` uses nonblocking BouncyCastle TLS 1.3 and
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
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
rather than automatic identity rotation. See [api-dotnet.md](api-dotnet.md).
Client certificates are reserved for a future "key-based identity" option (see roadmap) but Client certificates are reserved for a future "key-based identity" option (see roadmap) but
are not required in v1. are not required in v1.
@@ -67,13 +77,16 @@ mandatory from the first build. This was chosen over DTLS after weighing two fin
### How it works ### How it works
1. During the TLS 1.3 control handshake, both sides call the keying-material exporter with a 1. After the TLS 1.3 control handshake, both sides call the keying-material exporter with
fixed label (`"voicecat media v1"`) to derive independent **send/recv media keys** and a label `"voicecat media v1"` and a one-byte context: `0x00` for client→server,
salt. No second handshake, no certificates on the UDP path — the UDP channel inherits the `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. authenticated, MITM-resistant TLS session's trust.
2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (libsodium, ISC license). 2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (libsodium in C++;
3. The readable routing field (`ssrc`) is passed as AEAD **associated data** so the relay can platform cryptography with a BouncyCastle fallback in .NET).
route without decrypting and an attacker cannot tamper with it undetected. 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
recipient's next send counter. It forwards the encoded Opus bytes without decoding audio.
This keeps the entire crypto surface on two permissive libraries (mbedTLS + libsodium), adds This keeps the entire crypto surface on two permissive libraries (mbedTLS + libsodium), adds
no handshake latency to voice startup, and is small enough to audit fully. It is abstracted no handshake latency to voice startup, and is small enough to audit fully. It is abstracted
@@ -84,11 +97,12 @@ the design depends on that.
### Per-frame protections ### Per-frame protections
- **AEAD** (ChaCha20-Poly1305) over each voice frame — confidentiality + integrity. - **AEAD** (ChaCha20-Poly1305) over each voice frame — confidentiality + integrity.
- **Associated data:** the `ssrc` (and version/flags) are authenticated-but-visible so the - **Associated data:** all 20 header bytes remain visible and authenticated; the Opus
relay routes without decrypting; everything else is encrypted. payload is encrypted and followed by a 16-byte tag.
- **Nonce discipline:** `nonce = direction_bit ‖ ssrc ‖ monotonic_packet_counter`. The - **Nonce discipline:** `nonce = four_zero_bytes ‖ counter_u64_big_endian`. Counters are
counter never repeats under one key; the session **rekeys** (re-derives via the exporter per directional session key, shared across its streams. Direction separation comes
with a bumped epoch) well before counter exhaustion or on a time/byte budget. from exporter contexts, not nonce bits. Automatic epoch rekeying is not implemented;
the .NET encryptor refuses counter exhaustion and requires a new session.
- **Anti-replay:** a 64-bit sliding-window replay filter keyed on the packet counter (à la - **Anti-replay:** a 64-bit sliding-window replay filter keyed on the packet counter (à la
IPsec). The window is **advanced only after the AEAD tag verifies** (RFC 3711 §3.3 order: IPsec). The window is **advanced only after the AEAD tag verifies** (RFC 3711 §3.3 order:
replay-check → authenticate → update). The counter is read from the unauthenticated replay-check → authenticate → update). The counter is read from the unauthenticated
@@ -102,14 +116,21 @@ the design depends on that.
UDP packets are not individually authenticated to a *user* beyond the transport session. UDP packets are not individually authenticated to a *user* beyond the transport session.
Binding works as: Binding works as:
1. `AuthResult.udp_token` (issued over TLS) is a short-lived, single-use, random token tied 1. `AuthResult.udp_token` (issued over TLS) is a random 16-byte token tied to the
to `session_id`. authenticated session. The client confirms it with `UdpBinding` over TLS.
2. Client's first UDP message is `UdpBinding{udp_token}`, sent as the first AEAD media frame 2. Protocol v2 bootstraps UDP with a **plaintext** `UDP_BINDING` packet: the 20-byte
using the keys exported from the TLS session. binary header followed by the token. This is not a protobuf or an AEAD voice frame.
3. Server validates the token, binds the **5-tuple → session_id**, and discards the token. 3. Server validates the token and binds the **5-tuple → session_id**. The managed server
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
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 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 streams the session announced. Source-address spoofing can't hijack a session because the
attacker lacks the media key and the token. attacker lacks the media key. The bootstrap token is visible on UDP, so it is not
a substitute for AEAD authentication and SSRC ownership checks. Header-only keepalives
are echoed only for bound endpoints; they provide liveness, not authenticated content.
## 4. Authentication & accounts (settled: guests + local accounts) ## 4. Authentication & accounts (settled: guests + local accounts)
+13
View File
@@ -1,5 +1,18 @@
# Tech Stack & Dependencies # Tech Stack & Dependencies
## Initial .NET rewrite
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.
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.
Concrete library choices with versions and rationale. Everything in the **core** is C++ Concrete library choices with versions and rationale. Everything in the **core** is C++
(C++20). UIs are Swift and C#. Build is CMake + vcpkg. (C++20). UIs are Swift and C#. Build is CMake + vcpkg.
+5 -4
View File
@@ -66,9 +66,10 @@ payload one Opus packet (the encoder's output for one frame)
> interoperate; the `Hello` handshake rejects on `proto_version` mismatch. > interoperate; the `Hello` handshake rejects on `proto_version` mismatch.
This is intentionally RTP-shaped (familiar semantics: ssrc/seq/timestamp) without RTP's This is intentionally RTP-shaped (familiar semantics: ssrc/seq/timestamp) without RTP's
full machinery. The **server relays the payload unmodified** — it only reads the header to full machinery. The server authenticates/decrypts each incoming packet and reseals its
route by ssrc→channel and may restamp nothing (the client's ssrc is globally unique once encoded Opus bytes for each recipient using that recipient's directional key and send
assigned at `StreamAnnounce`). No server-side decode. counter. SSRC, timestamp, flags, and codec pass through; sequence and ciphertext/tag change.
There is no server-side audio decoding or transcoding.
### Why client-sends-ssrc is safe ### Why client-sends-ssrc is safe
@@ -183,7 +184,7 @@ Each receiver keeps an **adaptive jitter buffer per ssrc** with **bounded-depth
- A `KEEPALIVE` (type 2) frame flows both directions on the media channel every ~5 s to - A `KEEPALIVE` (type 2) frame flows both directions on the media channel every ~5 s to
hold NAT bindings and measure media-path RTT/loss independent of TCP. The frame is hold NAT bindings and measure media-path RTT/loss independent of TCP. The frame is
plaintext (14-byte header, no payload, no AEAD) — the server identifies the sender by plaintext (20-byte header, no payload, no AEAD) — the server identifies the sender by
its already-verified UDP endpoint (established during the `UdpBinding` handshake). On its already-verified UDP endpoint (established during the `UdpBinding` handshake). On
receipt the server bumps the sender's `last_seen` (so media activity defers the TCP receipt the server bumps the sender's `last_seen` (so media activity defers the TCP
reaper independently of control-channel traffic) and echoes the frame back so the reaper independently of control-channel traffic) and echoes the frame back so the
+7
View File
@@ -0,0 +1,7 @@
root = true
[*.cs]
indent_style = space
indent_size = 4
csharp_style_namespace_declarations = file_scoped:warning
dotnet_sort_system_directives_first = true
+10
View File
@@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
<Project>
<PropertyGroup>
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == '' and '$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</VoiceCatNativeRid>
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</VoiceCatNativeRid>
<VoiceCatNativeDirectory Condition="'$(VoiceCatNativeDirectory)' == ''">$(MSBuildThisFileDirectory)artifacts/native/runtimes/$(VoiceCatNativeRid)/native</VoiceCatNativeDirectory>
</PropertyGroup>
<ItemGroup Condition="'$(MSBuildProjectName)' != 'VoiceCat.Server' and '$(MSBuildProjectName)' != 'VoiceCat.Crypto' and '$(MSBuildProjectName)' != 'VoiceCat.Protocol'">
<None Include="$(MSBuildThisFileDirectory)artifacts/native/licenses/*.txt" Link="licenses/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="$(VoiceCatNativeDirectory)/voicecat_media.dll" Condition="Exists('$(VoiceCatNativeDirectory)/voicecat_media.dll')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="$(VoiceCatNativeDirectory)/libvoicecat_media.so" Condition="Exists('$(VoiceCatNativeDirectory)/libvoicecat_media.so')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="$(VoiceCatNativeDirectory)/libvoicecat_media.dylib" Condition="Exists('$(VoiceCatNativeDirectory)/libvoicecat_media.dylib')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+167
View File
@@ -0,0 +1,167 @@
# VoiceCat .NET rewrite
The first slice targets .NET 10: protobuf, control framing, voice headers, and media
encryption, TLS 1.3, persisted TOFU pins, server credentials, and an initial managed
control server. Media relay, client state, audio, and UI migration are next. The existing
C++ implementation remains the conformance oracle.
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):
```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
```
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.
## 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.
+17
View File
@@ -0,0 +1,17 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<Project Path="src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<Project Path="src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
<Project Path="src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<Project Path="src/VoiceCat.Server/VoiceCat.Server.csproj" />
<Project Path="src/VoiceCat.Core/VoiceCat.Core.csproj" />
<Project Path="src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
</Folder>
<Folder Name="/clients/">
<Project Path="../clients/windows/VoiceCat.Managed/VoiceCat.Managed.csproj" />
</Folder>
</Solution>
+18
View File
@@ -0,0 +1,18 @@
param(
[string]$BuildDirectory = "$PSScriptRoot/artifacts/native-build",
[string]$RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier,
[string]$Generator,
[string]$CCompiler
)
$ErrorActionPreference = 'Stop'
$configure = @('-S', "$PSScriptRoot/native", '-B', $BuildDirectory,
'-DCMAKE_BUILD_TYPE=Release', "-DVOICECAT_DOTNET_RID=$RuntimeIdentifier")
if ($Generator) { $configure += @('-G', $Generator) }
if ($CCompiler) { $configure += "-DCMAKE_C_COMPILER=$CCompiler" }
& cmake @configure
if ($LASTEXITCODE) { throw "Native configure failed: $LASTEXITCODE" }
& cmake --build $BuildDirectory --config Release --target voicecat_media --parallel 2
if ($LASTEXITCODE) { throw "Native build failed: $LASTEXITCODE" }
& cmake --install $BuildDirectory --config Release --component DotnetMedia --prefix "$PSScriptRoot/artifacts/native"
if ($LASTEXITCODE) { throw "Native staging failed: $LASTEXITCODE" }
+34
View File
@@ -0,0 +1,34 @@
$ErrorActionPreference = 'Stop'
$allowed = @('MIT', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', 'ISC', '0BSD')
$seen = @{}
foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter 'packages*.lock.json' -Recurse)) {
$lock = Get-Content -Raw -LiteralPath $lockPath.FullName | ConvertFrom-Json
$assets = Get-Content -Raw -LiteralPath (Join-Path $lockPath.DirectoryName 'obj/project.assets.json') | ConvertFrom-Json
foreach ($framework in $lock.dependencies.PSObject.Properties) {
foreach ($package in $framework.Value.PSObject.Properties) {
if ($package.Value.type -eq 'Project') { continue }
$id = $package.Name.ToLowerInvariant()
$version = $package.Value.resolved
if ($seen.ContainsKey("$id/$version")) { continue }
$seen["$id/$version"] = $true
$nuspec = $null
foreach ($folder in $assets.packageFolders.PSObject.Properties.Name) {
$candidate = Join-Path $folder "$id/$version/$id.nuspec"
if (Test-Path -LiteralPath $candidate) { $nuspec = $candidate; break }
}
if (!$nuspec) { throw "Restore dependencies before auditing $id/$version." }
[xml]$spec = Get-Content -Raw -LiteralPath $nuspec
$license = $spec.package.metadata.license
if ($license.type -eq 'expression' -and $allowed -contains $license.InnerText) { continue }
# This pinned package contains public-domain SQLite builds; no NuGet license metadata.
if ($id -eq 'sourcegear.sqlite3' -and $version -eq '3.50.4.2' -and
$spec.package.metadata.projectUrl -eq 'https://sqlite.org/' -and
$spec.package.metadata.repository.commit -eq '9a2d8281d8f714fe54f7cbcd122479d17b533e89') { continue }
# This legacy pinned package predates NuGet license expressions (Apache-2.0).
if ($id -eq 'xunit.abstractions' -and $version -eq '2.0.3' -and
$spec.package.metadata.licenseUrl -eq 'https://raw.githubusercontent.com/xunit/xunit/master/license.txt') { continue }
throw "Unapproved license for $id/$version. Review before changing the allowlist."
}
}
}
Write-Output "Checked $($seen.Count) package licenses: permissive allowlist passed."
+13
View File
@@ -0,0 +1,13 @@
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.'
+3
View File
@@ -0,0 +1,3 @@
{
"sdk": { "version": "10.0.203", "rollForward": "latestFeature" }
}
+101
View File
@@ -0,0 +1,101 @@
cmake_minimum_required(VERSION 3.24)
project(VoiceCatMedia LANGUAGES C)
if(MSVC)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
set(OPUS_STATIC_RUNTIME ON CACHE BOOL "" FORCE)
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
message(FATAL_ERROR "iOS static NativeReference packaging belongs to the later client phase.")
endif()
if(NOT TARGET Opus::opus)
set(bundled_default OFF)
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
set(bundled_default ON)
endif()
option(VOICECAT_BUNDLED_OPUS "Build pinned Opus with DRED support" ${bundled_default})
if(VOICECAT_BUNDLED_OPUS)
include(FetchContent)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(OPUS_DRED ON CACHE BOOL "" FORCE)
set(OPUS_DEEP_PLC ON CACHE BOOL "" FORCE)
set(OPUS_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
set(OPUS_BUILD_TESTING OFF CACHE BOOL "" FORCE)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
FetchContent_Declare(opus
URL https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
URL_HASH SHA256=65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1
TIMEOUT 60
INACTIVITY_TIMEOUT 30
DOWNLOAD_EXTRACT_TIMESTAMP TRUE)
FetchContent_MakeAvailable(opus)
set(VOICECAT_OPUS_LICENSE "${opus_SOURCE_DIR}/COPYING")
else()
find_package(Opus CONFIG REQUIRED)
endif()
endif()
if(NOT VOICECAT_OPUS_LICENSE)
find_file(VOICECAT_OPUS_LICENSE NAMES copyright COPYING HINTS "${Opus_DIR}" NO_DEFAULT_PATH)
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")
if(NOT TARGET rnnoise)
add_library(rnnoise STATIC
${RNNOISE_DIR}/src/denoise.c ${RNNOISE_DIR}/src/rnn.c
${RNNOISE_DIR}/src/pitch.c ${RNNOISE_DIR}/src/kiss_fft.c
${RNNOISE_DIR}/src/celt_lpc.c ${RNNOISE_DIR}/src/nnet.c
${RNNOISE_DIR}/src/nnet_default.c ${RNNOISE_DIR}/src/parse_lpcnet_weights.c
${RNNOISE_DIR}/src/rnnoise_data.c ${RNNOISE_DIR}/src/rnnoise_tables.c)
target_include_directories(rnnoise PUBLIC ${RNNOISE_DIR}/include PRIVATE ${RNNOISE_DIR}/src)
target_compile_definitions(rnnoise PRIVATE DISABLE_DEBUG_FLOAT)
if(MSVC)
target_compile_definitions(rnnoise PRIVATE restrict=__restrict)
endif()
target_compile_features(rnnoise PRIVATE c_std_11)
set_target_properties(rnnoise PROPERTIES POSITION_INDEPENDENT_CODE ON C_VISIBILITY_PRESET hidden)
endif()
add_library(voicecat_media SHARED media.c)
target_compile_features(voicecat_media PRIVATE c_std_99)
target_link_libraries(voicecat_media PRIVATE Opus::opus rnnoise)
set_target_properties(voicecat_media PROPERTIES C_VISIBILITY_PRESET hidden)
if(WIN32)
set_target_properties(voicecat_media PROPERTIES PREFIX "")
endif()
if(NOT WIN32)
target_link_libraries(voicecat_media PRIVATE m)
elseif(MINGW)
target_link_options(voicecat_media PRIVATE -static-libgcc -static)
endif()
if(NOT VOICECAT_DOTNET_RID)
string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" architecture)
if(architecture MATCHES "^(amd64|x86_64)$")
set(architecture x64)
elseif(architecture MATCHES "^(aarch64|arm64)$")
set(architecture arm64)
else()
message(FATAL_ERROR "Set VOICECAT_DOTNET_RID for architecture ${architecture}")
endif()
if(WIN32)
set(platform win)
elseif(APPLE)
set(platform osx)
else()
set(platform linux)
endif()
set(VOICECAT_DOTNET_RID "${platform}-${architecture}")
endif()
install(TARGETS voicecat_media
RUNTIME DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia
LIBRARY DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia)
install(FILES ${RNNOISE_DIR}/COPYING DESTINATION licenses RENAME RNNoise.txt COMPONENT DotnetMedia)
install(FILES ${CMAKE_CURRENT_LIST_DIR}/NOTICE.txt DESTINATION licenses COMPONENT DotnetMedia)
if(VOICECAT_OPUS_LICENSE)
install(FILES ${VOICECAT_OPUS_LICENSE} DESTINATION licenses RENAME Opus.txt COMPONENT DotnetMedia)
endif()
+12
View File
@@ -0,0 +1,12 @@
VoiceCat desktop codec/DSP bindings
Opus 1.5.2: BSD-3-Clause. See Opus.txt for copyright, license, and patent notices.
Upstream: https://opus-codec.org/
Release: https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
SHA-256: 65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1
RNNoise code: BSD-3-Clause. See RNNoise.txt.
RNNoise model weights: CC0-1.0, as recorded in third_party/README.md.
Upstream: https://github.com/xiph/rnnoise
Vendored commit: 70f1d256acd4b34a572f999a05c87bf00b67730d
CC0: https://creativecommons.org/publicdomain/zero/1.0/
+55
View File
@@ -0,0 +1,55 @@
#include <opus.h>
#include "rnnoise.h"
#ifdef _WIN32
#define VC_EXPORT __declspec(dllexport)
#else
#define VC_EXPORT __attribute__((visibility("default")))
#endif
VC_EXPORT const char *vcm_opus_version(void) { return opus_get_version_string(); }
VC_EXPORT const char *vcm_opus_error(int error) { return opus_strerror(error); }
VC_EXPORT OpusEncoder *vcm_encoder_create(int rate, int channels, int application, int *error) {
return opus_encoder_create(rate, channels, application, error);
}
VC_EXPORT void vcm_encoder_destroy(OpusEncoder *encoder) { opus_encoder_destroy(encoder); }
/* C varargs are called here, not through P/Invoke: Apple arm64 uses a distinct varargs ABI. */
VC_EXPORT int vcm_encoder_set(OpusEncoder *encoder, int request, int value) {
switch (request) {
case OPUS_SET_BITRATE_REQUEST: case OPUS_SET_MAX_BANDWIDTH_REQUEST:
case OPUS_SET_COMPLEXITY_REQUEST: case OPUS_SET_INBAND_FEC_REQUEST:
case OPUS_SET_DTX_REQUEST: case OPUS_SET_PACKET_LOSS_PERC_REQUEST:
case OPUS_SET_DRED_DURATION_REQUEST:
return opus_encoder_ctl(encoder, request, value);
default: return OPUS_BAD_ARG;
}
}
VC_EXPORT int vcm_encoder_get_dred(OpusEncoder *encoder, int *duration) {
return opus_encoder_ctl(encoder, OPUS_GET_DRED_DURATION(duration));
}
VC_EXPORT int vcm_encode(OpusEncoder *encoder, const short *pcm, int samples, unsigned char *packet, int capacity) {
return opus_encode(encoder, pcm, samples, packet, capacity);
}
VC_EXPORT OpusDecoder *vcm_decoder_create(int rate, int channels, int *error) {
return opus_decoder_create(rate, channels, error);
}
VC_EXPORT void vcm_decoder_destroy(OpusDecoder *decoder) { opus_decoder_destroy(decoder); }
VC_EXPORT int vcm_decode(OpusDecoder *decoder, const unsigned char *packet, int length, short *pcm, int samples, int fec) {
return opus_decode(decoder, packet, length, pcm, samples, fec);
}
VC_EXPORT OpusDREDDecoder *vcm_dred_decoder_create(int *error) { return opus_dred_decoder_create(error); }
VC_EXPORT void vcm_dred_decoder_destroy(OpusDREDDecoder *decoder) { opus_dred_decoder_destroy(decoder); }
VC_EXPORT OpusDRED *vcm_dred_create(int *error) { return opus_dred_alloc(error); }
VC_EXPORT void vcm_dred_destroy(OpusDRED *dred) { opus_dred_free(dred); }
VC_EXPORT int vcm_dred_parse(OpusDREDDecoder *decoder, OpusDRED *dred, const unsigned char *packet,
int length, int samples, int rate, int *end) {
return opus_dred_parse(decoder, dred, packet, length, samples, rate, end, 0);
}
VC_EXPORT int vcm_dred_decode(OpusDecoder *decoder, OpusDRED *dred, int offset, short *pcm, int samples) {
return opus_decoder_dred_decode(decoder, dred, offset, pcm, samples);
}
VC_EXPORT DenoiseState *vcm_rnnoise_create(void) { return rnnoise_create(NULL); }
VC_EXPORT void vcm_rnnoise_destroy(DenoiseState *state) { rnnoise_destroy(state); }
VC_EXPORT float vcm_rnnoise_process(DenoiseState *state, float *output, const float *input) {
return rnnoise_process_frame(state, output, input);
}
+29
View File
@@ -0,0 +1,29 @@
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()
+41
View File
@@ -0,0 +1,41 @@
#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;
}
+30
View File
@@ -0,0 +1,30 @@
#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;
}
+56
View File
@@ -0,0 +1,56 @@
#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;
}
+29
View File
@@ -0,0 +1,29 @@
#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;
}
+76
View File
@@ -0,0 +1,76 @@
#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;
}
}
+127
View File
@@ -0,0 +1,127 @@
#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;
}
+7
View File
@@ -0,0 +1,7 @@
param(
[string]$Runtime = 'win-x64',
[string]$Output = "$PSScriptRoot/artifacts/server/$Runtime"
)
$ErrorActionPreference = 'Stop'
dotnet publish "$PSScriptRoot/src/VoiceCat.Server/VoiceCat.Server.csproj" -c Release -r $Runtime --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false -p:RestoreLockedMode=true "-p:NuGetLockFilePath=packages.publish.$Runtime.lock.json" -o $Output
if ($LASTEXITCODE -ne 0) { throw 'Managed server publish failed.' }
@@ -0,0 +1,12 @@
namespace VoiceCat.Audio;
public sealed record AudioDeviceInfo(string Id, string Name, bool IsDefault);
public delegate void CapturePcmHandler(ReadOnlySpan<short> pcm, int channels);
public interface IAudioCapture : IDisposable { }
public interface IAudioPlayback : IDisposable { void Write(ReadOnlySpan<short> stereoPcm); }
public interface IAudioDeviceBackend
{
IReadOnlyList<AudioDeviceInfo> Enumerate(bool input);
IAudioCapture OpenCapture(string? deviceId, bool loopback, CapturePcmHandler pcm);
IAudioPlayback OpenPlayback(string? deviceId = null);
}
+170
View File
@@ -0,0 +1,170 @@
using System.Diagnostics;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Audio;
public sealed class AudioEngine : IDisposable
{
private readonly object gate = new();
private readonly EncodedVoiceSender sender;
private readonly int[] mixed = new int[1920];
private readonly short[] output = new short[1920];
private Routes routes = new([], [], 0);
private readonly List<(IDisposable Stream, long Epoch)> retired = [];
private long completedEpoch;
private readonly CancellationTokenSource stop = new();
private readonly Task maintenance;
private Thread? worker;
private int disposed;
public uint SampleClock { get; private set; }
public event MixedPcmHandler? MixedPcm;
public event PcmStreamHandler? StreamPcm;
public volatile float InputGain = 1, OutputGain = 1, VadThreshold = 0.02f;
public volatile bool InputNoiseReduction, MicMuted, Deafened, PushToTalk;
public volatile AudioInputMode InputMode = AudioInputMode.VoiceActivation;
public Exception? Failure { get; private set; }
public AudioEngine(EncodedVoiceSender sender, bool startWorker = true)
{
this.sender = sender;
maintenance = MaintainAsync();
if (startWorker)
{
worker = new Thread(Work) { IsBackground = true, Name = "VoiceCat managed audio" };
worker.Start();
}
}
public void AddLocalStream(StreamInfo info, int captureChannels = 1)
{
if (captureChannels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(captureChannels));
lock (gate)
{
ObjectDisposedException.ThrowIf(disposed != 0, this);
var stream = new LocalStream(info, captureChannels);
Routes previous = routes;
var locals = previous.Local.Where(s => s.Info.StreamId != info.StreamId).Append(stream).ToArray();
Publish(locals, previous.Remote);
}
}
public void RemoveLocalStream(uint streamId)
{
lock (gate) Publish(routes.Local.Where(s => s.Info.StreamId != streamId).ToArray(), routes.Remote);
}
public void SetCaptureChannels(uint streamId, int channels)
{
if (channels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(channels));
lock (gate)
{
var current = routes.Local.FirstOrDefault(s => s.Info.StreamId == streamId) ?? throw new ArgumentException("Stream not found.");
if (current.CaptureChannels != channels) Publish(routes.Local.Select(s => s == current ? new LocalStream(s.Info, channels) : s).ToArray(), routes.Remote);
}
}
public void SetRemoteStreams(IReadOnlyList<User> users, uint selfId, uint channelId)
{
lock (gate)
{
var next = new List<ReceiveStream>();
foreach (User user in users.Where(u => u.Id != selfId && u.ChannelId == channelId))
foreach (StreamInfo info in user.Streams)
{
var previous = routes.Remote.FirstOrDefault(s => s.UserId == user.Id && s.Info.Equals(info));
next.Add(previous ?? new ReceiveStream(user.Id, info));
}
Publish(routes.Local, next.ToArray());
}
}
public bool FeedPcm(uint streamId, ReadOnlySpan<short> pcm, int channels)
{
foreach (LocalStream stream in Volatile.Read(ref routes).Local)
if (stream.Info.StreamId == streamId) return stream.Feed(pcm, channels);
return false;
}
public void Receive(VoiceFrameHeader header, ReadOnlySpan<byte> packet)
{
foreach (ReceiveStream stream in Volatile.Read(ref routes).Remote) if (stream.Info.Ssrc == header.Ssrc) { stream.Enqueue(header, packet); return; }
}
public (float Level, bool Talking) GetLocalLevel(uint streamId)
{
foreach (LocalStream stream in Volatile.Read(ref routes).Local) if (stream.Info.StreamId == streamId) return (stream.Level, stream.Talking);
return default;
}
public void SetRemotePlayback(uint userId, uint streamId, float gain, bool muted, bool noiseReduction)
{
if (!float.IsFinite(gain) || gain < 0 || gain > 4) throw new ArgumentOutOfRangeException(nameof(gain));
foreach (var stream in Volatile.Read(ref routes).Remote)
if (stream.UserId == userId && stream.Info.StreamId == streamId) { stream.Gain = gain; stream.Muted = muted; stream.NoiseReduction = noiseReduction; return; }
}
public (float Gain, bool Muted, bool NoiseReduction)? GetRemotePlayback(uint userId, uint streamId)
{
foreach (var stream in Volatile.Read(ref routes).Remote) if (stream.UserId == userId && stream.Info.StreamId == streamId) return (stream.Gain, stream.Muted, stream.NoiseReduction);
return null;
}
private void Publish(LocalStream[] local, ReceiveStream[] remote)
{
Routes previous = routes; var next = new Routes(local, remote, previous.Epoch + 1);
foreach (var stream in previous.Local) if (!local.Contains(stream)) retired.Add((stream, next.Epoch));
foreach (var stream in previous.Remote) if (!remote.Contains(stream)) retired.Add((stream, next.Epoch));
Volatile.Write(ref routes, next);
}
// One audio owner calls this. No allocation, waiting, lock, registry mutation or disposal
// occurs inside a mix cycle. Callbacks receive borrowed spans and must follow that rule.
internal void ProcessCycle()
{
Routes current = Volatile.Read(ref routes);
mixed.AsSpan().Clear();
foreach (var stream in current.Local) stream.Process(this, sender);
foreach (var stream in current.Remote) stream.Mix(mixed, Deafened, StreamPcm);
float gain = Deafened ? 0 : OutputGain;
for (int i = 0; i < output.Length; i++) output[i] = (short)Math.Clamp((int)(mixed[i] * gain), short.MinValue, short.MaxValue);
MixedPcm?.Invoke(output);
SampleClock = unchecked(SampleClock + 960);
Volatile.Write(ref completedEpoch, current.Epoch);
}
private void Work()
{
long deadline = Stopwatch.GetTimestamp();
try
{
while (!stop.IsCancellationRequested)
{
ProcessCycle();
deadline += Stopwatch.Frequency / 50;
double remaining = (deadline - Stopwatch.GetTimestamp()) * 1000.0 / Stopwatch.Frequency;
if (remaining > 0) Thread.Sleep((int)Math.Ceiling(remaining));
else if (remaining < -100) deadline = Stopwatch.GetTimestamp();
}
}
catch (Exception exception) { Failure = exception; stop.Cancel(); }
}
private async Task MaintainAsync()
{
try
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(20));
while (await timer.WaitForNextTickAsync(stop.Token).ConfigureAwait(false))
lock (gate)
for (int i = retired.Count - 1; i >= 0; i--)
if (retired[i].Epoch <= Volatile.Read(ref completedEpoch)) { retired[i].Stream.Dispose(); retired.RemoveAt(i); }
}
catch (OperationCanceledException) when (stop.IsCancellationRequested) { }
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
stop.Cancel(); worker?.Join(); maintenance.GetAwaiter().GetResult();
lock (gate)
{
foreach (var stream in routes.Local) stream.Dispose(); foreach (var stream in routes.Remote) stream.Dispose();
foreach (var stream in retired) stream.Stream.Dispose(); retired.Clear();
routes = new([], [], routes.Epoch + 1);
}
}
private sealed record Routes(LocalStream[] Local, ReceiveStream[] Remote, long Epoch);
}
+115
View File
@@ -0,0 +1,115 @@
using VoiceCat.Codec;
using VoiceCat.Dsp;
using VoiceCat.Protocol;
using Voicecat.V1;
using OpusApplication = Voicecat.V1.OpusApplication;
namespace VoiceCat.Audio;
public enum AudioInputMode { VoiceActivation, PushToTalk, AlwaysOn }
public delegate bool EncodedVoiceSender(uint ssrc, uint timestamp, ReadOnlySpan<byte> payload, VoiceFrameFlags flags);
public delegate void PcmStreamHandler(uint userId, uint streamId, ReadOnlySpan<short> pcm, int channels);
public delegate void MixedPcmHandler(ReadOnlySpan<short> stereoPcm);
internal sealed class LocalStream : IDisposable
{
internal readonly StreamInfo Info;
internal readonly int CaptureChannels;
internal readonly PcmRing Input = new(16384);
internal volatile float Level;
internal volatile bool Talking;
private readonly OpusEncoder encoder;
private readonly RnnoiseProcessor left, right;
private readonly EnergyVadProcessor vad = new();
private readonly short[] capture = new short[1920], wire = new short[5760 * 2], mono = new short[960];
private readonly byte[] packet = new byte[1275];
private readonly short[] converted = new short[16384];
private int feeding;
private int buffered;
private uint timestamp;
private bool wasTransmitting, marker;
internal bool Feed(ReadOnlySpan<short> pcm, int channels)
{
if (channels is not (1 or 2) || pcm.Length % channels != 0 || pcm.Length / channels * CaptureChannels > converted.Length || Interlocked.CompareExchange(ref feeding, 1, 0) != 0) return false;
try
{
if (channels == CaptureChannels) return Input.TryWrite(pcm);
int frames = pcm.Length / channels;
for (int i = 0; i < frames; i++)
if (CaptureChannels == 1) converted[i] = (short)(((int)pcm[i * 2] + pcm[i * 2 + 1]) / 2);
else { converted[2 * i] = pcm[i]; converted[2 * i + 1] = pcm[i]; }
return Input.TryWrite(converted.AsSpan(0, frames * CaptureChannels));
}
finally { Volatile.Write(ref feeding, 0); }
}
internal LocalStream(StreamInfo stream, int captureChannels)
{
Info = stream.Clone(); CaptureChannels = captureChannels;
encoder = new(new()
{
Channels = stream.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1,
FrameDurationMilliseconds = checked((int)stream.Audio.FrameMs), MaximumBandwidthHz = checked((int)stream.Audio.SampleRate),
Bitrate = checked((int)stream.Audio.BitrateBps), Complexity = checked((int)stream.Audio.Complexity),
ExpectedPacketLossPercent = checked((int)stream.Audio.ExpectedPacketLoss), ForwardErrorCorrection = stream.Audio.Fec,
DiscontinuousTransmission = stream.Audio.Dtx, DeepRedundancy = stream.Audio.Dred,
Application = stream.Audio.Application switch { OpusApplication.OpusAudio => VoiceCat.Codec.OpusApplication.Audio, OpusApplication.OpusLowdelay => VoiceCat.Codec.OpusApplication.LowDelay, _ => VoiceCat.Codec.OpusApplication.Voip }
});
try { left = new(); } catch { encoder.Dispose(); throw; }
try { right = new(); } catch { left.Dispose(); encoder.Dispose(); throw; }
}
internal void Process(AudioEngine engine, EncodedVoiceSender sender)
{
var input = capture.AsSpan(0, 960 * CaptureChannels);
if (Input.Count < input.Length) { Level = 0; Talking = false; buffered = 0; wasTransmitting = false; return; }
while (Input.Count > input.Length * 6) Input.Read(input);
if (buffered == 0) timestamp = engine.SampleClock;
Input.Read(input);
bool mic = Info.Kind == StreamKind.StreamMic;
if (mic && engine.InputNoiseReduction)
{
if (CaptureChannels == 1) left.Process(input);
else
{
for (int i = 0; i < 960; i++) mono[i] = input[2 * i]; left.Process(mono);
for (int i = 0; i < 960; i++) input[2 * i] = mono[i];
for (int i = 0; i < 960; i++) mono[i] = input[2 * i + 1]; right.Process(mono);
for (int i = 0; i < 960; i++) input[2 * i + 1] = mono[i];
}
}
float gain = mic ? engine.InputGain : 1;
double energy = 0;
for (int i = 0; i < input.Length; i++) { input[i] = (short)Math.Clamp((int)(input[i] * gain), short.MinValue, short.MaxValue); energy += (double)input[i] * input[i]; }
Level = (float)(Math.Sqrt(energy / input.Length) / 32768);
vad.Threshold = engine.VadThreshold;
bool transmit = !mic || !engine.MicMuted && engine.InputMode switch
{
AudioInputMode.AlwaysOn => true, AudioInputMode.PushToTalk => engine.PushToTalk,
_ => vad.Process(input)
};
Talking = transmit && Level > 0.001f;
if (!transmit) { buffered = 0; wasTransmitting = false; return; }
if (!wasTransmitting) marker = true;
wasTransmitting = true;
int channels = encoder.Options.Channels;
for (int i = 0; i < 960; i++)
{
if (channels == 1) wire[buffered + i] = CaptureChannels == 1 ? input[i] : (short)(((int)input[2 * i] + input[2 * i + 1]) / 2);
else { wire[buffered + 2 * i] = input[i * CaptureChannels]; wire[buffered + 2 * i + 1] = input[i * CaptureChannels + CaptureChannels - 1]; }
}
buffered += 960 * channels;
int frame = encoder.Options.SamplesPerChannel * channels;
while (buffered >= frame)
{
int length = encoder.Encode(wire.AsSpan(0, frame), packet);
VoiceFrameFlags flags = (Info.Audio.Fec ? VoiceFrameFlags.FecPresent : VoiceFrameFlags.None) | (marker ? VoiceFrameFlags.Marker : VoiceFrameFlags.None);
sender(Info.Ssrc, timestamp, packet.AsSpan(0, length), flags); marker = false;
timestamp = unchecked(timestamp + (uint)encoder.Options.SamplesPerChannel);
buffered -= frame;
wire.AsSpan(frame, buffered).CopyTo(wire);
}
}
public void Dispose() { encoder.Dispose(); left.Dispose(); right.Dispose(); }
}
+34
View File
@@ -0,0 +1,34 @@
namespace VoiceCat.Audio;
// Single consumer, non-waiting producer gate. Whole writes either fit or drop, so
// channels remain aligned and a capture thread never waits for a mixer/network owner.
public sealed class PcmRing
{
private readonly short[] samples;
private readonly int mask;
private int read, written, producer;
public PcmRing(int capacity = 32768)
{
if (capacity < 2 || (capacity & (capacity - 1)) != 0) throw new ArgumentOutOfRangeException(nameof(capacity));
samples = new short[capacity]; mask = capacity - 1;
}
public int Count => unchecked(Volatile.Read(ref written) - Volatile.Read(ref read));
public bool TryWrite(ReadOnlySpan<short> source)
{
if (Interlocked.CompareExchange(ref producer, 1, 0) != 0) return false;
try
{
int index = written;
if (source.Length > samples.Length - unchecked(index - Volatile.Read(ref read))) return false;
for (int i = 0; i < source.Length; i++) samples[(index + i) & mask] = source[i];
Volatile.Write(ref written, unchecked(index + source.Length)); return true;
}
finally { Volatile.Write(ref producer, 0); }
}
public int Read(Span<short> destination)
{
int index = read, count = Math.Min(destination.Length, unchecked(Volatile.Read(ref written) - index));
for (int i = 0; i < count; i++) destination[i] = samples[(index + i) & mask];
Volatile.Write(ref read, unchecked(index + count)); return count;
}
}
+163
View File
@@ -0,0 +1,163 @@
using VoiceCat.Codec;
using VoiceCat.Dsp;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Audio;
internal sealed class ReceiveStream : IDisposable
{
internal readonly uint UserId;
internal readonly StreamInfo Info;
internal volatile float Gain = 1;
internal volatile bool Muted, NoiseReduction;
private readonly OpusDecoder decoder;
private readonly OpusDeepRedundancy? dred;
private readonly RnnoiseProcessor left, right;
private readonly short[] pcm = new short[5760 * 2];
private readonly short[] mono = new short[960];
private readonly short[] block = new short[1920];
private readonly byte[][] packets = Enumerable.Range(0, 64).Select(_ => new byte[1275]).ToArray();
private readonly VoiceFrameHeader[] headers = new VoiceFrameHeader[64];
private readonly int[] lengths = new int[64];
private int read, written;
private readonly byte[][] jitter = Enumerable.Range(0, 6).Select(_ => new byte[1275]).ToArray();
private readonly uint[] timestamps = new uint[6];
private readonly int[] sizes = new int[6];
private int count, available, offset, missing, waiting;
private uint expected;
private bool started, hasTimestamp;
private bool hasMarker;
private uint lastMarker;
private readonly int channels, frameSamples, maximumDepth;
internal int Depth => count;
internal int ConcealedFrames { get; private set; }
internal int DredFrames { get; private set; }
internal int FecFrames { get; private set; }
internal ReceiveStream(uint userId, StreamInfo info)
{
if (info.Audio.FrameMs is not (5 or 10 or 20 or 40 or 60) || !Enum.IsDefined(info.Audio.Mode)) throw new ArgumentException("Unsupported remote audio configuration.", nameof(info));
UserId = userId; Info = info.Clone();
channels = info.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1;
frameSamples = checked((int)info.Audio.FrameMs * 48);
maximumDepth = Math.Clamp(120 / (int)info.Audio.FrameMs, 2, 6);
decoder = new(48000, channels);
try { left = new(); } catch { decoder.Dispose(); throw; }
try { right = new(); } catch { left.Dispose(); decoder.Dispose(); throw; }
try { if (info.Audio.Dred) dred = new(); } catch { right.Dispose(); left.Dispose(); decoder.Dispose(); throw; }
}
// Only the network receive owner calls this; mixer alone consumes.
internal bool Enqueue(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
{
int index = written;
if (payload.Length is < 1 or > 1275 || unchecked(index - Volatile.Read(ref read)) >= 64) return false;
int slot = index & 63; payload.CopyTo(packets[slot]); headers[slot] = header; lengths[slot] = payload.Length;
Volatile.Write(ref written, unchecked(index + 1)); return true;
}
private void Drain()
{
while (read != Volatile.Read(ref written))
{
int source = read & 63; uint timestamp = headers[source].Timestamp;
if (!hasTimestamp) { hasTimestamp = true; expected = timestamp; }
int delta = unchecked((int)(timestamp - expected));
if ((headers[source].Flags & VoiceFrameFlags.Marker) != 0 && (!hasMarker || unchecked((int)(timestamp - lastMarker)) > 0))
{
hasMarker = true; lastMarker = timestamp;
sizes.AsSpan().Clear(); count = available = offset = missing = waiting = 0;
expected = timestamp; started = false; delta = 0;
}
bool duplicate = false;
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == timestamp) duplicate = true;
if ((!started || delta >= 0) && delta % frameSamples == 0 && !duplicate)
{
if (count >= maximumDepth)
{
int oldest = Oldest(); sizes[oldest] = 0; count--;
}
int target = Array.IndexOf(sizes, 0);
timestamps[target] = timestamp; sizes[target] = lengths[source]; packets[source].AsSpan(0, lengths[source]).CopyTo(jitter[target]); count++;
int oldestRemaining = Oldest();
if (count >= maximumDepth && unchecked((int)(timestamps[oldestRemaining] - expected)) > 0) expected = timestamps[oldestRemaining];
}
Volatile.Write(ref read, unchecked(read + 1));
}
}
private int Oldest()
{
int oldest = -1;
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && (oldest < 0 || unchecked((int)(timestamps[i] - timestamps[oldest])) < 0)) oldest = i;
return oldest;
}
private void Decode()
{
available = frameSamples; offset = 0;
int found = -1;
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == expected) { found = i; break; }
bool decoded = false;
if (found >= 0)
{
decoded = decoder.TryDecode(jitter[found].AsSpan(0, sizes[found]), pcm, frameSamples, out int result) && result == frameSamples;
sizes[found] = 0; count--; missing = decoded ? 0 : missing + 1;
}
else
{
int next = Oldest(); missing++;
if (next >= 0 && unchecked((int)(timestamps[next] - expected)) == frameSamples)
{
var packet = jitter[next].AsSpan(0, sizes[next]);
if (dred?.TryRecover(decoder, packet, pcm, frameSamples) == true) { decoded = true; DredFrames++; }
else if (Info.Audio.Fec && decoder.TryDecode(packet, pcm, frameSamples, out int recovered, true) && recovered == frameSamples) { decoded = true; FecFrames++; }
}
}
int maximumConcealment = Math.Max(1, 200 / (int)Info.Audio.FrameMs);
if (!decoded && missing <= maximumConcealment)
{
decoded = decoder.TryDecode([], pcm, frameSamples, out _); ConcealedFrames++;
}
if (!decoded || missing > maximumConcealment) pcm.AsSpan(0, frameSamples * channels).Clear();
expected = unchecked(expected + (uint)frameSamples);
}
internal void Mix(Span<int> output, bool deafened, PcmStreamHandler? sink)
{
Drain();
if (!started)
{
waiting++;
if (!hasTimestamp || count < Math.Min(3, maximumDepth) && waiting < 3) return;
expected = timestamps[Oldest()]; started = true;
}
int copied = 0;
while (copied < 960)
{
if (available == 0) Decode();
int take = Math.Min(960 - copied, available);
var decoded = pcm.AsSpan(offset * channels, take * channels);
decoded.CopyTo(block.AsSpan(copied * channels));
available -= take; offset += take; copied += take;
}
var samples = block.AsSpan(0, 960 * channels);
if (NoiseReduction && Info.Kind == StreamKind.StreamMic)
{
if (channels == 1) left.Process(samples);
else
{
for (int i = 0; i < 960; i++) mono[i] = samples[2 * i]; left.Process(mono);
for (int i = 0; i < 960; i++) samples[2 * i] = mono[i];
for (int i = 0; i < 960; i++) mono[i] = samples[2 * i + 1]; right.Process(mono);
for (int i = 0; i < 960; i++) samples[2 * i + 1] = mono[i];
}
}
float gain = Muted || deafened ? 0 : Gain;
for (int i = 0; i < samples.Length; i++) samples[i] = (short)Math.Clamp((int)(samples[i] * gain), short.MinValue, short.MaxValue);
sink?.Invoke(UserId, Info.StreamId, samples, channels);
for (int i = 0; i < 960; i++) { output[i * 2] += samples[i * channels]; output[i * 2 + 1] += samples[i * channels + channels - 1]; }
}
public void Dispose() { decoder.Dispose(); dred?.Dispose(); left.Dispose(); right.Dispose(); }
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../VoiceCat.Codec/VoiceCat.Codec.csproj" />
<ProjectReference Include="../VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<ProjectReference Include="../VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<InternalsVisibleTo Include="VoiceCat.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,24 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}
@@ -0,0 +1,31 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/win-x64": {}
}
}
@@ -0,0 +1,31 @@
using Microsoft.Win32.SafeHandles;
namespace VoiceCat.Codec;
internal sealed class OpusEncoderHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public OpusEncoderHandle() : base(true) { }
internal OpusEncoderHandle(nint value) : this() => SetHandle(value);
protected override bool ReleaseHandle() { NativeMethods.EncoderDestroy(handle); return true; }
}
internal sealed class OpusDecoderHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public OpusDecoderHandle() : base(true) { }
internal OpusDecoderHandle(nint value) : this() => SetHandle(value);
protected override bool ReleaseHandle() { NativeMethods.DecoderDestroy(handle); return true; }
}
internal sealed class DredDecoderHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public DredDecoderHandle() : base(true) { }
internal DredDecoderHandle(nint value) : this() => SetHandle(value);
protected override bool ReleaseHandle() { NativeMethods.DredDecoderDestroy(handle); return true; }
}
internal sealed class DredHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public DredHandle() : base(true) { }
internal DredHandle(nint value) : this() => SetHandle(value);
protected override bool ReleaseHandle() { NativeMethods.DredDestroy(handle); return true; }
}
@@ -0,0 +1,40 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Codec;
internal static unsafe partial class NativeMethods
{
private const string Library = "voicecat_media";
[LibraryImport(Library, EntryPoint = "vcm_opus_version")]
internal static partial nint Version();
[LibraryImport(Library, EntryPoint = "vcm_opus_error")]
internal static partial nint Error(int error);
[LibraryImport(Library, EntryPoint = "vcm_encoder_create")]
internal static partial nint EncoderCreate(int rate, int channels, int application, out int error);
[LibraryImport(Library, EntryPoint = "vcm_encoder_destroy")]
internal static partial void EncoderDestroy(nint encoder);
[LibraryImport(Library, EntryPoint = "vcm_encoder_set")]
internal static partial int EncoderSet(OpusEncoderHandle encoder, int request, int value);
[LibraryImport(Library, EntryPoint = "vcm_encoder_get_dred")]
internal static partial int EncoderGetDred(OpusEncoderHandle encoder, out int duration);
[LibraryImport(Library, EntryPoint = "vcm_encode")]
internal static partial int Encode(OpusEncoderHandle encoder, short* pcm, int samples, byte* packet, int capacity);
[LibraryImport(Library, EntryPoint = "vcm_decoder_create")]
internal static partial nint DecoderCreate(int rate, int channels, out int error);
[LibraryImport(Library, EntryPoint = "vcm_decoder_destroy")]
internal static partial void DecoderDestroy(nint decoder);
[LibraryImport(Library, EntryPoint = "vcm_decode")]
internal static partial int Decode(OpusDecoderHandle decoder, byte* packet, int length, short* pcm, int samples, int fec);
[LibraryImport(Library, EntryPoint = "vcm_dred_decoder_create")]
internal static partial nint DredDecoderCreate(out int error);
[LibraryImport(Library, EntryPoint = "vcm_dred_decoder_destroy")]
internal static partial void DredDecoderDestroy(nint decoder);
[LibraryImport(Library, EntryPoint = "vcm_dred_create")]
internal static partial nint DredCreate(out int error);
[LibraryImport(Library, EntryPoint = "vcm_dred_destroy")]
internal static partial void DredDestroy(nint dred);
[LibraryImport(Library, EntryPoint = "vcm_dred_parse")]
internal static partial int DredParse(DredDecoderHandle decoder, DredHandle dred, byte* packet, int length, int samples, int rate, out int end);
[LibraryImport(Library, EntryPoint = "vcm_dred_decode")]
internal static partial int DredDecode(OpusDecoderHandle decoder, DredHandle dred, int offset, short* pcm, int samples);
}
+57
View File
@@ -0,0 +1,57 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Codec;
public sealed class OpusDecoder : IDisposable
{
private readonly OpusDecoderHandle handle;
public int SampleRate { get; }
public int Channels { get; }
public OpusDecoder(int sampleRate = 48000, int channels = 1)
{
new OpusOptions { SampleRate = sampleRate, Channels = channels }.Validate();
SampleRate = sampleRate;
Channels = channels;
handle = new(NativeMethods.DecoderCreate(sampleRate, channels, out int error));
if (error < 0 || handle.IsInvalid)
{
handle.Dispose();
OpusException.Check(error);
throw new OutOfMemoryException();
}
}
internal OpusDecoderHandle Handle => handle;
internal void ValidateOutput(Span<short> pcm, int samplesPerChannel)
{
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
if (samplesPerChannel <= 0 || samplesPerChannel > SampleRate * 120 / 1000 || samplesPerChannel % (SampleRate / 400) != 0)
throw new ArgumentOutOfRangeException(nameof(samplesPerChannel));
if (pcm.Length < samplesPerChannel * Channels) throw new ArgumentException("PCM storage is too small.", nameof(pcm));
}
public unsafe int Decode(ReadOnlySpan<byte> packet, Span<short> pcm, int samplesPerChannel, bool recoverPreviousFrame = false)
{
ValidateOutput(pcm, samplesPerChannel);
if (packet.Overlaps(MemoryMarshal.AsBytes(pcm))) throw new ArgumentException("Packet and PCM storage must not overlap.");
fixed (byte* input = packet)
fixed (short* output = pcm)
return OpusException.Check(NativeMethods.Decode(handle, input, packet.Length, output, samplesPerChannel, recoverPreviousFrame ? 1 : 0));
}
public unsafe bool TryDecode(ReadOnlySpan<byte> packet, Span<short> pcm, int samplesPerChannel, out int decodedSamples, bool recoverPreviousFrame = false)
{
ValidateOutput(pcm, samplesPerChannel);
if (packet.Overlaps(MemoryMarshal.AsBytes(pcm))) throw new ArgumentException("Packet and PCM storage must not overlap.");
fixed (byte* input = packet)
fixed (short* output = pcm)
{
decodedSamples = NativeMethods.Decode(handle, input, packet.Length, output, samplesPerChannel, recoverPreviousFrame ? 1 : 0);
return decodedSamples >= 0;
}
}
public void Dispose() => handle.Dispose();
}
@@ -0,0 +1,51 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Codec;
public sealed class OpusDeepRedundancy : IDisposable
{
private readonly DredDecoderHandle decoder;
private readonly DredHandle dred;
public OpusDeepRedundancy()
{
decoder = new(NativeMethods.DredDecoderCreate(out int error));
if (error < 0 || decoder.IsInvalid)
{
decoder.Dispose();
if (error == -5) throw new NotSupportedException("This libopus build does not include DRED.");
OpusException.Check(error);
throw new OutOfMemoryException();
}
dred = new(NativeMethods.DredCreate(out error));
if (error < 0 || dred.IsInvalid)
{
decoder.Dispose();
dred.Dispose();
if (error == -5) throw new NotSupportedException("This libopus build does not include DRED.");
OpusException.Check(error);
throw new OutOfMemoryException();
}
}
public unsafe bool TryRecover(OpusDecoder audioDecoder, ReadOnlySpan<byte> nextPacket, Span<short> pcm, int samplesPerChannel, int? offset = null)
{
ObjectDisposedException.ThrowIf(decoder.IsClosed, this);
ArgumentNullException.ThrowIfNull(audioDecoder);
audioDecoder.ValidateOutput(pcm, samplesPerChannel);
int recoveryOffset = offset ?? samplesPerChannel;
ArgumentOutOfRangeException.ThrowIfNegative(recoveryOffset);
if (nextPacket.IsEmpty) return false;
if (nextPacket.Overlaps(MemoryMarshal.AsBytes(pcm))) throw new ArgumentException("Packet and PCM storage must not overlap.");
fixed (byte* packet = nextPacket)
fixed (short* output = pcm)
{
int parsed = NativeMethods.DredParse(decoder, dred, packet, nextPacket.Length,
checked(samplesPerChannel + recoveryOffset), audioDecoder.SampleRate, out _);
if (parsed <= 0) return false;
return NativeMethods.DredDecode(audioDecoder.Handle, dred, recoveryOffset, output, samplesPerChannel) >= 0;
}
}
public void Dispose() { dred.Dispose(); decoder.Dispose(); }
}
+53
View File
@@ -0,0 +1,53 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Codec;
public sealed class OpusEncoder : IDisposable
{
private readonly OpusEncoderHandle handle;
public OpusOptions Options { get; }
public bool SupportsDeepRedundancy { get; }
public static string Version => Marshal.PtrToStringUTF8(NativeMethods.Version())!;
public OpusEncoder(OpusOptions? options = null)
{
Options = options ?? new();
Options.Validate();
handle = new(NativeMethods.EncoderCreate(Options.SampleRate, Options.Channels, (int)Options.Application, out int error));
try
{
OpusException.Check(error);
if (handle.IsInvalid) throw new OutOfMemoryException();
Set(4002, Options.Bitrate);
Set(4004, Options.MaximumBandwidthHz switch { 0 => 1105, <= 8000 => 1101, <= 12000 => 1102, <= 16000 => 1103, <= 24000 => 1104, _ => 1105 });
Set(4010, Options.Complexity);
Set(4012, Options.ForwardErrorCorrection ? 1 : 0);
Set(4016, Options.DiscontinuousTransmission ? 1 : 0);
Set(4014, Options.ExpectedPacketLossPercent);
int support = NativeMethods.EncoderGetDred(handle, out _);
if (support != -5) OpusException.Check(support);
SupportsDeepRedundancy = support == 0 && Options.SampleRate >= 16000;
if (Options.DeepRedundancy && !SupportsDeepRedundancy)
throw new NotSupportedException("DRED encoding requires a DRED-enabled libopus build and a PCM rate of at least 16 kHz.");
if (SupportsDeepRedundancy)
// Opus 1.5.2 requires two redundancy chunks; 20 ms alone cannot produce DRED.
Set(4050, Options.DeepRedundancy ? Math.Max(3, (Options.FrameDurationMilliseconds + 9) / 10) : 0);
}
catch { handle.Dispose(); throw; }
}
private void Set(int request, int value) => OpusException.Check(NativeMethods.EncoderSet(handle, request, value));
public unsafe int Encode(ReadOnlySpan<short> pcm, Span<byte> packet)
{
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
if (pcm.Length != Options.SamplesPerChannel * Options.Channels) throw new ArgumentException("PCM must contain exactly one interleaved frame.", nameof(pcm));
if (packet.IsEmpty) throw new ArgumentException("Packet storage must not be empty.", nameof(packet));
if (MemoryMarshal.AsBytes(pcm).Overlaps(packet)) throw new ArgumentException("PCM and packet storage must not overlap.");
fixed (short* input = pcm)
fixed (byte* output = packet)
return OpusException.Check(NativeMethods.Encode(handle, input, Options.SamplesPerChannel, output, packet.Length));
}
public void Dispose() => handle.Dispose();
}
@@ -0,0 +1,10 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Codec;
public sealed class OpusException : Exception
{
public int ErrorCode { get; }
internal OpusException(int error) : base(Marshal.PtrToStringUTF8(NativeMethods.Error(error))) => ErrorCode = error;
internal static int Check(int result) => result < 0 ? throw new OpusException(result) : result;
}
+32
View File
@@ -0,0 +1,32 @@
namespace VoiceCat.Codec;
public enum OpusApplication { Voip = 2048, Audio = 2049, LowDelay = 2051 }
public sealed record OpusOptions
{
public int SampleRate { get; init; } = 48000;
public int Channels { get; init; } = 1;
public int FrameDurationMilliseconds { get; init; } = 20;
public int Bitrate { get; init; } = 24000;
public int MaximumBandwidthHz { get; init; }
public int Complexity { get; init; } = 10;
public int ExpectedPacketLossPercent { get; init; }
public bool ForwardErrorCorrection { get; init; } = true;
public bool DiscontinuousTransmission { get; init; }
public bool DeepRedundancy { get; init; }
public OpusApplication Application { get; init; } = OpusApplication.Voip;
public int SamplesPerChannel => SampleRate / 1000 * FrameDurationMilliseconds;
internal void Validate()
{
if (SampleRate is not (8000 or 12000 or 16000 or 24000 or 48000)) throw new ArgumentOutOfRangeException(nameof(SampleRate));
if (Channels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(Channels));
if (FrameDurationMilliseconds is not (5 or 10 or 20 or 40 or 60)) throw new ArgumentOutOfRangeException(nameof(FrameDurationMilliseconds));
if (Application == OpusApplication.LowDelay && FrameDurationMilliseconds > 20) throw new ArgumentException("Low-delay Opus requires frames of at most 20 ms.");
if (!Enum.IsDefined(Application)) throw new ArgumentOutOfRangeException(nameof(Application));
if (Bitrate is < 500 or > 512000) throw new ArgumentOutOfRangeException(nameof(Bitrate));
if (Complexity is < 0 or > 10) throw new ArgumentOutOfRangeException(nameof(Complexity));
if (ExpectedPacketLossPercent is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(ExpectedPacketLossPercent));
ArgumentOutOfRangeException.ThrowIfNegative(MaximumBandwidthHz);
}
}
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
@@ -0,0 +1,6 @@
{
"version": 1,
"dependencies": {
"net10.0": {}
}
}
@@ -0,0 +1,14 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
}
},
"net10.0/win-x64": {}
}
}
@@ -0,0 +1,128 @@
using System.Net;
using System.Net.Sockets;
using VoiceCat.Protocol;
using VoiceCat.Crypto;
using VoiceCat.Transport;
namespace VoiceCat.Core;
public delegate void EncodedVoiceHandler(VoiceFrameHeader header, ReadOnlySpan<byte> payload);
internal sealed class ClientMediaTransport : IAsyncDisposable
{
private readonly Socket socket;
private readonly MediaSessionCrypto crypto;
private readonly CancellationTokenSource stop;
private readonly byte[] binding = new byte[VoiceFrameHeader.Size + 16];
private readonly byte[] keepalive = new byte[VoiceFrameHeader.Size];
private readonly PacketQueue packets = new();
private readonly Task sending;
private readonly Task receiving;
private readonly TaskCompletionSource bound = new(TaskCreationOptions.RunContinuationsAsynchronously);
internal event EncodedVoiceHandler? Received;
internal Task Bound => bound.Task;
internal ClientMediaTransport(IPEndPoint endpoint, ReadOnlySpan<byte> token, MediaSessionCrypto crypto, CancellationToken cancellationToken)
{
if (token.Length != 16) throw new IOException("Invalid UDP binding token.");
this.crypto = crypto;
stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
socket = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
try { socket.Connect(endpoint); }
catch { socket.Dispose(); stop.Dispose(); throw; }
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
token.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
receiving = ReceiveAsync();
sending = SendAsync();
}
internal bool TrySend(VoiceFrameHeader header, ReadOnlySpan<byte> payload) => packets.TryWrite(header, payload);
private async Task SendAsync()
{
byte[] plain = new byte[1275], packet = new byte[1275 + VoiceFrameHeader.Size + MediaEncryptor.TagSize];
long nextKeepalive = 0;
try
{
while (!stop.IsCancellationRequested)
{
if (Environment.TickCount64 >= nextKeepalive)
{
if (!bound.Task.IsCompleted) await socket.SendAsync(binding, SocketFlags.None, stop.Token).ConfigureAwait(false);
await socket.SendAsync(keepalive, SocketFlags.None, stop.Token).ConfigureAwait(false);
nextKeepalive = Environment.TickCount64 + (bound.Task.IsCompleted ? 5000 : 250);
}
while (packets.TryRead(plain, out VoiceFrameHeader header, out int length))
{
int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet);
await socket.SendAsync(packet.AsMemory(0, size), SocketFlags.None, stop.Token).ConfigureAwait(false);
}
await Task.Delay(5, stop.Token).ConfigureAwait(false);
}
}
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException)
{ if (!stop.IsCancellationRequested) bound.TrySetException(exception); }
finally { stop.Cancel(); }
}
private async Task ReceiveAsync()
{
byte[] packet = new byte[65535], plain = new byte[65535];
try
{
while (true)
{
int length;
try { length = await socket.ReceiveAsync(packet, SocketFlags.None, stop.Token).ConfigureAwait(false); }
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionReset or SocketError.MessageSize) { continue; }
if (!VoiceFrameHeader.TryRead(packet.AsSpan(0, length), out var candidate)) continue;
if (candidate.Type == MediaFrameType.Keepalive && length == VoiceFrameHeader.Size) { bound.TrySetResult(); continue; }
if (candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
!crypto.Decryptor.TryDecrypt(packet.AsSpan(0, length), plain, out var header, out int size)) continue;
Received?.Invoke(header, plain.AsSpan(0, size));
}
}
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException)
{ if (!stop.IsCancellationRequested) bound.TrySetException(exception); }
finally { bound.TrySetCanceled(); stop.Cancel(); }
}
public async ValueTask DisposeAsync()
{
stop.Cancel(); socket.Dispose();
try { await Task.WhenAll(sending, receiving).ConfigureAwait(false); }
finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding); stop.Dispose(); }
}
// A bounded, allocation-free packet handoff. A contending producer drops instead of
// waiting; the network owner alone consumes and encrypts. Audio never enters a Channel lock.
private sealed class PacketQueue
{
private readonly byte[][] payloads = Enumerable.Range(0, 64).Select(_ => new byte[1275]).ToArray();
private readonly VoiceFrameHeader[] headers = new VoiceFrameHeader[64];
private readonly int[] lengths = new int[64];
private int read, written, producer;
internal bool TryWrite(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
{
if (payload.Length is < 1 or > 1275 || Interlocked.CompareExchange(ref producer, 1, 0) != 0) return false;
try
{
int index = written;
if (unchecked(index - Volatile.Read(ref read)) >= 64) return false;
int slot = index & 63;
payload.CopyTo(payloads[slot]); headers[slot] = header; lengths[slot] = payload.Length;
Volatile.Write(ref written, unchecked(index + 1)); return true;
}
finally { Volatile.Write(ref producer, 0); }
}
internal bool TryRead(Span<byte> payload, out VoiceFrameHeader header, out int length)
{
int index = read; header = default; length = 0;
if (index == Volatile.Read(ref written)) return false;
int slot = index & 63; header = headers[slot]; length = lengths[slot];
payloads[slot].AsSpan(0, length).CopyTo(payload);
Volatile.Write(ref read, unchecked(index + 1)); return true;
}
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<ProjectReference Include="../VoiceCat.Audio/VoiceCat.Audio.csproj" />
<InternalsVisibleTo Include="VoiceCat.Tests" />
</ItemGroup>
</Project>
+292
View File
@@ -0,0 +1,292 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Threading.Channels;
using VoiceCat.Crypto;
using VoiceCat.Transport;
using VoiceCat.Protocol;
using VoiceCat.Audio;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.Core;
public enum ClientConnectionState { Disconnected, Connecting, VerifyingIdentity, Authenticating, Connected }
public sealed record ServerIdentityChallenge(string Host, ushort Port, string CertificateFingerprint, TofuStatus Status);
public sealed partial class VoiceCatClient : IAsyncDisposable
{
private readonly string clientName;
private readonly string clientVersion;
private readonly TofuStore pins;
private readonly SemaphoreSlim lifecycle = new(1);
private readonly CancellationTokenSource disposed = new();
private readonly object stateGate = new();
private readonly ConcurrentDictionary<ulong, TaskCompletionSource<Envelope>> pending = new();
private readonly System.Threading.Channels.Channel<Envelope> events = System.Threading.Channels.Channel.CreateBounded<Envelope>(128);
private readonly Dictionary<uint, Channel> channels = [];
private readonly Dictionary<uint, User> users = [];
private readonly Dictionary<uint, StreamInfo> localStreams = [];
public AudioEngine Audio { get; }
public IReadOnlyList<StreamInfo> LocalStreams { get { lock (stateGate) return localStreams.Values.Select(s => s.Clone()).ToArray(); } }
private TlsControlConnection? control;
private MediaSessionCrypto? mediaCrypto;
private ClientMediaTransport? media;
private Task keepalive = Task.CompletedTask;
public event EncodedVoiceHandler? VoiceReceived;
private CancellationTokenSource? connectionLifetime;
private Task reader = Task.CompletedTask;
private long nextRequest;
private AuthResult? authentication;
private ServerHello? hello;
private ClientConnectionState state;
public event Action<ClientConnectionState>? ConnectionStateChanged;
public ClientConnectionState State { get { lock (stateGate) return state; } }
public Task Completion => reader;
public AuthResult? Authentication { get { lock (stateGate) return authentication?.Clone(); } }
public ServerHello? ServerHello { get { lock (stateGate) return hello?.Clone(); } }
public IReadOnlyList<Channel> Channels { get { lock (stateGate) return channels.Values.Select(c => c.Clone()).ToArray(); } }
public IReadOnlyList<User> Users { get { lock (stateGate) return users.Values.Select(u => u.Clone()).ToArray(); } }
public bool TryReadEvent(out Envelope? envelope) => events.Reader.TryRead(out envelope);
public IAsyncEnumerable<Envelope> ReadEventsAsync(CancellationToken cancellationToken = default) => events.Reader.ReadAllAsync(cancellationToken);
public VoiceCatClient(string clientName = "VoiceCat .NET", string clientVersion = "0.1.0", string? tofuStorePath = null)
{
this.clientName = clientName;
this.clientVersion = clientVersion;
pins = new(tofuStorePath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VoiceCat", "tofu.txt"));
Audio = new(TrySendEncodedVoice);
VoiceReceived += Audio.Receive;
}
public async Task ConnectAsync(string host, ushort port, Func<ServerIdentityChallenge, CancellationToken, ValueTask<bool>>? confirmIdentity = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
ArgumentOutOfRangeException.ThrowIfZero(port);
await lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false);
Socket? socket = null;
bool started = false;
try
{
ObjectDisposedException.ThrowIf(disposed.IsCancellationRequested, this);
if (control is not null) throw new InvalidOperationException("Disconnect before reconnecting.");
started = true;
connectionLifetime = CancellationTokenSource.CreateLinkedTokenSource(disposed.Token);
using var connecting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, connectionLifetime.Token);
CancellationToken token = connecting.Token;
SetState(ClientConnectionState.Connecting);
socket = new(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
await socket.ConnectAsync(host, port, token).ConfigureAwait(false);
string? fingerprint = null;
control = new(socket, TlsSession.CreateClient(value => { fingerprint = value; return true; }), connectionLifetime.Token);
socket = null; // Transport owns it from here.
mediaCrypto = await control.TakeMediaCryptoAsync(token).ConfigureAwait(false);
string certificatePin = fingerprint ?? throw new IOException("TLS did not report a certificate fingerprint.");
TofuStatus pinStatus = pins.Check(host, port, certificatePin);
if (pinStatus != TofuStatus.Matched)
{
SetState(ClientConnectionState.VerifyingIdentity);
if (confirmIdentity is null || !await confirmIdentity(new(host, port, certificatePin, pinStatus), token).ConfigureAwait(false))
throw new System.Security.Authentication.AuthenticationException("Server identity was rejected.");
pins.Pin(host, port, certificatePin);
}
reader = ReadAsync(control, connectionLifetime.Token);
Envelope response = await RequestAsync(new() { ClientHello = new() { ProtoVersion = 2, ClientName = clientName, ClientVersion = clientVersion } }, token).ConfigureAwait(false);
if (response.ServerHello?.ProtoVersion != 2) throw new IOException("Unsupported server protocol.");
lock (stateGate) hello = response.ServerHello.Clone();
keepalive = KeepaliveAsync(connectionLifetime.Token);
SetState(ClientConnectionState.Authenticating);
}
catch
{
socket?.Dispose();
if (started) await CloseAsync().ConfigureAwait(false);
throw;
}
finally { lifecycle.Release(); }
}
public Task<AuthResult> AuthenticateGuestAsync(string nickname, CancellationToken cancellationToken = default) =>
AuthenticateAsync(new() { Guest = new() { Nickname = nickname } }, cancellationToken);
public Task<AuthResult> AuthenticateUserAsync(string username, string password, CancellationToken cancellationToken = default) =>
AuthenticateAsync(new() { Password = new() { Username = username, Password = password } }, cancellationToken);
private async Task<AuthResult> AuthenticateAsync(AuthRequest request, CancellationToken cancellationToken)
{
if (State != ClientConnectionState.Authenticating) throw new InvalidOperationException("Authentication requires a connected TLS session.");
Envelope response = await RequestAsync(new() { AuthRequest = request }, cancellationToken).ConfigureAwait(false);
AuthResult result = response.AuthResult ?? throw new IOException("Unexpected authentication response.");
if (result.Ok)
{
var endpoint = (IPEndPoint)control!.RemoteEndPoint;
IPAddress address = endpoint.Address.IsIPv4MappedToIPv6 ? endpoint.Address.MapToIPv4() : endpoint.Address;
media = new(new(address, checked((int)ServerHello!.UdpPort)), result.UdpToken.Span, mediaCrypto!, connectionLifetime!.Token);
media.Received += (header, packet) => VoiceReceived?.Invoke(header, packet);
SetState(ClientConnectionState.Connected);
}
return result;
}
public async Task<VoiceSubscriptionResult> SubscribeVoiceAsync(bool subscribe = true, CancellationToken cancellationToken = default)
{
if (subscribe && media is not null) await media.Bound.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
return (await RequestAsync(subscribe ? new() { SubscribeVoice = new() } : new() { UnsubscribeVoice = new() }, cancellationToken).ConfigureAwait(false)).VoiceSubscriptionResult;
}
public bool TrySendEncodedVoice(uint ssrc, uint timestamp, ReadOnlySpan<byte> payload, VoiceFrameFlags flags = VoiceFrameFlags.None) =>
media?.TrySend(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload) == true;
public async Task<StreamInfo> StartStreamAsync(StreamKind kind, string label = "", int captureChannels = 1, CancellationToken cancellationToken = default)
{
if (State != ClientConnectionState.Connected) throw new InvalidOperationException("Client is disconnected.");
var response = (await RequestAsync(new() { StreamAnnounce = new() { Kind = kind, Label = label } }, cancellationToken).ConfigureAwait(false)).StreamAnnounceResult;
if (!response.Ok) throw new InvalidOperationException(response.Error);
var info = new StreamInfo { StreamId = response.StreamId, Ssrc = response.Ssrc, Kind = kind, Audio = response.EffectiveAudio.Clone(), Label = label };
try
{
lock (stateGate) { if (State != ClientConnectionState.Connected) throw new InvalidOperationException("Client disconnected during stream negotiation."); Audio.AddLocalStream(info, captureChannels); localStreams[info.StreamId] = info; }
return info.Clone();
}
catch { if (State == ClientConnectionState.Connected) Send(new() { StreamStop = new() { StreamId = info.StreamId } }); throw; }
}
public void StopStream(uint streamId)
{
lock (stateGate) { localStreams.Remove(streamId); Audio.RemoveLocalStream(streamId); }
Send(new() { StreamStop = new() { StreamId = streamId } });
}
private async Task KeepaliveAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) Send(new() { Ping = new() { Nonce = checked((ulong)Environment.TickCount64) } });
}
catch (Exception exception) when (exception is OperationCanceledException or IOException or InvalidOperationException) { }
}
public async Task<Envelope> RequestAsync(Envelope request, CancellationToken cancellationToken = default)
{
TlsControlConnection connection = control ?? throw new InvalidOperationException("Client is disconnected.");
var completion = new TaskCompletionSource<Envelope>(TaskCreationOptions.RunContinuationsAsynchronously);
ulong id = checked((ulong)Interlocked.Increment(ref nextRequest));
Envelope outbound = request.Clone(); outbound.RequestId = id;
if (!pending.TryAdd(id, completion)) throw new InvalidOperationException("Request ids exhausted.");
try
{
if (!connection.TrySend(outbound)) throw new IOException("Control queue is full or closed.");
return await completion.Task.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false);
}
finally { pending.TryRemove(id, out _); }
}
public void Send(Envelope message)
{
TlsControlConnection connection = control ?? throw new InvalidOperationException("Client is disconnected.");
if (!connection.TrySend(message.Clone())) throw new IOException("Control queue is full or closed.");
}
private async Task ReadAsync(TlsControlConnection connection, CancellationToken cancellationToken)
{
Exception? failure = null;
try
{
await foreach (Envelope message in connection.ReadAsync(cancellationToken).ConfigureAwait(false))
{
Apply(message);
if (message.RequestId != 0 && pending.TryRemove(message.RequestId, out var completion)) completion.TrySetResult(message.Clone());
if (!events.Writer.TryWrite(message.Clone())) throw new IOException("Client event queue exhausted; consume events regularly.");
if (message.Disconnect is not null) { connection.CompleteWrites(); break; }
}
}
catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { failure = exception; }
finally
{
connectionLifetime?.Cancel();
foreach (var operation in pending.Values) operation.TrySetException(failure ?? new IOException("Connection closed."));
SetState(ClientConnectionState.Disconnected);
}
}
private void Apply(Envelope message)
{
lock (stateGate)
{
if (message.AuthResult?.Ok == true) authentication = message.AuthResult.Clone();
if (message.ServerState is not null)
{
channels.Clear(); users.Clear();
foreach (var channel in message.ServerState.Channels) channels[channel.Id] = channel.Clone();
foreach (var user in message.ServerState.Users) users[user.Id] = user.Clone();
}
if (message.ChannelEvent is not null)
{
if (message.ChannelEvent.Kind == ChannelEvent.Types.Kind.Deleted) channels.Remove(message.ChannelEvent.DeletedId);
else if (message.ChannelEvent.Channel is not null) channels[message.ChannelEvent.Channel.Id] = message.ChannelEvent.Channel.Clone();
}
if (message.UserEvent is not null)
{
if (message.UserEvent.Kind == UserEvent.Types.Kind.Left) users.Remove(message.UserEvent.LeftId);
else if (message.UserEvent.User is not null) users[message.UserEvent.User.Id] = message.UserEvent.User.Clone();
}
if (authentication is not null && (message.ServerState is not null || message.UserEvent is not null))
{
User self = users.GetValueOrDefault(authentication.Self.Id, authentication.Self);
Audio.SetRemoteStreams(users.Values.ToArray(), self.Id, self.ChannelId);
foreach (var id in localStreams.Keys.Where(id => !self.Streams.Any(s => s.StreamId == id)).ToArray())
{ Audio.RemoveLocalStream(id); localStreams.Remove(id); }
}
}
}
private void SetState(ClientConnectionState value)
{
lock (stateGate) state = value;
ConnectionStateChanged?.Invoke(value);
}
public async Task DisconnectAsync()
{
connectionLifetime?.Cancel();
await lifecycle.WaitAsync().ConfigureAwait(false);
try { await CloseAsync().ConfigureAwait(false); }
finally { lifecycle.Release(); }
}
private async Task CloseAsync()
{
connectionLifetime?.Cancel();
try { await reader.ConfigureAwait(false); }
finally
{
try { await keepalive.ConfigureAwait(false); }
catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { }
try { if (media is not null) await media.DisposeAsync().ConfigureAwait(false); }
catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { }
try { if (control is not null) await control.DisposeAsync().ConfigureAwait(false); }
catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { }
control = null;
media = null;
mediaCrypto?.Dispose(); mediaCrypto = null;
connectionLifetime?.Dispose(); connectionLifetime = null;
lock (stateGate) { authentication = null; hello = null; channels.Clear(); users.Clear(); }
lock (stateGate)
{
foreach (var id in localStreams.Keys) Audio.RemoveLocalStream(id);
localStreams.Clear(); Audio.SetRemoteStreams([], 0, 0);
}
SetState(ClientConnectionState.Disconnected);
}
}
public async ValueTask DisposeAsync()
{
if (disposed.IsCancellationRequested) return;
disposed.Cancel();
await DisconnectAsync().ConfigureAwait(false);
events.Writer.TryComplete();
Audio.Dispose();
}
}
@@ -0,0 +1,44 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}
@@ -0,0 +1,51 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/win-x64": {}
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Parameters;
namespace VoiceCat.Crypto;
internal sealed class MediaCipher : IDisposable
{
private readonly byte[] key;
private readonly ChaCha20Poly1305? platformCipher;
private bool disposed;
public MediaCipher(ReadOnlySpan<byte> key, bool useManaged)
{
if (key.Length != 32) throw new ArgumentException("Media keys must contain 32 bytes.", nameof(key));
this.key = key.ToArray();
if (!useManaged && ChaCha20Poly1305.IsSupported) platformCipher = new(this.key);
}
public void Encrypt(ulong counter, ReadOnlySpan<byte> plaintext, ReadOnlySpan<byte> aad, Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
Span<byte> nonce = stackalloc byte[12];
nonce.Clear();
BinaryPrimitives.WriteUInt64BigEndian(nonce[4..], counter);
if (platformCipher is not null)
{
platformCipher.Encrypt(nonce, plaintext, output[..plaintext.Length], output.Slice(plaintext.Length, 16), aad);
return;
}
var cipher = new Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305();
cipher.Init(true, new AeadParameters(new KeyParameter(key), 128, nonce.ToArray(), aad.ToArray()));
int written = cipher.ProcessBytes(plaintext, output);
cipher.DoFinal(output[written..]);
}
public bool TryDecrypt(ulong counter, ReadOnlySpan<byte> sealedPayload, ReadOnlySpan<byte> aad, Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
Span<byte> nonce = stackalloc byte[12];
nonce.Clear();
BinaryPrimitives.WriteUInt64BigEndian(nonce[4..], counter);
int length = sealedPayload.Length - 16;
try
{
if (platformCipher is not null)
platformCipher.Decrypt(nonce, sealedPayload[..length], sealedPayload[length..], output[..length], aad);
else
{
var cipher = new Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305();
cipher.Init(false, new AeadParameters(new KeyParameter(key), 128, nonce.ToArray(), aad.ToArray()));
int written = cipher.ProcessBytes(sealedPayload, output);
cipher.DoFinal(output[written..]);
}
return true;
}
catch (Exception exception) when (exception is AuthenticationTagMismatchException or InvalidCipherTextException)
{
CryptographicOperations.ZeroMemory(output[..length]);
return false;
}
}
public void Dispose()
{
if (disposed) return;
disposed = true;
platformCipher?.Dispose();
CryptographicOperations.ZeroMemory(key);
}
}
@@ -0,0 +1,60 @@
using VoiceCat.Protocol;
namespace VoiceCat.Crypto;
public sealed class MediaDecryptor : IDisposable
{
private readonly MediaCipher cipher;
private ulong highestSequence;
private ulong replayWindow;
private bool initialized;
private bool disposed;
public MediaDecryptor(ReadOnlySpan<byte> key) : this(key, false) { }
internal MediaDecryptor(ReadOnlySpan<byte> key, bool useManaged) => cipher = new(key, useManaged);
public bool TryDecrypt(ReadOnlySpan<byte> packet, Span<byte> plaintext, out VoiceFrameHeader header, out int bytesWritten)
{
ObjectDisposedException.ThrowIf(disposed, this);
header = default;
bytesWritten = 0;
if (packet.Length < VoiceFrameHeader.Size + MediaEncryptor.TagSize) return false;
int length = packet.Length - VoiceFrameHeader.Size - MediaEncryptor.TagSize;
ArgumentOutOfRangeException.ThrowIfLessThan(plaintext.Length, length);
if (packet.Overlaps(plaintext)) throw new ArgumentException("Input and output must not overlap.", nameof(plaintext));
VoiceFrameHeader.TryRead(packet, out var candidate);
ulong sequence = candidate.Sequence;
if (initialized && sequence <= highestSequence)
{
ulong offset = highestSequence - sequence;
if (offset >= 64 || (replayWindow & (1UL << (int)offset)) != 0) return false;
}
if (!cipher.TryDecrypt(sequence, packet[VoiceFrameHeader.Size..], packet[..VoiceFrameHeader.Size], plaintext[..length])) return false;
// Only authenticated counters may move the replay window.
if (!initialized)
{
highestSequence = sequence;
replayWindow = 1;
initialized = true;
}
else if (sequence > highestSequence)
{
ulong shift = sequence - highestSequence;
replayWindow = (shift >= 64 ? 0 : replayWindow << (int)shift) | 1;
highestSequence = sequence;
}
else replayWindow |= 1UL << (int)(highestSequence - sequence);
header = candidate;
bytesWritten = length;
return true;
}
public void Dispose()
{
if (disposed) return;
disposed = true;
cipher.Dispose();
}
}
@@ -0,0 +1,40 @@
using VoiceCat.Protocol;
namespace VoiceCat.Crypto;
public sealed class MediaEncryptor : IDisposable
{
private readonly MediaCipher cipher;
private ulong nextSequence;
private bool disposed;
public const int TagSize = 16;
public MediaEncryptor(ReadOnlySpan<byte> key) : this(key, false) { }
internal MediaEncryptor(ReadOnlySpan<byte> key, bool useManaged, ulong initialSequence = 0)
{
cipher = new(key, useManaged);
nextSequence = initialSequence;
}
public int Encrypt(VoiceFrameHeader header, ReadOnlySpan<byte> plaintext, Span<byte> packet)
{
ObjectDisposedException.ThrowIf(disposed, this);
int size = checked(VoiceFrameHeader.Size + plaintext.Length + TagSize);
ArgumentOutOfRangeException.ThrowIfLessThan(packet.Length, size);
if (nextSequence == ulong.MaxValue) throw new InvalidOperationException("Media counter exhausted; establish a new session.");
if (plaintext.Overlaps(packet)) throw new ArgumentException("Input and output must not overlap.", nameof(packet));
header = header with { Sequence = nextSequence++ };
header.Write(packet);
cipher.Encrypt(header.Sequence, plaintext, packet[..VoiceFrameHeader.Size], packet.Slice(VoiceFrameHeader.Size, plaintext.Length + TagSize));
return size;
}
public void Dispose()
{
if (disposed) return;
disposed = true;
cipher.Dispose();
}
}
@@ -0,0 +1,77 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
namespace VoiceCat.Crypto;
public sealed class PasswordHasher
{
private static readonly UTF8Encoding Utf8 = new(false, true);
public const int MaximumPasswordBytes = 1024;
public string Hash(string password)
{
ArgumentException.ThrowIfNullOrEmpty(password);
byte[] salt = RandomNumberGenerator.GetBytes(16);
byte[] hash = Derive(password, salt, 65536, 2, 1);
try { return $"$argon2id$v=19$m=65536,t=2,p=1${Base64(salt)}${Base64(hash)}"; }
finally { CryptographicOperations.ZeroMemory(hash); }
}
public bool Verify(string password, string encodedHash)
{
ArgumentNullException.ThrowIfNull(password);
ArgumentNullException.ThrowIfNull(encodedHash);
if (encodedHash.Length > 256) return false;
try { if (Utf8.GetByteCount(password) > MaximumPasswordBytes) return false; }
catch (EncoderFallbackException) { return false; }
string[] fields = encodedHash.Split('$');
if (fields.Length != 6 || fields[0] != "" || fields[1] != "argon2id" || fields[2] != "v=19") return false;
string[] costs = fields[3].Split(',');
if (costs.Length != 3 || !Cost(costs[0], "m=", out int memory) || !Cost(costs[1], "t=", out int iterations) || !Cost(costs[2], "p=", out int parallelism)) return false;
if (memory is < 8 or > 131072 || iterations is < 1 or > 10 || parallelism is < 1 or > 4 || memory < 8 * parallelism) return false;
byte[] salt, expected;
try { salt = Decode(fields[4]); expected = Decode(fields[5]); }
catch (FormatException) { return false; }
if (salt.Length != 16 || expected.Length != 32) return false;
byte[] actual = Derive(password, salt, memory, iterations, parallelism);
try { return CryptographicOperations.FixedTimeEquals(actual, expected); }
finally { CryptographicOperations.ZeroMemory(actual); }
}
private static bool Cost(string value, string prefix, out int cost)
{
cost = 0;
return value.StartsWith(prefix, StringComparison.Ordinal) && int.TryParse(value.AsSpan(prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out cost);
}
private static byte[] Derive(string password, byte[] salt, int memory, int iterations, int parallelism)
{
if (Utf8.GetByteCount(password) > MaximumPasswordBytes) throw new ArgumentException("Password exceeds 1024 UTF-8 bytes.", nameof(password));
byte[] bytes = Utf8.GetBytes(password);
byte[] output = new byte[32];
var parameters = new Argon2Parameters.Builder(Argon2Parameters.Argon2id)
.WithVersion(Argon2Parameters.Version13).WithMemoryAsKB(memory)
.WithIterations(iterations).WithParallelism(parallelism).WithSalt(salt).Build();
try
{
var generator = new Argon2BytesGenerator();
generator.Init(parameters);
generator.GenerateBytes(bytes, output);
return output;
}
catch { CryptographicOperations.ZeroMemory(output); throw; }
finally { CryptographicOperations.ZeroMemory(bytes); }
}
private static string Base64(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=');
private static byte[] Decode(string value)
{
if (value.Contains('=') || value.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not ('+' or '/'))) throw new FormatException();
byte[] bytes = Convert.FromBase64String(value.PadRight((value.Length + 3) / 4 * 4, '='));
if (Base64(bytes) != value) throw new FormatException();
return bytes;
}
}
@@ -0,0 +1,22 @@
namespace VoiceCat.Crypto;
internal static class PrivateFiles
{
public static void Write(string path, ReadOnlySpan<byte> data)
{
string destination = Path.GetFullPath(path);
string temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
using (var stream = new FileStream(temporary, options))
{
stream.Write(data);
stream.Flush(flushToDisk: true);
}
File.Move(temporary, destination, overwrite: true);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
}
@@ -0,0 +1,75 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace VoiceCat.Crypto;
public sealed class ServerCredentials : IDisposable
{
private readonly X509Certificate2 certificate;
private bool disposed;
private ServerCredentials(ServerIdentity identity, X509Certificate2 certificate)
{
Identity = identity;
this.certificate = certificate;
}
public ServerIdentity Identity { get; }
public string CertificateFingerprint => Convert.ToHexString(SHA256.HashData(certificate.RawData));
public static ServerCredentials LoadOrCreate(string directory, string serverName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
Directory.CreateDirectory(directory);
string identityPath = Path.Combine(directory, "identity.key");
string certificatePath = Path.Combine(directory, "server.crt");
string keyPath = Path.Combine(directory, "server.key");
bool hasIdentity = File.Exists(identityPath);
bool hasCertificate = File.Exists(certificatePath);
bool hasKey = File.Exists(keyPath);
if (hasIdentity && hasCertificate && hasKey)
{
var identity = ServerIdentity.Load(identityPath);
try { return new(identity, X509Certificate2.CreateFromPemFile(certificatePath, keyPath)); }
catch { identity.Dispose(); throw; }
}
if (hasIdentity || hasCertificate || hasKey)
throw new InvalidDataException("Server credentials are incomplete; restore the missing files before starting.");
var generated = ServerIdentity.Generate();
try
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var name = new X500DistinguishedNameBuilder();
name.AddCommonName(serverName);
var request = new CertificateRequest(name.Build(), key, HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
var san = new SubjectAlternativeNameBuilder();
san.AddUri(new Uri("urn:voicecat:identity:ed25519:" + Convert.ToHexString(generated.PublicKey).ToLowerInvariant()));
request.CertificateExtensions.Add(san.Build());
using var created = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddYears(10));
string certificatePem = created.ExportCertificatePem();
string privateKeyPem = key.ExportPkcs8PrivateKeyPem();
generated.Save(identityPath);
PrivateFiles.Write(certificatePath, Encoding.UTF8.GetBytes(certificatePem));
PrivateFiles.Write(keyPath, Encoding.UTF8.GetBytes(privateKeyPem));
return new(generated, X509Certificate2.CreateFromPem(certificatePem, privateKeyPem));
}
catch { generated.Dispose(); throw; }
}
public TlsSession CreateTlsSession()
{
ObjectDisposedException.ThrowIf(disposed, this);
using var key = certificate.GetECDsaPrivateKey() ?? throw new InvalidDataException("Server TLS certificate requires an ECDSA key.");
return TlsSession.CreateServer(certificate.ExportCertificatePem(), key.ExportPkcs8PrivateKeyPem());
}
public void Dispose()
{
if (disposed) return;
disposed = true;
Identity.Dispose();
certificate.Dispose();
}
}
@@ -0,0 +1,58 @@
using System.Security.Cryptography;
using Org.BouncyCastle.Crypto.Parameters;
namespace VoiceCat.Crypto;
public sealed class ServerIdentity : IDisposable
{
private readonly byte[] seed;
private readonly byte[] publicKey;
private bool disposed;
private ServerIdentity(byte[] seed)
{
this.seed = seed;
publicKey = new Ed25519PrivateKeyParameters(seed, 0).GeneratePublicKey().GetEncoded();
}
public byte[] PublicKey => (byte[])publicKey.Clone();
public string Fingerprint => Convert.ToHexString(SHA256.HashData(publicKey));
public static ServerIdentity Generate() => new(RandomNumberGenerator.GetBytes(32));
public static ServerIdentity Load(string path)
{
byte[] data = File.ReadAllBytes(path);
try
{
if (data.Length != 96) throw new InvalidDataException("Server identity must contain 96 bytes.");
var identity = new ServerIdentity(data.AsSpan(32, 32).ToArray());
if (!CryptographicOperations.FixedTimeEquals(identity.publicKey, data.AsSpan(0, 32)) ||
!CryptographicOperations.FixedTimeEquals(identity.publicKey, data.AsSpan(64, 32)))
{
identity.Dispose();
throw new InvalidDataException("Server identity public key does not match its seed.");
}
return identity;
}
finally { CryptographicOperations.ZeroMemory(data); }
}
public void Save(string path)
{
ObjectDisposedException.ThrowIf(disposed, this);
byte[] data = new byte[96];
publicKey.CopyTo(data, 0);
seed.CopyTo(data, 32);
publicKey.CopyTo(data, 64);
try { PrivateFiles.Write(path, data); }
finally { CryptographicOperations.ZeroMemory(data); }
}
public void Dispose()
{
if (disposed) return;
disposed = true;
CryptographicOperations.ZeroMemory(seed);
}
}
+208
View File
@@ -0,0 +1,208 @@
using System.Security.Cryptography;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Tls;
using Org.BouncyCastle.Tls.Crypto;
using Org.BouncyCastle.Tls.Crypto.Impl.BC;
namespace VoiceCat.Crypto;
public sealed class TlsSession : IDisposable
{
private readonly TlsProtocol protocol;
private readonly bool isClient;
private readonly byte[] scratch = new byte[16384];
private byte[]? clientToServerKey;
private byte[]? serverToClientKey;
private bool disposed;
private TlsSession(TlsProtocol protocol, bool isClient)
{
this.protocol = protocol;
this.isClient = isClient;
}
public bool IsReady => !disposed && clientToServerKey is not null && serverToClientKey is not null && !protocol.IsClosed;
public string? PeerCertificateFingerprint { get; private set; }
public int PendingCiphertextBytes => protocol.GetAvailableOutputBytes();
public void Close()
{
ObjectDisposedException.ThrowIf(disposed, this);
protocol.Close();
}
public void CompleteInput()
{
ObjectDisposedException.ThrowIf(disposed, this);
protocol.CloseInput();
}
public static TlsSession CreateClient(Func<string, bool> acceptCertificate)
{
ArgumentNullException.ThrowIfNull(acceptCertificate);
var protocol = new TlsClientProtocol();
var session = new TlsSession(protocol, true);
protocol.Connect(new ClientPeer(session, acceptCertificate));
return session;
}
public static TlsSession CreateServer(string certificatePem, string privateKeyPem)
{
ArgumentException.ThrowIfNullOrWhiteSpace(certificatePem);
ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPem);
var protocol = new TlsServerProtocol();
var session = new TlsSession(protocol, false);
protocol.Accept(new ServerPeer(session, certificatePem, privateKeyPem));
return session;
}
public void ReceiveCiphertext(ReadOnlySpan<byte> input)
{
ObjectDisposedException.ThrowIf(disposed, this);
while (!input.IsEmpty)
{
int count = Math.Min(input.Length, scratch.Length);
input[..count].CopyTo(scratch);
protocol.OfferInput(scratch, 0, count);
input = input[count..];
}
}
public int DrainCiphertext(Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
int count = protocol.ReadOutput(scratch, 0, Math.Min(output.Length, scratch.Length));
scratch.AsSpan(0, count).CopyTo(output);
return count;
}
public int ReadPlaintext(Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
int count = protocol.ReadInput(scratch, 0, Math.Min(output.Length, scratch.Length));
scratch.AsSpan(0, count).CopyTo(output);
CryptographicOperations.ZeroMemory(scratch.AsSpan(0, count));
return count;
}
public void WritePlaintext(ReadOnlySpan<byte> input)
{
RequireReady();
protocol.WriteApplicationData(input);
}
public MediaEncryptor CreateMediaEncryptor()
{
byte[] key = ExportMediaKey(isClient ? (byte)0 : (byte)1);
try { return new(key); }
finally { CryptographicOperations.ZeroMemory(key); }
}
public MediaDecryptor CreateMediaDecryptor()
{
byte[] key = ExportMediaKey(isClient ? (byte)1 : (byte)0);
try { return new(key); }
finally { CryptographicOperations.ZeroMemory(key); }
}
internal byte[] ExportMediaKey(byte direction)
{
RequireReady();
ArgumentOutOfRangeException.ThrowIfGreaterThan(direction, (byte)1);
return (byte[])(direction == 0 ? clientToServerKey! : serverToClientKey!).Clone();
}
private void CompleteHandshake(TlsContext context)
{
// BouncyCastle destroys exporter secrets after this callback returns.
clientToServerKey = context.ExportKeyingMaterial("voicecat media v1", [0], 32);
serverToClientKey = context.ExportKeyingMaterial("voicecat media v1", [1], 32);
}
private void RequireReady()
{
ObjectDisposedException.ThrowIf(disposed, this);
if (!IsReady) throw new InvalidOperationException("TLS handshake has not completed or the session is closed.");
}
public void Dispose()
{
if (disposed) return;
disposed = true;
try { protocol.Close(); }
finally
{
if (clientToServerKey is not null) CryptographicOperations.ZeroMemory(clientToServerKey);
if (serverToClientKey is not null) CryptographicOperations.ZeroMemory(serverToClientKey);
CryptographicOperations.ZeroMemory(scratch);
}
}
private sealed class ClientPeer(TlsSession session, Func<string, bool> acceptCertificate)
: DefaultTlsClient(new BcTlsCrypto())
{
protected override ProtocolVersion[] GetSupportedVersions() => [ProtocolVersion.TLSv13];
protected override int[] GetSupportedCipherSuites() => CipherSuites;
public override TlsAuthentication GetAuthentication() => new Authentication(session, acceptCertificate);
public override void NotifyHandshakeComplete()
{
base.NotifyHandshakeComplete();
session.CompleteHandshake(m_context);
}
}
private sealed class Authentication(TlsSession session, Func<string, bool> acceptCertificate) : TlsAuthentication
{
public void NotifyServerCertificate(TlsServerCertificate serverCertificate)
{
var chain = serverCertificate.Certificate.GetCertificateList();
if (chain.Length == 0) throw new TlsFatalAlert(AlertDescription.bad_certificate);
string fingerprint = Convert.ToHexString(SHA256.HashData(chain[0].GetEncoded()));
session.PeerCertificateFingerprint = fingerprint;
if (!acceptCertificate(fingerprint)) throw new TlsFatalAlert(AlertDescription.bad_certificate);
}
public TlsCredentials? GetClientCredentials(Org.BouncyCastle.Tls.CertificateRequest certificateRequest) => null;
}
private sealed class ServerPeer : DefaultTlsServer
{
private readonly TlsSession session;
private readonly byte[] certificateDer;
private readonly AsymmetricKeyParameter privateKey;
public ServerPeer(TlsSession session, string certificatePem, string privateKeyPem) : base(new BcTlsCrypto())
{
this.session = session;
using var certificate = System.Security.Cryptography.X509Certificates.X509Certificate2.CreateFromPem(certificatePem);
certificateDer = certificate.RawData;
using var reader = new StringReader(privateKeyPem);
privateKey = (AsymmetricKeyParameter)new PemReader(reader).ReadObject();
if (privateKey is not Org.BouncyCastle.Crypto.Parameters.ECPrivateKeyParameters)
throw new ArgumentException("Server TLS credentials require an ECDSA key.", nameof(privateKeyPem));
}
protected override ProtocolVersion[] GetSupportedVersions() => [ProtocolVersion.TLSv13];
protected override int[] GetSupportedCipherSuites() => CipherSuites;
public override TlsCredentials GetCredentials()
{
var certificate = new Certificate([], [new CertificateEntry(Crypto.CreateCertificate(certificateDer), null)]);
return new BcDefaultTlsCredentialedSigner(new TlsCryptoParameters(m_context), (BcTlsCrypto)Crypto,
privateKey, certificate, new SignatureAndHashAlgorithm(Org.BouncyCastle.Tls.HashAlgorithm.sha256, SignatureAlgorithm.ecdsa));
}
public override void NotifyHandshakeComplete()
{
base.NotifyHandshakeComplete();
session.CompleteHandshake(m_context);
}
}
private static int[] CipherSuites =>
[
CipherSuite.TLS_AES_128_GCM_SHA256,
CipherSuite.TLS_AES_256_GCM_SHA384,
CipherSuite.TLS_CHACHA20_POLY1305_SHA256
];
}
+72
View File
@@ -0,0 +1,72 @@
using System.Text;
namespace VoiceCat.Crypto;
public enum TofuStatus { FirstConnect, Matched, Mismatch }
public sealed class TofuStore
{
private readonly string path;
private readonly Dictionary<string, string> pins = new(StringComparer.Ordinal);
public TofuStore(string path)
{
this.path = Path.GetFullPath(path);
if (!File.Exists(this.path)) return;
foreach (string line in File.ReadLines(this.path))
{
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) continue;
string[] parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) throw new InvalidDataException("Malformed TOFU pin entry.");
pins[parts[0]] = NormalizeFingerprint(parts[1]);
}
}
public TofuStatus Check(string host, ushort port, string fingerprint)
{
string key = Endpoint(host, port);
string normalized = NormalizeFingerprint(fingerprint);
return !pins.TryGetValue(key, out var pin) ? TofuStatus.FirstConnect :
pin == normalized ? TofuStatus.Matched : TofuStatus.Mismatch;
}
public void Pin(string host, ushort port, string fingerprint)
{
string key = Endpoint(host, port);
string value = NormalizeFingerprint(fingerprint);
var updated = new Dictionary<string, string>(pins, StringComparer.Ordinal) { [key] = value };
Save(updated);
pins[key] = value;
}
public void Remove(string host, ushort port)
{
string key = Endpoint(host, port);
var updated = new Dictionary<string, string>(pins, StringComparer.Ordinal);
updated.Remove(key);
Save(updated);
pins.Remove(key);
}
private void Save(Dictionary<string, string> updated)
{
string contents = string.Concat(updated.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key} {pair.Value}\n"));
PrivateFiles.Write(path, Encoding.UTF8.GetBytes(contents));
}
private static string Endpoint(string host, ushort port)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
if (host.Any(char.IsWhiteSpace)) throw new ArgumentException("Host cannot contain whitespace.", nameof(host));
ArgumentOutOfRangeException.ThrowIfZero(port);
return $"{host}:{port}";
}
private static string NormalizeFingerprint(string fingerprint)
{
ArgumentNullException.ThrowIfNull(fingerprint);
if (fingerprint.Length != 64 || !fingerprint.All(Uri.IsHexDigit))
throw new InvalidDataException("TLS certificate fingerprints must contain 64 hexadecimal characters.");
return fingerprint.ToLowerInvariant();
}
}
@@ -0,0 +1,10 @@
using VoiceCat.Crypto;
namespace VoiceCat.Transport;
internal sealed class MediaSessionCrypto(MediaEncryptor encryptor, MediaDecryptor decryptor) : IDisposable
{
public MediaEncryptor Encryptor { get; } = encryptor;
public MediaDecryptor Decryptor { get; } = decryptor;
public void Dispose() { Encryptor.Dispose(); Decryptor.Dispose(); }
}
@@ -0,0 +1,186 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Threading.Channels;
using Google.Protobuf;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Transport;
internal sealed class TlsControlConnection : IAsyncDisposable
{
internal const int MaximumPayloadLength = 65536;
private readonly Socket socket;
private readonly TlsSession tls;
private readonly CancellationTokenSource lifetime;
private readonly Channel<byte[]> outgoing = System.Threading.Channels.Channel.CreateBounded<byte[]>(64);
private readonly Channel<Envelope> incoming = System.Threading.Channels.Channel.CreateBounded<Envelope>(32);
private readonly byte[] prefix = new byte[4];
private int prefixBytes;
private byte[]? payload;
private int payloadBytes;
private readonly TaskCompletionSource mediaReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private MediaSessionCrypto? mediaCrypto;
public Task Completion { get; }
internal System.Net.EndPoint RemoteEndPoint => socket.RemoteEndPoint!;
public CancellationToken CancellationToken => lifetime.Token;
internal TlsControlConnection(Socket socket, TlsSession tls, CancellationToken cancellationToken, TimeSpan? handshakeTimeout = null)
{
this.socket = socket;
this.tls = tls;
lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
lifetime.CancelAfter(handshakeTimeout ?? TimeSpan.FromSeconds(15));
Completion = RunAsync();
}
public IAsyncEnumerable<Envelope> ReadAsync(CancellationToken cancellationToken) => incoming.Reader.ReadAllAsync(cancellationToken);
public bool TrySend(Envelope envelope)
{
if (envelope.CalculateSize() > MaximumPayloadLength) throw new InvalidDataException("Server control payload exceeds 64 KiB.");
var framed = new ArrayBufferWriter<byte>();
ControlFraming.WriteEnvelope(framed, envelope);
if (outgoing.Writer.TryWrite(framed.WrittenSpan.ToArray())) return true;
lifetime.Cancel();
return false;
}
public void CompleteWrites() => outgoing.Writer.TryComplete();
internal async Task<MediaSessionCrypto> TakeMediaCryptoAsync(CancellationToken cancellationToken)
{
await mediaReady.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
return Interlocked.Exchange(ref mediaCrypto, null) ?? throw new InvalidOperationException("Media crypto already has an owner.");
}
private async Task RunAsync()
{
byte[] ciphertext = new byte[16384];
byte[] plaintext = new byte[16384];
byte[] sendBuffer = new byte[16384];
CancellationToken cancellationToken = lifetime.Token;
Task<int>? receive = null;
Task<bool>? ready = null;
Exception? error = null;
try
{
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask();
while (true)
{
if (tls.IsReady)
{
while (outgoing.Reader.TryRead(out byte[]? frame)) tls.WritePlaintext(frame);
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
ready ??= outgoing.Reader.WaitToReadAsync(cancellationToken).AsTask();
}
Task winner = ready is null ? receive : await Task.WhenAny(receive, ready).ConfigureAwait(false);
if (winner == receive)
{
int count = await receive.ConfigureAwait(false);
if (count == 0)
{
tls.CompleteInput();
if (prefixBytes != 0 || payload is not null) throw new InvalidDataException("Truncated control frame.");
break;
}
tls.ReceiveCiphertext(ciphertext.AsSpan(0, count));
if (tls.IsReady && !mediaReady.Task.IsCompleted)
{
var encryptor = tls.CreateMediaEncryptor();
try { mediaCrypto = new(encryptor, tls.CreateMediaDecryptor()); }
catch { encryptor.Dispose(); throw; }
mediaReady.SetResult();
lifetime.CancelAfter(Timeout.InfiniteTimeSpan);
}
while ((count = tls.ReadPlaintext(plaintext)) > 0) Parse(plaintext.AsSpan(0, count));
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask();
}
else
{
bool hasOutgoing = await ready!.ConfigureAwait(false);
ready = null;
if (!hasOutgoing)
{
tls.Close();
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
break;
}
}
}
}
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
{
if (!cancellationToken.IsCancellationRequested) error = exception;
}
finally
{
mediaReady.TrySetCanceled();
lifetime.Cancel();
socket.Dispose();
if (receive is not null)
{
try { await receive.ConfigureAwait(false); }
catch (Exception exception) when (exception is SocketException or OperationCanceledException or ObjectDisposedException) { }
}
tls.Dispose();
incoming.Writer.TryComplete(error);
outgoing.Writer.TryComplete(error);
}
}
private async Task FlushAsync(byte[] buffer, CancellationToken cancellationToken)
{
int count;
while ((count = tls.DrainCiphertext(buffer)) > 0)
{
int sent = 0;
while (sent < count)
{
int written = await socket.SendAsync(buffer.AsMemory(sent, count - sent), SocketFlags.None, cancellationToken).ConfigureAwait(false);
if (written == 0) throw new IOException("Socket closed during TLS send.");
sent += written;
}
}
}
private void Parse(ReadOnlySpan<byte> input)
{
while (!input.IsEmpty)
{
if (payload is null)
{
int count = Math.Min(4 - prefixBytes, input.Length);
input[..count].CopyTo(prefix.AsSpan(prefixBytes));
prefixBytes += count;
input = input[count..];
if (prefixBytes != 4) continue;
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
if (length > MaximumPayloadLength) throw new InvalidDataException("Server control payload exceeds 64 KiB.");
payload = new byte[length];
prefixBytes = 0;
}
int consumed = Math.Min(payload.Length - payloadBytes, input.Length);
input[..consumed].CopyTo(payload.AsSpan(payloadBytes));
payloadBytes += consumed;
input = input[consumed..];
if (payloadBytes != payload.Length) continue;
Envelope envelope = Envelope.Parser.ParseFrom(payload);
payload = null;
payloadBytes = 0;
if (!incoming.Writer.TryWrite(envelope)) throw new IOException("Control consumer exceeded its bounded queue.");
}
}
public async ValueTask DisposeAsync()
{
lifetime.Cancel();
try { await Completion.ConfigureAwait(false); }
finally { Interlocked.Exchange(ref mediaCrypto, null)?.Dispose(); lifetime.Dispose(); }
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="VoiceCat.Tests" />
<InternalsVisibleTo Include="VoiceCat.Server" />
<InternalsVisibleTo Include="VoiceCat.Core" />
</ItemGroup>
</Project>
@@ -0,0 +1,24 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Direct",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}
@@ -0,0 +1,31 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Direct",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/win-x64": {}
}
}
@@ -0,0 +1,48 @@
namespace VoiceCat.Dsp;
public sealed class EnergyVadProcessor
{
private readonly TimeProvider timeProvider;
private long lastVoiceTimestamp;
private bool hasVoice;
private float threshold;
public float Threshold
{
get => Volatile.Read(ref threshold);
set
{
if (!float.IsFinite(value) || value is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(value));
Volatile.Write(ref threshold, value);
}
}
public TimeSpan HangTime { get; }
public EnergyVadProcessor(float threshold = 0.02f, TimeSpan? hangTime = null, TimeProvider? timeProvider = null)
{
Threshold = threshold;
HangTime = hangTime ?? TimeSpan.FromMilliseconds(300);
if (HangTime < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(hangTime));
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public bool Process(ReadOnlySpan<short> pcm)
{
long now = timeProvider.GetTimestamp();
if (!pcm.IsEmpty)
{
double sum = 0;
foreach (short sample in pcm)
{
double normalized = sample / 32768.0;
sum += normalized * normalized;
}
if (Math.Sqrt(sum / pcm.Length) >= Threshold)
{
lastVoiceTimestamp = now;
hasVoice = true;
}
}
return hasVoice && timeProvider.GetElapsedTime(lastVoiceTimestamp, now) < HangTime;
}
}
@@ -0,0 +1,53 @@
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace VoiceCat.Dsp;
public sealed unsafe partial class RnnoiseProcessor : IDisposable
{
public const int SampleRate = 48000;
public const int FrameSamples = 480;
private readonly RnnoiseHandle handle;
private readonly float[] input = new float[FrameSamples];
private readonly float[] output = new float[FrameSamples];
public RnnoiseProcessor()
{
handle = new(Create());
if (handle.IsInvalid) { handle.Dispose(); throw new OutOfMemoryException(); }
}
public void Process(Span<short> pcm, int sampleRate = SampleRate)
{
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
if (sampleRate != SampleRate) return;
if (pcm.Length % FrameSamples != 0) throw new ArgumentException("RNNoise requires complete 480-sample mono chunks.", nameof(pcm));
fixed (float* source = input)
fixed (float* destination = output)
{
for (int offset = 0; offset < pcm.Length; offset += FrameSamples)
{
for (int i = 0; i < FrameSamples; i++) input[i] = pcm[offset + i];
ProcessFrame(handle, destination, source);
for (int i = 0; i < FrameSamples; i++)
pcm[offset + i] = (short)Math.Clamp(MathF.Round(output[i], MidpointRounding.AwayFromZero), short.MinValue, short.MaxValue);
}
}
}
public void Dispose() => handle.Dispose();
[LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_create")]
private static partial nint Create();
[LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_destroy")]
private static partial void Destroy(nint state);
[LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_process")]
private static partial float ProcessFrame(RnnoiseHandle state, float* output, float* input);
private sealed class RnnoiseHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public RnnoiseHandle() : base(true) { }
internal RnnoiseHandle(nint value) : this() => SetHandle(value);
protected override bool ReleaseHandle() { Destroy(handle); return true; }
}
}
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
@@ -0,0 +1,6 @@
{
"version": 1,
"dependencies": {
"net10.0": {}
}
}
@@ -0,0 +1,14 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
}
},
"net10.0/win-x64": {}
}
}
@@ -0,0 +1,95 @@
using System.Buffers;
using System.Buffers.Binary;
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
using Google.Protobuf;
using Voicecat.V1;
namespace VoiceCat.Protocol;
public static class ControlFraming
{
public const int MaxPayloadLength = 16 * 1024 * 1024;
public static bool TryReadFrame(ref ReadOnlySequence<byte> input, out ReadOnlySequence<byte> payload)
{
payload = default;
if (input.Length < 4) return false;
Span<byte> prefix = stackalloc byte[4];
input.Slice(0, 4).CopyTo(prefix);
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
if (length > MaxPayloadLength) throw new InvalidDataException("Control frame exceeds 16 MiB.");
if (input.Length < 4L + length) return false;
payload = input.Slice(4, length);
input = input.Slice(4L + length);
return true;
}
public static void WriteFrame(IBufferWriter<byte> output, ReadOnlySpan<byte> payload)
{
ArgumentNullException.ThrowIfNull(output);
ArgumentOutOfRangeException.ThrowIfGreaterThan(payload.Length, MaxPayloadLength);
BinaryPrimitives.WriteUInt32BigEndian(output.GetSpan(4), (uint)payload.Length);
output.Advance(4);
output.Write(payload);
}
public static void WriteEnvelope(IBufferWriter<byte> output, Envelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(output);
int length = envelope.CalculateSize();
ArgumentOutOfRangeException.ThrowIfGreaterThan(length, MaxPayloadLength);
BinaryPrimitives.WriteUInt32BigEndian(output.GetSpan(4), (uint)length);
output.Advance(4);
envelope.WriteTo(output);
}
public static async IAsyncEnumerable<Envelope> ReadEnvelopesAsync(
PipeReader reader, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(reader);
byte[] prefix = new byte[4];
while (true)
{
if (!await ReadExactlyAsync(reader, prefix, cancellationToken).ConfigureAwait(false)) yield break;
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
if (length > MaxPayloadLength) throw new InvalidDataException("Control frame exceeds 16 MiB.");
byte[] payload = length == 0 ? [] : new byte[length];
if (length != 0 && !await ReadExactlyAsync(reader, payload, cancellationToken).ConfigureAwait(false))
throw new InvalidDataException("Truncated control frame.");
yield return Envelope.Parser.ParseFrom(payload);
}
}
private static async ValueTask<bool> ReadExactlyAsync(PipeReader reader, Memory<byte> destination, CancellationToken cancellationToken)
{
int written = 0;
while (written < destination.Length)
{
ReadResult result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
var buffer = result.Buffer;
var consumed = buffer.Start;
try
{
if (result.IsCanceled) throw new OperationCanceledException(cancellationToken);
int count = (int)Math.Min(buffer.Length, destination.Length - written);
buffer.Slice(0, count).CopyTo(destination.Span[written..]);
consumed = buffer.GetPosition(count);
written += count;
if (written == destination.Length) return true;
if (result.IsCompleted)
{
if (written != 0) throw new InvalidDataException("Truncated control frame.");
return false;
}
}
finally
{
// Consume fragments so pipe backpressure cannot stall a large frame.
reader.AdvanceTo(consumed, consumed);
}
}
return true;
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<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" />
</ItemGroup>
</Project>
@@ -0,0 +1,49 @@
using System.Buffers.Binary;
namespace VoiceCat.Protocol;
public enum MediaFrameType : byte
{
Voice = 1,
Keepalive = 2,
UdpBinding = 3
}
[Flags]
public enum VoiceFrameFlags : byte
{
None = 0,
Marker = 1,
FecPresent = 2,
Dtx = 4,
Last = 8
}
public readonly record struct VoiceFrameHeader(
MediaFrameType Type, VoiceFrameFlags Flags, ushort Codec, uint Ssrc, ulong Sequence, uint Timestamp)
{
public const int Size = 20;
public void Write(Span<byte> destination)
{
ArgumentOutOfRangeException.ThrowIfLessThan(destination.Length, Size);
destination[0] = (byte)Type;
destination[1] = (byte)Flags;
BinaryPrimitives.WriteUInt16BigEndian(destination[2..], Codec);
BinaryPrimitives.WriteUInt32BigEndian(destination[4..], Ssrc);
BinaryPrimitives.WriteUInt64BigEndian(destination[8..], Sequence);
BinaryPrimitives.WriteUInt32BigEndian(destination[16..], Timestamp);
}
public static bool TryRead(ReadOnlySpan<byte> source, out VoiceFrameHeader header)
{
header = default;
if (source.Length < Size) return false;
header = new((MediaFrameType)source[0], (VoiceFrameFlags)source[1],
BinaryPrimitives.ReadUInt16BigEndian(source[2..]),
BinaryPrimitives.ReadUInt32BigEndian(source[4..]),
BinaryPrimitives.ReadUInt64BigEndian(source[8..]),
BinaryPrimitives.ReadUInt32BigEndian(source[16..]));
return true;
}
}

Some files were not shown because too many files have changed in this diff Show More