Compare commits
9
Commits
main
...
82ad4c2811
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82ad4c2811 | ||
|
|
5a226ba543 | ||
|
|
653131b876 | ||
|
|
274b85025c | ||
|
|
05eacb3092 | ||
|
|
4067bab7c2 | ||
|
|
2df79cdd4c | ||
|
|
b76181d9fb | ||
|
|
c6c003b8a7 |
@@ -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
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
# Build output
|
# Build output
|
||||||
|
/dotnet/**/bin/
|
||||||
|
/dotnet/**/obj/
|
||||||
|
/dotnet/**/TestResults/
|
||||||
|
/dotnet/artifacts/
|
||||||
/build/
|
/build/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 <100 ms).
|
/// <summary>Starts capture. Blocks until WASAPI activation completes (typically <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();
|
||||||
}
|
}
|
||||||
@@ -164,11 +164,20 @@ public sealed class InputDeviceCapture : IDisposable
|
|||||||
// ── Capture thread (MTA) ──────────────────────────────────────────────────
|
// ── Capture thread (MTA) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
private void CaptureThreadProc()
|
private void CaptureThreadProc()
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
_initOk = ActivateAndStart();
|
_initOk = ActivateAndStart();
|
||||||
_initDone.Set();
|
_initDone.Set();
|
||||||
if (!_initOk) return;
|
if (_initOk && _running) CaptureLoop();
|
||||||
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
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_frameLock)
|
|
||||||
{
|
|
||||||
_latestFrames = new List<short[]>(new short[specs.Count][]);
|
|
||||||
_activeChannels = Channels;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < specs.Count; i++)
|
for (int i = 0; i < specs.Count; i++)
|
||||||
{
|
{
|
||||||
int captureIndex = i;
|
Input input = inputs[i]; var (pid, mode) = specs[i];
|
||||||
var (pid, mode) = specs[i];
|
var capture = new ProcessLoopbackCapture(pid, mode);
|
||||||
var cap = new ProcessLoopbackCapture(pid, mode);
|
capture.PcmFrameReady += (pcm, _, channels) => input.Feed(pcm, channels);
|
||||||
cap.PcmFrameReady += (pcm, spc, ch) => OnCaptureFrame(captureIndex, pcm, ch);
|
captures.Add(capture);
|
||||||
_captures.Add(cap);
|
if (!capture.Start()) throw new InvalidOperationException("Process audio capture could not start.");
|
||||||
}
|
}
|
||||||
|
if (inputs.Length == 0) return;
|
||||||
foreach (var c in _captures) c.Start();
|
running = true;
|
||||||
|
thread = new Thread(MixLoop) { IsBackground = true, Name = "ProcessAudioMixer" }; thread.Start();
|
||||||
_running = true;
|
}
|
||||||
_mixThread = new Thread(MixLoop) { IsBackground = true, Name = "ProcessAudioMixer" };
|
catch { Stop(); throw; }
|
||||||
_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)
|
}
|
||||||
{
|
for (int i = 0; i < mix.Length; i++) mix[i] = (short)Math.Clamp(sums[i], short.MinValue, short.MaxValue);
|
||||||
if (frame == null) continue;
|
client?.StreamFeedPcm(streamId, mix, 960, 2);
|
||||||
int frameLen = Math.Min(frame.Length, len);
|
deadline += System.Diagnostics.Stopwatch.Frequency / 50;
|
||||||
for (int i = 0; i < frameLen; i++)
|
double wait = (deadline - System.Diagnostics.Stopwatch.GetTimestamp()) * 1000.0 / System.Diagnostics.Stopwatch.Frequency;
|
||||||
{
|
if (wait > 0) Thread.Sleep((int)Math.Ceiling(wait));
|
||||||
int sum = mix[i] + frame[i];
|
else if (wait < -100) deadline = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||||
mix[i] = (short)Math.Clamp(sum, short.MinValue, short.MaxValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
private static List<(int pid, ProcessLoopbackCapture.Mode mode)> ResolveCaptures(AppAudioScope scope) => scope switch
|
||||||
|
|
||||||
_client?.StreamFeedPcm(_streamId, mix, FrameSamples, (uint)_activeChannels);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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(),
|
OnlyApps o => o.Pids.Select(p => (p, ProcessLoopbackCapture.Mode.Include)).ToList(),
|
||||||
AllExceptApps a when a.Pids.Count > 0 =>
|
AllExceptApps a when a.Pids.Count > 0 => [(a.Pids[0], ProcessLoopbackCapture.Mode.Exclude)],
|
||||||
[(a.Pids[0], ProcessLoopbackCapture.Mode.Exclude)],
|
EntireDesktop { ExcludeSelf: true } => [(Environment.ProcessId, 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();
|
||||||
}
|
}
|
||||||
@@ -80,11 +80,24 @@ public sealed class ProcessLoopbackCapture : IDisposable
|
|||||||
// ── Capture thread (MTA) ──────────────────────────────────────────────────
|
// ── Capture thread (MTA) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
private void CaptureThreadProc()
|
private void CaptureThreadProc()
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
_initOk = ActivateAndStart();
|
_initOk = ActivateAndStart();
|
||||||
_initDone.Set();
|
_initDone.Set();
|
||||||
if (!_initOk) return;
|
if (_initOk && _running) CaptureLoop();
|
||||||
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();
|
||||||
|
|||||||
@@ -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 && 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -34,6 +34,7 @@ that implementation can start from a shared, agreed plan.
|
|||||||
5. [tech-stack.md](tech-stack.md) — Concrete libraries with versions and rationale, the permissive-license rule, build tooling, per-platform notes.
|
5. [tech-stack.md](tech-stack.md) — Concrete libraries with versions and rationale, the permissive-license rule, build tooling, per-platform notes.
|
||||||
6. [deployment.md](deployment.md) — The "set it up in a few minutes" story: Docker, single binary, source build, zero-config defaults.
|
6. [deployment.md](deployment.md) — The "set it up in a few minutes" story: Docker, single binary, source build, zero-config defaults.
|
||||||
7. [roadmap.md](roadmap.md) — Milestones, what ships when, and the list of open questions still to resolve.
|
7. [roadmap.md](roadmap.md) — Milestones, what ships when, and the list of open questions still to resolve.
|
||||||
|
8. [porting-to-dotnet.md](porting-to-dotnet.md) — **Proposal.** Step-by-step plan to replace the C++ core, C++ server, and Swift clients with a single .NET 10 / C# codebase. Dependency map, the TLS-exporter blocker, real-time-audio design, phased migration.
|
||||||
|
|
||||||
## Design principles
|
## Design principles
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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**.
|
||||||
|
|||||||
@@ -0,0 +1,919 @@
|
|||||||
|
# Porting VoiceCat to pure .NET / C#
|
||||||
|
|
||||||
|
**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.
|
||||||
|
**Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the
|
||||||
|
Swift macOS/iOS clients with a single C# codebase. The Windows WinForms client is already C#
|
||||||
|
and is mostly *kept*.
|
||||||
|
|
||||||
|
This document is the map for that work: what maps 1:1, what has no .NET equivalent, what has
|
||||||
|
to stay native, and the order to do it in so the tree is testable at every step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Executive summary
|
||||||
|
|
||||||
|
**The port is feasible.** Roughly 80 % of the C++ core is protocol/state-machine/buffer code
|
||||||
|
that translates to C# almost mechanically and gets *shorter*. The risk is concentrated in
|
||||||
|
four places, and only one of them is a genuine design change:
|
||||||
|
|
||||||
|
| Risk | Verdict |
|
||||||
|
|------|---------|
|
||||||
|
| **TLS keying-material exporter (RFC 5705)** — the media-key derivation the whole UDP path depends on | ⚠️ **`SslStream` cannot do this.** [The API is an unapproved proposal](https://github.com/dotnet/runtime/issues/112529) targeting "Future", and SChannel structurally can't export secrets. **Must** use BouncyCastle's managed TLS stack, or change the protocol. See §3. |
|
||||||
|
| **Real-time audio + GC** | Manageable, but needs deliberate design. Native audio callbacks must not enter managed code. See §5. |
|
||||||
|
| **Opus 1.6 / DRED, RNNoise** | No managed equivalent exists. Stay native via P/Invoke. See §4. |
|
||||||
|
| **iOS ReplayKit Broadcast Upload Extension** | ⚠️ **Keep this in Swift.** 50 MB jetsam cap + a managed runtime in an appex that .NET for iOS does not officially support. See §8.4. |
|
||||||
|
|
||||||
|
Everything else — protobuf, SQLite, sockets, Argon2id, ChaCha20-Poly1305, X.509 generation,
|
||||||
|
jitter buffers, the mixer, the session model, the server — is either built into .NET or
|
||||||
|
covered by a permissive, well-maintained NuGet package.
|
||||||
|
|
||||||
|
**Net effect on native dependencies:** from 8 vcpkg deps + 1 vendored, down to **3 native
|
||||||
|
libraries** (libopus, RNNoise, miniaudio) — all three tiny, all three already vendored or
|
||||||
|
trivially buildable, and all three optional to *replace* later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What exists today (baseline inventory)
|
||||||
|
|
||||||
|
Sizes are source bytes, to calibrate effort.
|
||||||
|
|
||||||
|
### Core — `core/` (~310 KB C++)
|
||||||
|
|
||||||
|
| File | Bytes | What it is | Port difficulty |
|
||||||
|
|------|-------|-----------|-----------------|
|
||||||
|
| `core/src/core/client.cpp` + `.h` | 107 K | `vc_client` — the entire client state machine: connect/auth/TOFU, channel + user model, stream lifecycle, reframing, encode path, event queue | **Medium**, but large. Mostly mechanical. |
|
||||||
|
| `core/src/audio/audio_engine.cpp` + `.h` | 69 K | miniaudio devices, `JitterBuffer`, per-ssrc decode, DRED/FEC/PLC ladder, mixer, level meters, talk detection, external feed/tap/mixed-sink | **Hard** — the RT-sensitive part |
|
||||||
|
| `core/src/net/transport.cpp` + `.h` | 25 K | Asio TCP framing `[u32 len][payload]`, UDP socket | **Easy** — `Socket`/`System.IO.Pipelines` is nicer |
|
||||||
|
| `core/src/crypto/` | 27 K | mbedTLS TLS 1.3 wrapper, exporter, self-signed cert gen, Ed25519 identity, ChaCha20-Poly1305 + anti-replay, TOFU pin store | **Hard** — see §3 |
|
||||||
|
| `core/src/codec/opus_codec.*` | 9 K | libopus encode/decode wrapper incl. DRED | **Easy** — P/Invoke shim |
|
||||||
|
| `core/src/protocol/`, `core/src/session/` | 12 K | Envelope (de)serialize, dispatch, channel/user/stream registry | **Easy** |
|
||||||
|
| `core/src/audio/apm_processor.*` | 5 K | RNNoise wrapper + energy VAD | **Easy** |
|
||||||
|
| `core/src/voicecat.cpp` | 13 K | C ABI façade over `vc_client` | **Deleted** — no ABI needed any more |
|
||||||
|
| `core/include/voicecat.h` | 27 K | The C ABI | **Becomes a C# interface**, not an ABI |
|
||||||
|
| `core/proto/voicecat.proto` | 9 K | Wire format, source of truth | **Unchanged** |
|
||||||
|
|
||||||
|
### Server — `server/` (~80 KB C++)
|
||||||
|
|
||||||
|
`conn_session.cpp` (34 K, per-connection protocol handling), `db.cpp` (26 K, SQLite:
|
||||||
|
accounts, channels, bans), `session_registry.cpp` (20 K), `media_relay.cpp` (8 K, the SFU
|
||||||
|
relay), `server.cpp`, `identity.cpp`. All **easy-to-medium** — this is ordinary async network
|
||||||
|
server code and translates very well.
|
||||||
|
|
||||||
|
### Clients
|
||||||
|
|
||||||
|
| Client | Today | After the port |
|
||||||
|
|--------|-------|----------------|
|
||||||
|
| Windows | C#, WinForms, `net10.0-windows`, ~40 files | **Kept.** Swap `VoiceCat.Interop` P/Invoke for a direct project reference. |
|
||||||
|
| macOS | Swift + AppKit, `MainWindowController.swift` alone is 70 K | Rewrite as C# AppKit on `net10.0-macos` — near-mechanical, AppKit maps 1:1 |
|
||||||
|
| iOS | Swift + SwiftUI, ~150 K across views/audio | Rewrite as C# UIKit on `net10.0-ios` (or MAUI — see §8.3). **No 1:1 SwiftUI equivalent.** |
|
||||||
|
| iOS broadcast appex | Swift, 12 K (`SampleHandler.swift` + `BroadcastAudioRing.swift`) | **Stays Swift.** See §8.4 |
|
||||||
|
| `tools/vccli` | C++, 32 K | Rewrite as a C# console app — this becomes the primary conformance harness |
|
||||||
|
|
||||||
|
### Tests — `tests/` (29 files, ~290 KB, all green)
|
||||||
|
|
||||||
|
These are the real specification. Every one of them must be ported to xUnit and stay green;
|
||||||
|
the milestone exit criteria in `docs/roadmap.md` are encoded here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Target solution layout
|
||||||
|
|
||||||
|
```
|
||||||
|
voice-cat/
|
||||||
|
├── proto/voicecat.proto # unchanged, single source of truth
|
||||||
|
├── native/ # the only C left
|
||||||
|
│ ├── opus/ # libopus 1.6 build scripts
|
||||||
|
│ ├── rnnoise/ # moved from third_party/
|
||||||
|
│ ├── miniaudio/ # miniaudio.h + voicecat_audio_shim.c (§5.2)
|
||||||
|
│ └── build-native.{ps1,sh} # produces per-RID binaries
|
||||||
|
├── src/
|
||||||
|
│ ├── VoiceCat.Protocol/ # Google.Protobuf codegen + Envelope framing
|
||||||
|
│ ├── VoiceCat.Crypto/ # TLS, media AEAD, anti-replay, identity, TOFU store
|
||||||
|
│ ├── VoiceCat.Codec/ # libopus P/Invoke + OpusEncoder/OpusDecoder
|
||||||
|
│ ├── VoiceCat.Dsp/ # RNNoise P/Invoke, energy VAD, resample helpers
|
||||||
|
│ ├── VoiceCat.Audio/ # devices, jitter buffer, mixer, RT ring buffers
|
||||||
|
│ ├── VoiceCat.Core/ # VoiceCatClient — replaces vc_client + the C ABI
|
||||||
|
│ ├── VoiceCat.Server/ # replaces server/
|
||||||
|
│ ├── VoiceCat.Cli/ # replaces tools/vccli + voicecat-admin
|
||||||
|
│ └── clients/
|
||||||
|
│ ├── VoiceCat.Windows/ # net10.0-windows, WinForms (kept, retargeted)
|
||||||
|
│ ├── VoiceCat.Mac/ # net10.0-macos, AppKit
|
||||||
|
│ ├── VoiceCat.iOS/ # net10.0-ios, UIKit
|
||||||
|
│ └── VoiceCatBroadcast/ # Swift appex — the one non-C# artifact
|
||||||
|
└── tests/VoiceCat.Tests/ # xUnit, ports all 29 ctest cases
|
||||||
|
```
|
||||||
|
|
||||||
|
**Target frameworks.** `VoiceCat.Core` and everything below it target plain `net10.0` — no
|
||||||
|
platform TFM — so the same assembly loads into the server, the WinForms app, the macOS app,
|
||||||
|
the iOS app, and the test host. Only the four leaf client projects carry a platform TFM.
|
||||||
|
|
||||||
|
**Why not one big assembly:** the layering is what keeps the "core owns audio, UI is thin"
|
||||||
|
rule enforceable. It also lets the server reference `VoiceCat.Protocol` + `VoiceCat.Crypto`
|
||||||
|
without dragging in miniaudio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The TLS problem — read this before anything else
|
||||||
|
|
||||||
|
### 3.1 What breaks
|
||||||
|
|
||||||
|
`docs/security.md` §2 is built on one mbedTLS call:
|
||||||
|
|
||||||
|
```c
|
||||||
|
mbedtls_ssl_export_keying_material("voicecat media v1", ...) → media keys
|
||||||
|
```
|
||||||
|
|
||||||
|
**.NET has no equivalent.** `SslStream` exposes no RFC 5705 exporter. The API proposal
|
||||||
|
([dotnet/runtime#112529](https://github.com/dotnet/runtime/issues/112529)) is labelled
|
||||||
|
`api-suggestion` ("NOT ready for implementation"), milestone *Future*, and the platform notes
|
||||||
|
on it are discouraging: *"Windows – needs verification"* (SChannel runs TLS in a separate
|
||||||
|
privileged process and deliberately refuses to hand back secrets), *"OSX – Not implemented
|
||||||
|
for Secure Transport"*. Do not plan around this shipping.
|
||||||
|
|
||||||
|
This is not a small dependency swap. The media key derivation is the root of the entire UDP
|
||||||
|
path: AEAD keys, nonce discipline, anti-replay, and the UDP binding token all hang off it.
|
||||||
|
|
||||||
|
### 3.2 Option A (recommended) — BouncyCastle managed TLS
|
||||||
|
|
||||||
|
[BouncyCastle for .NET](https://www.nuget.org/packages/BouncyCastle.Cryptography) (MIT,
|
||||||
|
actively maintained) ships a **complete managed TLS 1.3 client and server** in
|
||||||
|
`Org.BouncyCastle.Tls`, and `TlsContext` exposes exactly the method we need:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
byte[] ExportKeyingMaterial(string asciiLabel, byte[] contextValue, int length);
|
||||||
|
// "Export keying material according to RFC 5705" — TLS 1.3 (RFC 8446 §7.5) aware
|
||||||
|
```
|
||||||
|
|
||||||
|
*(verified against [bc-csharp `crypto/src/tls/TlsContext.cs`](https://github.com/bcgit/bc-csharp/blob/master/crypto/src/tls/TlsContext.cs); the C# port is feature-matched to bc-java here.)*
|
||||||
|
|
||||||
|
**Consequences — mostly good:**
|
||||||
|
|
||||||
|
- ✅ **Byte-identical wire compatibility with the existing C++ implementation.** This is the
|
||||||
|
single biggest de-risking factor in the whole project: it means a .NET client can talk to
|
||||||
|
the shipped C++ server (and vice versa) at *every* step of the port, and the C++ side
|
||||||
|
becomes a conformance oracle. See §11.
|
||||||
|
- ✅ **Identical TLS behaviour on all five platforms.** No SChannel-vs-OpenSSL-vs-
|
||||||
|
SecureTransport variance, no per-OS cipher-suite policy surprises, no "TLS 1.3 on macOS
|
||||||
|
only since .NET 10" caveat. For a self-hosted product shipping to unknown machines this is
|
||||||
|
worth a lot on its own.
|
||||||
|
- ✅ MIT, permissive — satisfies the hard no-GPL rule.
|
||||||
|
- ✅ Also gives us **Ed25519** and **BLAKE2b** (see §4), which .NET lacks.
|
||||||
|
|
||||||
|
**Costs:**
|
||||||
|
|
||||||
|
- ⚠️ Pure-managed TLS is slower than SChannel/OpenSSL. **This does not matter here** — the
|
||||||
|
TLS channel carries only control messages (a handshake plus a few KB/s of protobuf). Media
|
||||||
|
is UDP + ChaCha20-Poly1305, which uses the fast built-in .NET AEAD, not BouncyCastle.
|
||||||
|
- ⚠️ You implement `TlsClient` / `TlsServer` callback subclasses yourself (~200–300 lines for
|
||||||
|
both sides): cipher-suite selection, certificate handling, `TlsCrypto` provider. Well-trodden
|
||||||
|
— BC ships `DefaultTlsClient`/`DefaultTlsServer` bases and `BcTlsCrypto`.
|
||||||
|
- ⚠️ You own the cert-validation logic (that's actually a *plus* for TOFU — see §3.4).
|
||||||
|
- ⚠️ One more external dependency in the trust base. It's Bouncy Castle; acceptable.
|
||||||
|
|
||||||
|
### 3.3 Option B — `SslStream` + in-band media keys (protocol v3)
|
||||||
|
|
||||||
|
Abandon the exporter. Since the TLS 1.3 channel is already confidential, authenticated and
|
||||||
|
forward-secret, the server can simply **generate the media keys and send them inside it**:
|
||||||
|
|
||||||
|
```proto
|
||||||
|
message AuthResult {
|
||||||
|
// ...
|
||||||
|
bytes udp_token = 6;
|
||||||
|
bytes media_key_c2s = 7; // 32 bytes, server-generated CSPRNG (NEW, v3)
|
||||||
|
bytes media_key_s2c = 8; // 32 bytes (NEW, v3)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is what Mumble does (its OCB2/AES key exchange happens inside its TLS control channel),
|
||||||
|
and it is what SDES-SRTP-over-TLS does. Security posture is equivalent: an attacker who can
|
||||||
|
read these can already read everything.
|
||||||
|
|
||||||
|
- ✅ Uses only built-in `System.Net.Security.SslStream` — zero TLS dependencies.
|
||||||
|
- ✅ Simplest possible code; best per-platform TLS performance.
|
||||||
|
- ❌ **Breaks wire compatibility** → no cross-testing against the C++ implementation during
|
||||||
|
the port, which throws away the best safety net available.
|
||||||
|
- ❌ Protocol version bump to 3, forced-update for the shipped iOS/macOS/Windows clients.
|
||||||
|
- ❌ Loses the exporter's nice property that media keys are never *transmitted* at all.
|
||||||
|
- ⚠️ Server-side `SslStream` on Windows has a real footgun: a cert created with
|
||||||
|
`CertificateRequest.CreateSelfSigned` must be round-tripped through
|
||||||
|
`X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null)` before SChannel
|
||||||
|
will accept it — an ephemeral/CNG-only key produces a confusing handshake failure.
|
||||||
|
|
||||||
|
### 3.4 Recommendation
|
||||||
|
|
||||||
|
**Take Option A (BouncyCastle) for the port. Keep Option B as an optional later
|
||||||
|
simplification** once the C++ tree is retired and you no longer need it as an oracle — at
|
||||||
|
which point it's a contained protocol-v3 change, not a rewrite.
|
||||||
|
|
||||||
|
TOFU is *easier* under Option A: BouncyCastle hands you the peer's DER certificate chain
|
||||||
|
directly in `TlsAuthentication.NotifyServerCertificate`, so
|
||||||
|
`SHA256.HashData(leafDer)` — the exact value `docs/security.md` §1.1 says is pinned — falls
|
||||||
|
out with no ceremony. (Under `SslStream` you'd get it from
|
||||||
|
`RemoteCertificateValidationCallback` via `cert.GetRawCertData()`; also fine, just less
|
||||||
|
direct.)
|
||||||
|
|
||||||
|
**Worth fixing while you're in here:** `security.md` §1.1 documents a known limitation — the
|
||||||
|
Ed25519 identity key is not bound to the TLS cert, so it's display-only. When generating the
|
||||||
|
self-signed cert in C#, **embed the Ed25519 public key as a X.509 extension or SAN URI**.
|
||||||
|
`CertificateRequest.CertificateExtensions` makes this trivial, and it closes the gap the doc
|
||||||
|
has been carrying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Dependency map — C++ → .NET
|
||||||
|
|
||||||
|
| Concern | Today | .NET replacement | License | Notes / pitfalls |
|
||||||
|
|---------|-------|------------------|---------|------------------|
|
||||||
|
| Sockets, timers | Asio | **`System.Net.Sockets`** + `System.IO.Pipelines` + `PeriodicTimer` | built-in | Strictly better. `Socket.ReceiveFromAsync(SocketAddress)` (net8+) is the allocation-free UDP receive path — use it, not `UdpClient`. |
|
||||||
|
| Control framing | hand-rolled `[u32 len]` | `System.IO.Pipelines` `SequenceReader` | built-in | Removes a class of bugs. Keep the same 16 MiB frame cap. |
|
||||||
|
| TLS 1.3 | mbedTLS | **BouncyCastle `Org.BouncyCastle.Tls`** | MIT | See §3. **Not `SslStream`.** |
|
||||||
|
| Media AEAD | libsodium ChaCha20-Poly1305 | **`System.Security.Cryptography.ChaCha20Poly1305`** | built-in | ⚠️ Check `ChaCha20Poly1305.IsSupported` at startup — it is OS-backed (Windows 10 1903+ / OpenSSL 1.1+). Fall back to BouncyCastle's `ChaCha20Poly1305` if false. Same 12-byte nonce, 16-byte tag → identical wire bytes. |
|
||||||
|
| Anti-replay window | hand-rolled 64-bit | port verbatim | — | ~40 lines. Keep the RFC 3711 §3.3 ordering (replay-check → authenticate → *then* advance). This ordering is load-bearing; `test_media_aead.cpp` covers it. |
|
||||||
|
| Argon2id | libsodium `crypto_pwhash` | **BouncyCastle `Argon2BytesGenerator`** | MIT | Implemented with a strict libsodium PHC parser and original costs; native database tests prove existing-account import and managed-account verification by C++. See `docs/api-dotnet.md` for cost bounds. |
|
||||||
|
| BLAKE2b (channel passwords) | libsodium `crypto_generichash` | **`Blake2Fast`** (MIT) or BouncyCastle `Blake2bDigest` | MIT | Salted BLAKE2b-256, must produce identical digests to keep existing channel passwords working. Blake2Fast is SIMD and fast enough for the net thread, preserving the reason BLAKE2b was chosen over Argon2 here. |
|
||||||
|
| Ed25519 identity | libsodium | **BouncyCastle `Ed25519Signer`** | MIT | ⚠️ **Not in .NET 10.** [dotnet/runtime#63174](https://github.com/dotnet/runtime/issues/63174) is api-approved but milestoned **11.0.0**. Since Option A already pulls in BouncyCastle, this is free. |
|
||||||
|
| Self-signed cert gen | mbedTLS x509write | **`CertificateRequest.CreateSelfSigned`** | built-in | Much nicer than the C++ version. ECDSA-P256, same as today. Add the Ed25519 SAN (§3.4). |
|
||||||
|
| CSPRNG | libsodium | **`RandomNumberGenerator`** | built-in | — |
|
||||||
|
| Opus codec | libopus 1.6 | **P/Invoke libopus 1.6** | BSD | **Keep native.** [Concentus](https://github.com/lostromb/concentus) is a pure-C# Opus port but it tracks **Opus 1.1** — it has no DRED, no 1.6 features. `docs/voice.md` §4 and `test_dred_toggle.cpp` depend on DRED. Concentus is a viable *fallback* for a future platform where native linking is impossible, not the primary. |
|
||||||
|
| Noise suppression | RNNoise (vendored) | **P/Invoke RNNoise** | BSD-3 + CC0 | **Keep native.** No managed port exists. It's ~5 exported functions; the binding is trivial. Already vendored at `third_party/rnnoise/`. |
|
||||||
|
| Audio capture/playback | miniaudio | **P/Invoke miniaudio via a shim** | MIT-0/PD | **Keep native**, see §5.2. Managed alternatives exist ([SoundFlow](https://www.nuget.org/packages/SoundFlow), [MiniaudioSharp](https://www.nuget.org/packages/MiniaudioSharp), NAudio/CSCore for Windows-only) but auto-generated bindings marshal the callback into managed code, which is exactly what you must avoid (§5.1). Write the shim yourself. |
|
||||||
|
| Energy VAD | hand-rolled | port verbatim | — | ~60 lines. Trivial. |
|
||||||
|
| Protobuf | protobuf-lite (C++) | **`Google.Protobuf`** + `Grpc.Tools` | BSD | ⚠️ Reference `Grpc.Tools` for the `protoc` MSBuild integration even though there is no gRPC here — it is the standard way to codegen `.proto` in a `.csproj`. `<Protobuf Include="../../proto/voicecat.proto" GrpcServices="None" />`. The `.proto` needs **zero changes**. |
|
||||||
|
| SQLite | sqlite3 | **`Microsoft.Data.Sqlite.Core` + SQLitePCLRaw** | MIT / public domain | Implemented with provider 10.0.5, bundle 3.0.2 and pinned SQLite 3.50.4.2. Same schema/file; native database import is tested. NativeAOT publishing remains to be validated. |
|
||||||
|
| Logging | spdlog | **`Microsoft.Extensions.Logging`** (+ Serilog console sink) | MIT/Apache | Use `LoggerMessage` source generators on any path near the hot loop. Never log from an audio path. |
|
||||||
|
| Server config | `server.toml` | **`Tomlyn`** (MIT) or switch to JSON + `System.Text.Json` | MIT | Tomlyn keeps `server.toml` compatible; recommended, since operator-facing config shouldn't churn. |
|
||||||
|
| CLI arg parsing | hand-rolled | **`System.CommandLine`** | MIT | For `VoiceCat.Cli` and the server. |
|
||||||
|
| Build | CMake + vcpkg | **`dotnet build`** + a small native build script | — | vcpkg disappears entirely except for the 3 native libs, which you can vendor as sources and build with a 20-line CMakeLists or even `cl`/`gcc` directly. |
|
||||||
|
|
||||||
|
### 4.1 License check
|
||||||
|
|
||||||
|
Every replacement is MIT / BSD / Apache-2.0 / built-in. **The hard no-GPL/LGPL rule in
|
||||||
|
`docs/tech-stack.md` §5 holds.** BouncyCastle is MIT. Konscious is MIT. Blake2Fast is MIT.
|
||||||
|
Tomlyn is MIT. The .NET runtime itself is MIT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Real-time audio — the hard part
|
||||||
|
|
||||||
|
This is where a naive port fails. `docs/architecture.md` §3 states the rule: *"Audio
|
||||||
|
(real-time) threads must not allocate, lock, log, or do syscalls."* A managed runtime adds a
|
||||||
|
second rule: **they must not be subject to GC pauses, and they must not be managed threads at
|
||||||
|
all if avoidable.**
|
||||||
|
|
||||||
|
### 5.1 Why you cannot just P/Invoke miniaudio and use `[UnmanagedCallersOnly]`
|
||||||
|
|
||||||
|
miniaudio calls your `ma_device_data_proc` on an OS-owned real-time audio thread (WASAPI's
|
||||||
|
MMCSS thread, a CoreAudio IOThread, an ALSA thread). If that callback is a managed method:
|
||||||
|
|
||||||
|
1. **The thread must attach to the runtime.** First entry does thread registration; every
|
||||||
|
entry does a managed↔native transition.
|
||||||
|
2. **The thread becomes GC-suspendable.** A gen-0 collection anywhere in the process can
|
||||||
|
suspend it mid-callback. WASAPI in exclusive/low-latency mode will glitch on a 2 ms stall;
|
||||||
|
a gen-2 blocking collection is fatal to the audio.
|
||||||
|
3. **Delegate lifetime.** Even with `[UnmanagedCallersOnly]` (which correctly avoids the
|
||||||
|
marshalling stub and the `GCHandle` dance) the *reachability* problem is solved but the
|
||||||
|
suspension problem is not.
|
||||||
|
|
||||||
|
The existing Windows client sidesteps all of this by keeping the entire audio pipeline in
|
||||||
|
C++. A pure-.NET port has to solve it deliberately.
|
||||||
|
|
||||||
|
### 5.2 Recommended design: a native shim that owns the RT thread
|
||||||
|
|
||||||
|
Write **one small C file** (`native/miniaudio/voicecat_audio_shim.c`, est. 300–400 lines)
|
||||||
|
that compiles miniaudio and exposes a *pull/push ring-buffer API* instead of a callback API:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// The audio callback lives entirely in C. It only ever touches lock-free ring buffers.
|
||||||
|
// Managed code polls. No managed frame is ever on an RT stack.
|
||||||
|
|
||||||
|
vcsh_device* vcsh_capture_open (const char* device_id, uint32_t channels, uint32_t rate);
|
||||||
|
size_t vcsh_capture_read (vcsh_device*, int16_t* dst, size_t frames); // non-blocking
|
||||||
|
vcsh_device* vcsh_playback_open(const char* device_id, uint32_t channels, uint32_t rate);
|
||||||
|
size_t vcsh_playback_write(vcsh_device*, const int16_t* src, size_t frames);
|
||||||
|
int vcsh_playback_wait(vcsh_device*, int timeout_ms); // eventfd/Event, wakes the mixer
|
||||||
|
void vcsh_enumerate(int capture, vcsh_device_info** out, size_t* n);
|
||||||
|
```
|
||||||
|
|
||||||
|
Managed side then runs a **normal, dedicated, non-RT `Thread`** at
|
||||||
|
`ThreadPriority.Highest`, woken by `vcsh_playback_wait`, that does: drain jitter buffers →
|
||||||
|
Opus decode → NR → gain/mute → mix → `vcsh_playback_write`. The ring absorbs GC pauses; size
|
||||||
|
it for ~120 ms (6 × 20 ms frames), which is well within the latency budget the jitter buffer
|
||||||
|
already targets (`target_depth_ms_` starts at 40).
|
||||||
|
|
||||||
|
This is *the same architecture the code already has* — `AudioEngine` already runs a
|
||||||
|
`mixer_timer_thread_` for the iOS external-playback path and already has per-stream ring
|
||||||
|
buffers (`RemoteStream::ring`). You are generalising the iOS path to every platform. That is
|
||||||
|
a pleasing simplification, and it means the iOS design needs no special case at all.
|
||||||
|
|
||||||
|
**Bonus:** it makes `vc_set_external_playback` / `vc_set_mixed_output_sink` disappear as
|
||||||
|
special modes. Everything is external playback; the shim is just one more sink.
|
||||||
|
|
||||||
|
### 5.3 GC and allocation discipline in the managed audio path
|
||||||
|
|
||||||
|
Even off the RT thread, the decode/mix loop runs 50×/second per stream and must not churn:
|
||||||
|
|
||||||
|
- `<ServerGarbageCollector>false</ServerGarbageCollector>` and
|
||||||
|
`<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>` on client apps.
|
||||||
|
Set `GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency` while a call is active.
|
||||||
|
- **Preallocate everything at stream init**, exactly as `RemoteStream::init_ring` does today.
|
||||||
|
Use `int16[]` fields, not `new` per frame.
|
||||||
|
- Use `Span<short>` / `ReadOnlySpan<short>` throughout the DSP; `ArrayPool<short>.Shared` for
|
||||||
|
the rare variable-size case. Never LINQ, never `IEnumerable`, never `List<T>` growth on
|
||||||
|
this path.
|
||||||
|
- Marshal to native with `fixed` + raw pointers, or declare P/Invokes as
|
||||||
|
`[LibraryImport]` taking `ref short` / `ReadOnlySpan<short>` — the source generator emits
|
||||||
|
pinning without a marshalling stub. **Do not** use `Marshal.Copy` per frame.
|
||||||
|
- **Add an allocation regression test.** `GC.GetAllocatedBytesForCurrentThread()` before/after
|
||||||
|
1000 simulated mix cycles must be ~0. This is a cheap, high-value test the C++ code can't
|
||||||
|
even express.
|
||||||
|
- The mixer's soft limiter, the RMS level meter, and the RNNoise call are all
|
||||||
|
fixed-work-per-frame — they port directly.
|
||||||
|
|
||||||
|
### 5.4 Jitter buffer
|
||||||
|
|
||||||
|
`JitterBuffer` uses `std::map<uint32_t, Frame>` keyed by timestamp with wraparound handling,
|
||||||
|
plus `try_lock` everywhere so the RT thread never blocks. In C#:
|
||||||
|
|
||||||
|
- `SortedDictionary<uint,Frame>` allocates per insert. Prefer a **fixed-capacity circular
|
||||||
|
array of pre-allocated frame slots** indexed by `(ts / frameSamples) % capacity` — the
|
||||||
|
buffer is bounded at 500 ms anyway (`kLateDropSamples`), so a ring is the natural shape and
|
||||||
|
removes all allocation. This is a genuine improvement over the current C++.
|
||||||
|
- Replace `try_lock` with `Monitor.TryEnter` or, better, make the ring single-producer
|
||||||
|
(net thread) / single-consumer (mixer thread) with `Volatile`/`Interlocked` indices and drop
|
||||||
|
the lock entirely.
|
||||||
|
- Keep the EWMA jitter estimation, the leading-edge reseed, and the frame-skip catch-up logic
|
||||||
|
**verbatim** — that logic is subtle, hard-won, and covered by `test_jitter_depth.cpp`.
|
||||||
|
|
||||||
|
### 5.5 Opus P/Invoke
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[LibraryImport("opus")]
|
||||||
|
internal static partial int opus_encode(IntPtr st, ReadOnlySpan<short> pcm, int frameSize,
|
||||||
|
Span<byte> data, int maxDataBytes);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. The implemented
|
||||||
|
desktop binding uses fixed C entry points in `dotnet/native/media.c`; C calls the
|
||||||
|
varargs function with the correct ABI. This also handles Apple arm64's different
|
||||||
|
varargs calling convention. Only whitelisted single-int controls are accepted.
|
||||||
|
- DRED (`opus_dred_alloc`, `opus_dred_parse`, `opus_decoder_dred_decode`) binds the same way.
|
||||||
|
Guard with a runtime feature check as `opus_codec.cpp` does today.
|
||||||
|
- **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link
|
||||||
|
`libopus.a` via `<NativeReference>` in the `.csproj`. Multi-target the DllImport name with a
|
||||||
|
`const string` behind `#if IOS`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Core client port — `VoiceCat.Core`
|
||||||
|
|
||||||
|
`vc_client` (107 KB) is the biggest single unit. It becomes `VoiceCatClient : IAsyncDisposable`.
|
||||||
|
|
||||||
|
### 6.1 The C ABI goes away — and the API gets much better
|
||||||
|
|
||||||
|
The 60-odd `vc_*` functions were shaped by C ABI constraints. In C#:
|
||||||
|
|
||||||
|
| C ABI pattern | C# replacement |
|
||||||
|
|---------------|----------------|
|
||||||
|
| `vc_result` enum returns | Exceptions for programmer errors; `VoiceCatResult` for protocol outcomes |
|
||||||
|
| `vc_callbacks.on_event` + `vc_event` union-ish struct | **`IAsyncEnumerable<VoiceCatEvent>`** or typed `event` handlers per event type. Kill the `u32a` generic-payload field — use a discriminated hierarchy (`record UserJoined(uint UserId, uint ChannelId, string Nick)`). |
|
||||||
|
| `VC_EVENT_JOIN_RESULT` correlating with `vc_join_channel` | **`Task<JoinResult> JoinChannelAsync(uint id, string? pw, CancellationToken ct)`** — request/response correlation via `TaskCompletionSource` keyed on `Envelope.request_id`. This removes an entire class of "which reply was mine" bugs and shrinks every client's code. |
|
||||||
|
| `vc_list_channels` + `vc_free_channel_list` | `IReadOnlyList<Channel> Channels { get; }` — no ownership contract at all |
|
||||||
|
| `vc_get_server_identity_display(buf, cap, out len)` two-call idiom | `string ServerIdentityDisplay { get; }` |
|
||||||
|
| `vc_set_pcm_sink` / `vc_set_mixed_output_sink` / `vc_stream_feed_pcm` | Keep as-is conceptually — they're the bot/extension API. `Action<PcmFrame>` or a `ChannelWriter<T>`. Document the no-blocking rule just as loudly. |
|
||||||
|
| `vc_test_inject_capture` | `internal` test hook, not public API |
|
||||||
|
|
||||||
|
**Do this deliberately, not accidentally.** Write `docs/api-dotnet.md` as the successor to
|
||||||
|
`voicecat.h`, and keep the same rule from `CLAUDE.md`: changing it is a versioned act.
|
||||||
|
|
||||||
|
### 6.2 Threading model in C#
|
||||||
|
|
||||||
|
| C++ | C# |
|
||||||
|
|-----|-----|
|
||||||
|
| Asio `io_context` on `io_thread_` | A single `async` read loop over `PipeReader` per connection; no explicit thread |
|
||||||
|
| `WorkerPool` (blocking work) | Default `ThreadPool` — `Task.Run` for Argon2id, SQLite, DNS |
|
||||||
|
| Event queue drained by UI | `System.Threading.Channels.Channel<VoiceCatEvent>` (already what the Windows client does) |
|
||||||
|
| Mixer timer thread | Dedicated `Thread` (§5.2) — **not** a `Task`, the thread pool is not for this |
|
||||||
|
| Capture/encode thread | Dedicated `Thread`, fed by the shim's capture ring |
|
||||||
|
|
||||||
|
`WorkerPool` (861 bytes) simply deletes.
|
||||||
|
|
||||||
|
### 6.3 State model
|
||||||
|
|
||||||
|
`client.cpp` holds channel/user/stream maps guarded by mutexes and exposes them through
|
||||||
|
pull-based `vc_list_*`. In C#, hold them as immutable snapshots swapped with
|
||||||
|
`Volatile.Write` — readers get a consistent view with no locking, and the UI can bind to it
|
||||||
|
directly. `VC_EVENT_CHANNEL_LIST` becomes "a new snapshot is available", which is what it
|
||||||
|
already means.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Server port — `VoiceCat.Server`
|
||||||
|
|
||||||
|
The most mechanical part of the project. Straight `async`/`await` network code.
|
||||||
|
|
||||||
|
| Component | Port notes |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `server.cpp` — accept loop | `Socket.AcceptAsync` loop + `Task` per connection. Trivial. |
|
||||||
|
| `conn_session.cpp` (34 K) — per-conn protocol | The bulk. A big `switch` on `Envelope.BodyCase`. Mechanical; write it against the ported xUnit tests. |
|
||||||
|
| `session_registry.cpp` | `ConcurrentDictionary<ulong, Session>` + a channel-membership index. Simpler than the C++. |
|
||||||
|
| `media_relay.cpp` — the SFU | **The server hot path.** Authenticate/decrypt using the sender's directional key, then reseal for each recipient with its directional key and next counter. Preserve SSRC, timestamp, flags, and encoded Opus bytes; replace sequence and ciphertext/tag. Use pooled buffers and `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)`. Never decode audio. Benchmark fan-out and allocations. |
|
||||||
|
| `db.cpp` (26 K) — SQLite | `Microsoft.Data.Sqlite`, same schema, same file. Keep raw SQL — do not introduce EF Core; the schema is 4 tables and EF's startup cost hurts the "single binary, instant start" goal. |
|
||||||
|
| `identity.cpp` | `CertificateRequest` + BouncyCastle Ed25519. Reads the same on-disk files. |
|
||||||
|
| Keepalive reaper | `PeriodicTimer` — cleaner than the `asio::steady_timer`. |
|
||||||
|
| `server.toml` | Tomlyn, unchanged format. |
|
||||||
|
|
||||||
|
### 7.1 Deployment — keeping the "single static binary" promise
|
||||||
|
|
||||||
|
`docs/deployment.md` promises a single statically-linked executable with no runtime to
|
||||||
|
install. **NativeAOT preserves this:**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<StripSymbols>true</StripSymbols>
|
||||||
|
```
|
||||||
|
|
||||||
|
- ✅ SQLitePCLRaw, Google.Protobuf, and BouncyCastle are all AOT-compatible.
|
||||||
|
- ✅ Startup drops to ~5 ms; binary lands around 15–25 MB (vs. the current C++ static binary
|
||||||
|
— comparable order of magnitude).
|
||||||
|
- ⚠️ **No reflection-based JSON/config.** Use `System.Text.Json` source generators
|
||||||
|
(`JsonSerializerContext`) if you use JSON anywhere. Tomlyn's model binding uses reflection —
|
||||||
|
either use its low-level `DocumentSyntax` API or add trim descriptors.
|
||||||
|
- ⚠️ Cross-compilation is per-RID; you need a build machine per target (`linux-x64`,
|
||||||
|
`linux-arm64`, `win-x64`, `osx-arm64`). Same as today with vcpkg, so no regression.
|
||||||
|
- The Docker image gets *simpler*: `FROM scratch`-ish with a NativeAOT binary, or
|
||||||
|
`mcr.microsoft.com/dotnet/runtime-deps:10.0-noble`.
|
||||||
|
|
||||||
|
**Alternative if AOT fights you:** self-contained single-file publish
|
||||||
|
(`PublishSingleFile` + `SelfContained`) — bigger (~70 MB) and slower to start, but no AOT
|
||||||
|
constraints. Keep as a fallback per-RID, not the default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Clients
|
||||||
|
|
||||||
|
### 8.1 Windows — the easy one
|
||||||
|
|
||||||
|
The `net10.0-windows` WinForms app is already C# and already structured around
|
||||||
|
`Channel<VoiceCatEvent>` + a 30 ms UI-thread pump. The port is:
|
||||||
|
|
||||||
|
1. Delete `VoiceCat.Interop` (the P/Invoke layer) and `VoiceCat.Interop.Tests`.
|
||||||
|
2. `<ProjectReference Include="VoiceCat.Core" />`.
|
||||||
|
3. Update ~40 call sites from `VcResult r = Native.vc_join_channel(...)` to
|
||||||
|
`await client.JoinChannelAsync(...)`. Mostly a find/replace plus `async void` →
|
||||||
|
`async Task` hygiene on event handlers.
|
||||||
|
4. `Audio/ProcessLoopbackCapture.cs`, `ProcessAudioMixer.cs`, `AudioSessionEnumerator.cs`
|
||||||
|
(WASAPI process loopback, `AUDIOCLIENT_ACTIVATION_PARAMS`) — **unchanged**. They already
|
||||||
|
feed `vc_stream_feed_pcm`; they'll feed `client.FeedPcm(...)`.
|
||||||
|
5. `Native/RawInput.cs` (PTT hotkeys), `Models/PasswordProtector.cs` (DPAPI),
|
||||||
|
`Notifications/*` (SAPI announcer, sound pool) — **unchanged**.
|
||||||
|
|
||||||
|
**WinForms accessibility (the reason it was chosen over WinUI 3) is unaffected.** Keep it.
|
||||||
|
|
||||||
|
**Estimated effort: 1–2 weeks.** This client is nearly free.
|
||||||
|
|
||||||
|
### 8.2 macOS — AppKit in C#
|
||||||
|
|
||||||
|
`net10.0-macos` gives full AppKit bindings via [dotnet/macios](https://github.com/dotnet/macios).
|
||||||
|
The Swift AppKit code maps almost line-for-line:
|
||||||
|
|
||||||
|
| Swift | C# |
|
||||||
|
|-------|-----|
|
||||||
|
| `NSWindowController`, `NSOutlineView`, `NSTableViewDataSource` | Same types, same selectors, PascalCase |
|
||||||
|
| `@objc func handleClick(_ sender: Any)` | `[Export("handleClick:")] void HandleClick(NSObject sender)` |
|
||||||
|
| `accessibilityLabel`, `NSAccessibility.post(.announcement)` | `AccessibilityLabel`, `NSAccessibility.PostNotification(...)` — **full VoiceOver parity, the reason AppKit was chosen holds** |
|
||||||
|
| `ScreenAudioCapture.swift` — ScreenCaptureKit | ✅ **ScreenCaptureKit is bound** in `net10.0-macos` (`SCStream`, `SCContentFilter`, `SCStreamConfiguration` incl. `CapturesAudio` / `ExcludesCurrentProcessAudio`). Per-app include/exclude filters and the VoiceOver-exclusion set port directly. |
|
||||||
|
| `InputDeviceCapture.swift` — CoreAudio | AVFoundation/CoreAudio bound; or just use the miniaudio shim on macOS |
|
||||||
|
|
||||||
|
`MainWindowController.swift` is 70 KB — this is the single largest UI rewrite. Budget for it.
|
||||||
|
|
||||||
|
⚠️ **Distribution:** a `net10.0-macos` app bundle needs codesigning + notarization, and
|
||||||
|
NativeAOT for macOS app bundles is supported but adds a step. Nothing blocking; just not
|
||||||
|
free.
|
||||||
|
|
||||||
|
### 8.3 iOS — the SwiftUI gap
|
||||||
|
|
||||||
|
This is the only client with no mechanical path, because **SwiftUI has no C# equivalent.**
|
||||||
|
Three options:
|
||||||
|
|
||||||
|
| Option | Pros | Cons |
|
||||||
|
|--------|------|------|
|
||||||
|
| **A. UIKit in C#** (`net10.0-ios`, hand-written) | Full API access, best accessibility control, matches the macOS/AppKit approach, no extra framework | The ~150 KB of SwiftUI views (`SettingsView`, `ChannelTreeView`, `ChatView`, …) must be re-authored as UIKit — a real rewrite, not a translation |
|
||||||
|
| **B. .NET MAUI** | Fastest to write; XAML declarative style is closest in spirit to SwiftUI; one codebase could later cover macOS too (Mac Catalyst) | ⚠️ Accessibility is weaker than native UIKit — and the project *explicitly* chose native toolkits for screen-reader quality (`roadmap.md` §2). Extra abstraction layer over the audio-sensitive app lifecycle. |
|
||||||
|
| **C. Avalonia** | One UI codebase for Windows + macOS + iOS | ⚠️ Same accessibility objection as MAUI, *and* it would mean abandoning WinForms/AppKit — contradicts two settled decisions |
|
||||||
|
|
||||||
|
**Recommendation: Option A (UIKit).** It's more work but it is the only choice consistent
|
||||||
|
with the accessibility commitments already made twice in the docs. Budget it as the largest
|
||||||
|
single client task.
|
||||||
|
|
||||||
|
What ports cleanly regardless:
|
||||||
|
- `IOSAudioRouter.swift` (31 KB) — `AVAudioSession` is fully bound. `SetPreferredDataSource`,
|
||||||
|
`SetPreferredPolarPattern`, `AllowBluetoothA2DP`, `MeasurementMode` all exist in C#. The
|
||||||
|
re-entrancy guards and route-change filtering (`.categoryChange` / `.routeConfigurationChange`
|
||||||
|
/ `.override`) port verbatim. **Keep the invariants in `voice.md` §8 exactly.**
|
||||||
|
- `IOSVoiceProcessingEngine.swift` (24 KB) — `AVAudioEngine`, `AVAudioSourceNode`,
|
||||||
|
`SetVoiceProcessingEnabled(true)`, `VoiceProcessingAgcEnabled` are all bound.
|
||||||
|
- Under §5.2's design, iOS stops being a special case: the core is *always* externally
|
||||||
|
driven, and `AVAudioEngine` is simply the iOS "shim" implementation.
|
||||||
|
|
||||||
|
⚠️ **iOS + NativeAOT:** .NET for iOS ships Mono AOT by default; NativeAOT for iOS is
|
||||||
|
[still experimental](https://learn.microsoft.com/en-us/dotnet/maui/deployment/nativeaot).
|
||||||
|
Mono AOT is fine for the host app (it's what every Xamarin/MAUI app ships). Do not depend on
|
||||||
|
NativeAOT on iOS.
|
||||||
|
|
||||||
|
⚠️ **App Store:** you already ship `me.iamtalon.voicecat`. A runtime change is invisible to
|
||||||
|
review, but re-validate background-audio behaviour (`UIBackgroundModes: audio`) under Mono —
|
||||||
|
managed finalizers and the GC must not stall the audio render callback while backgrounded.
|
||||||
|
§5.2's native-ring design is what protects you here.
|
||||||
|
|
||||||
|
### 8.4 iOS screen sharing — **keep this in Swift**
|
||||||
|
|
||||||
|
You anticipated this correctly. The ReplayKit Broadcast Upload Extension should **not** be
|
||||||
|
ported.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
1. **The 50 MB jetsam cap.** A managed runtime (Mono AOT + metadata + GC heap) inside a
|
||||||
|
separate appex process eats a meaningful fraction of that before your code runs. The
|
||||||
|
current Swift `SampleHandler` is 4.4 KB and does one `AVAudioConverter` call per buffer.
|
||||||
|
2. **.NET for iOS does not officially support broadcast upload extensions.** Microsoft's own
|
||||||
|
guidance is that this is a [known gap with no documentation](https://learn.microsoft.com/en-sg/answers/questions/2006706/issues-with-bundling-ios-broadcast-extension-in-ne);
|
||||||
|
the supported extension types are enumerated and this isn't reliably among them.
|
||||||
|
3. **There is nothing to gain.** The extension deliberately does *not* link the core
|
||||||
|
(`voice.md` §9) — it converts PCM and writes to a shared ring. It is already a
|
||||||
|
language-agnostic boundary.
|
||||||
|
|
||||||
|
**The boundary is already clean.** `BroadcastAudioRing.swift` is an mmap'd file in an App
|
||||||
|
Group with an SPSC ring layout. C# reads it with `MemoryMappedFile.CreateFromFile` +
|
||||||
|
`MemoryMappedViewAccessor`, and `CFNotificationCenter` (Darwin notifications) is bound in
|
||||||
|
`net10.0-ios`. **Action: freeze `BroadcastAudioRing`'s binary layout as a documented struct**
|
||||||
|
(magic, version, capacity, head, tail, activeFlag, sample format) in
|
||||||
|
`docs/broadcast-ring-format.md`, so the Swift writer and the C# reader are contractually
|
||||||
|
pinned. The C# `BroadcastAudioPump` is then ~150 lines.
|
||||||
|
|
||||||
|
This leaves the repo with exactly **one Swift file plus one shared Swift ring** — an
|
||||||
|
acceptable, well-justified exception to "pure C#", and dramatically less than the current
|
||||||
|
three Swift codebases.
|
||||||
|
|
||||||
|
### 8.5 What about a Linux client?
|
||||||
|
|
||||||
|
Not in scope today, but worth noting: once the core is `net10.0` with a miniaudio shim
|
||||||
|
(which has ALSA/PulseAudio backends), a Linux client becomes a UI-only problem for the first
|
||||||
|
time. Avalonia would be the natural choice *there specifically*, without disturbing the
|
||||||
|
Windows/macOS/iOS decisions. Mention it in `roadmap.md`; don't build it now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Tests
|
||||||
|
|
||||||
|
The 29 ctest cases **are** the specification. Port every one to xUnit in
|
||||||
|
`tests/VoiceCat.Tests/`. Grouping:
|
||||||
|
|
||||||
|
| Group | Tests | Notes |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| Wire format | `test_envelope`, `test_voice_frame`, `test_frame_codec`, `test_frame_ms_reframe` | Port first. These are pure functions — fastest possible feedback on the protobuf + framing layers. |
|
||||||
|
| Crypto | `test_media_aead`, `test_tls_loopback`, `test_tofu_flow` | The AEAD test must produce **byte-identical** ciphertext to the C++ for a fixed key+nonce+AAD. Add that as a golden-vector test — it's your proof the port is wire-compatible. |
|
||||||
|
| Codec/DSP | `test_opus_codec`, `test_dred_toggle`, `test_noise_suppression`, `test_recv_noise_reduction`, `test_plc_cap` | Depend on the native P/Invokes; run them as soon as those exist. |
|
||||||
|
| Audio engine | `test_jitter_depth`, `test_channel_samplerate`, `test_external_pcm`, `test_external_playback`, `test_vad_ptt_devices` | The subtle ones. `test_jitter_depth` guards the bounded-depth invariant — do not weaken it. |
|
||||||
|
| Integration | `test_m1_integration`, `test_m2_voice`, `test_m3_multistream`, `test_tcp_loopback`, `test_disconnect_left`, `test_reaper_timeout` | Real client + real server in-process. |
|
||||||
|
| Moderation/admin | `test_m5_permissions`, `test_m5_kick_ban_move_mute`, `test_m5_channel_crud`, `test_m5_admin_accounts` | Server-side; port with `VoiceCat.Server`. |
|
||||||
|
| ABI surface | `test_voice_client_abi`, `test_channel_user_list_abi` | These test the C ABI specifically — **rewrite as API-shape tests** against the new C# surface, don't port literally. |
|
||||||
|
|
||||||
|
**New tests the port should add:**
|
||||||
|
- Allocation regression on the mix loop (§5.3).
|
||||||
|
- AEAD golden vectors vs. C++ output.
|
||||||
|
- A **cross-implementation test**: C# client ↔ C++ server, and C++ `vccli` ↔ C# server, run
|
||||||
|
in CI for as long as both trees exist (§11).
|
||||||
|
|
||||||
|
`ctest --preset dev` → `dotnet test`. The house rule in `CLAUDE.md` ("every commit builds and
|
||||||
|
passes") carries over unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Documentation changes
|
||||||
|
|
||||||
|
Per the `CLAUDE.md` rule that docs and code stay in sync:
|
||||||
|
|
||||||
|
| Doc | Change |
|
||||||
|
|-----|--------|
|
||||||
|
| `docs/architecture.md` | Rewrite §1 (shared-core model — it's now a shared *assembly*), §4 (C ABI → C# API), §3 (threading — the shim design). Keep §5 (server) and §2 (layers) nearly as-is. |
|
||||||
|
| `docs/tech-stack.md` | Replace the whole dependency table. Re-run the license audit (§5) — the no-GPL rule still passes. |
|
||||||
|
| `docs/security.md` | ⚠️ **§2 needs rewriting** to describe BouncyCastle's exporter rather than mbedTLS's, and §1.1 should be updated when the Ed25519↔cert binding lands (§3.4). §3–§6 unchanged. |
|
||||||
|
| `docs/voice.md` | §5 (jitter), §8 (pipeline), §10 (NR) get implementation-detail updates. The *protocol* sections (§2 frame format, §3 config, §4 loss resilience) are **unchanged** — that's the point. |
|
||||||
|
| `docs/protocol.md` | Unchanged unless you take Option B (§3.3), which adds two `AuthResult` fields and bumps to v3. |
|
||||||
|
| `docs/building.md` | Full rewrite: `dotnet build` + the native build script replace the CMake preset matrix. **Delete the "run ctest in PowerShell not Git Bash" warning** — that MinGW pathology disappears with the toolchain. |
|
||||||
|
| `docs/deployment.md` | §1.B/C update for NativeAOT publish; Docker base image changes. The zero-config promises hold. |
|
||||||
|
| `docs/roadmap.md` | Add the port as its own milestone; note the Linux-client possibility (§8.5). |
|
||||||
|
| `CLAUDE.md` | New build commands, new subsystem map, new house rules (no allocation in the audio path becomes explicit). |
|
||||||
|
| **new** `docs/api-dotnet.md` | The successor to `voicecat.h` — the versioned client API contract. |
|
||||||
|
| **new** `docs/broadcast-ring-format.md` | The frozen Swift↔C# App Group ring layout (§8.4). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Migration strategy — the actual step-by-step
|
||||||
|
|
||||||
|
The guiding principle: **the C++ tree stays working and becomes the conformance oracle.** Do
|
||||||
|
not delete anything until the C# equivalent passes the same test against it. This is only
|
||||||
|
possible because Option A (§3.2) preserves wire compatibility — which is the main reason to
|
||||||
|
choose it.
|
||||||
|
|
||||||
|
The rewrite lives under `dotnet/`; initial implementation branch: `dotnet/foundations`,
|
||||||
|
created from `cs-port`. Keep the existing schema at `core/proto/voicecat.proto` during migration.
|
||||||
|
Native packaging is deferred until the codec/audio phase rather than blocking the wire slice.
|
||||||
|
Each phase ends with a green build,
|
||||||
|
green tests, and an updated `PROGRESS.md` entry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 0 — Foundations (est. 1 week)
|
||||||
|
|
||||||
|
1. Create the solution skeleton from §2. `Directory.Build.props` with
|
||||||
|
`net10.0`, `<Nullable>enable</Nullable>`, `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`,
|
||||||
|
`<AnalysisLevel>latest-all</AnalysisLevel>`, `<InvariantGlobalization>true</InvariantGlobalization>`.
|
||||||
|
2. `native/build-native.{ps1,sh}`: build libopus 1.6, RNNoise, and the (empty for now)
|
||||||
|
miniaudio shim into `runtimes/{rid}/native/`. Vendor the sources — drop vcpkg.
|
||||||
|
3. `VoiceCat.Protocol`: add `Google.Protobuf` + `Grpc.Tools`, point at the **existing**
|
||||||
|
`core/proto/voicecat.proto`, verify generated C# types compile.
|
||||||
|
4. CI: GitHub Actions matrix building both trees (C++ and C#) side by side.
|
||||||
|
|
||||||
|
**Exit criterion:** `dotnet build` produces empty-but-real assemblies; generated protobuf
|
||||||
|
types are present; native libs land in the right RID folders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1 — Wire format, provably compatible (est. 1 week)
|
||||||
|
|
||||||
|
1. Port `envelope.cpp` (frame `[u32 len][payload]`) using `System.IO.Pipelines`.
|
||||||
|
2. Port `voice_frame.h` — the 20-byte big-endian header. Use
|
||||||
|
`BinaryPrimitives.WriteUInt64BigEndian` etc.
|
||||||
|
3. Port `SodiumMediaCrypto` → `MediaCrypto` on `System.Security.Cryptography.ChaCha20Poly1305`,
|
||||||
|
including the nonce scheme and the sliding replay window. **Preserve the RFC 3711 §3.3
|
||||||
|
ordering.**
|
||||||
|
4. Port `test_envelope`, `test_voice_frame`, `test_frame_codec`, `test_media_aead`.
|
||||||
|
5. **Generate golden vectors from the C++ build** (a throwaway C++ main dumping sealed frames
|
||||||
|
for fixed inputs) and assert the C# produces identical bytes.
|
||||||
|
|
||||||
|
**Exit criterion:** golden-vector tests green. From here on, byte-compatibility is measured,
|
||||||
|
not assumed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2 — TLS + the exporter (est. 1.5 weeks — highest-risk phase, do it early)
|
||||||
|
|
||||||
|
1. Spike first, in isolation: a BouncyCastle `TlsClientProtocol` ↔ `TlsServerProtocol`
|
||||||
|
loopback over a `Socket` pair, both calling
|
||||||
|
`ExportKeyingMaterial("voicecat media v1", ...)` and asserting the two sides agree.
|
||||||
|
2. **Then the real proof:** a C# BouncyCastle client handshaking against the **existing C++
|
||||||
|
mbedTLS server**, both exporting with the same label, and asserting the derived media keys
|
||||||
|
are identical. *This is the single most important checkpoint in the whole project.* If it
|
||||||
|
fails, stop and reconsider Option B before writing anything else.
|
||||||
|
3. Port `ServerCert`/`ServerIdentity` (`CertificateRequest` + BC Ed25519), the TOFU pin store
|
||||||
|
(`tofu_store.cpp` — a trivial text file), and cert-fingerprint pinning.
|
||||||
|
4. Port `test_tls_loopback`, `test_tofu_flow`, `test_tcp_loopback`.
|
||||||
|
|
||||||
|
**Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives
|
||||||
|
matching media keys, and pins the leaf fingerprint.
|
||||||
|
|
||||||
|
**Checkpoint (2026-09-15):** implemented nonblocking managed TLS, handshake-time
|
||||||
|
exporters, explicit certificate acceptance, persisted TOFU, and native-compatible
|
||||||
|
credentials. The C++ TLS oracle authenticates a media challenge in both directions
|
||||||
|
over an actual socket, proving exporter compatibility. Tests also cover managed
|
||||||
|
fragmented loopback, first-connect acceptance, changed-pin rejection, TLS 1.2 rejection,
|
||||||
|
close_notify/abrupt EOF, restart persistence, and import of C++ credential files.
|
||||||
|
Socket orchestration remains a transport-owner responsibility; the complete managed
|
||||||
|
server and client are later phases. See `dotnet/README.md` for the required native
|
||||||
|
interoperability test command.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3 — Codec + DSP (est. 1 week)
|
||||||
|
|
||||||
|
1. `VoiceCat.Codec`: libopus `[LibraryImport]`, `OpusEncoder`/`OpusDecoder`, the varargs-CTL
|
||||||
|
workaround (§5.5), DRED.
|
||||||
|
2. `VoiceCat.Dsp`: RNNoise binding, `EnergyVadProcessor`.
|
||||||
|
3. Port `test_opus_codec`, `test_dred_toggle`, `test_noise_suppression`.
|
||||||
|
|
||||||
|
**Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery
|
||||||
|
test green; RNNoise output matches the C++ within tolerance.
|
||||||
|
|
||||||
|
**Checkpoint (2026-09-15):** implemented `VoiceCat.Codec`, `VoiceCat.Dsp`, safe native
|
||||||
|
handles, fixed-signature C bindings, and independent desktop native staging. The native
|
||||||
|
build pins the upstream Opus 1.5.2 release/checksum (matching the actual vcpkg baseline,
|
||||||
|
despite older code comments referring to 1.6), enables DRED, and shares the existing
|
||||||
|
vendored RNNoise sources/model. Tests cover 40 rate/channel/frame-size round trips,
|
||||||
|
PLC, 40 dropped-frame DRED recovery formats, C++ denoising within one PCM unit, VAD
|
||||||
|
hang time, and zero managed allocations over 1,000 combined processing cycles.
|
||||||
|
At 8/12 kHz, DRED tests encode at 16 kHz and decode at the requested rate: this pinned
|
||||||
|
encoder's activity analysis cannot emit DRED at 8/12 kHz. These encoding configurations
|
||||||
|
are explicitly rejected. Its redundancy floor is 30 ms because two chunks are required
|
||||||
|
to emit DRED; actual redundancy remains adaptive. Desktop CI builds/stages the library
|
||||||
|
before testing. iOS static native packaging and device audio remain later phases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4 — Server (est. 3–4 weeks)
|
||||||
|
|
||||||
|
Do the server before the client: it lets you point the **existing, trusted C++ `vccli`** at
|
||||||
|
it, which is a far better test client than a half-built C# one.
|
||||||
|
|
||||||
|
**Implemented checkpoint (2026-09-15):** bounded async TLS socket orchestration,
|
||||||
|
guest/password authentication, existing SQLite account/channel import, snapshots,
|
||||||
|
unprotected channel joins, channel/private/server text, ping and disconnect events.
|
||||||
|
The existing C++ CLI authenticates and sends text through this server. Argon2id uses
|
||||||
|
the existing BouncyCastle dependency with a strict libsodium PHC parser, not a new
|
||||||
|
Konscious dependency. Native database tests prove password compatibility in both
|
||||||
|
directions without resets. SQLite uses `Microsoft.Data.Sqlite.Core` 10.0.5,
|
||||||
|
SQLitePCLRaw bundle 3.0.2 and explicitly pinned SourceGear SQLite 3.50.4.2.
|
||||||
|
See `docs/api-dotnet.md` for limits. This first checkpoint did not include UDP voice,
|
||||||
|
streams, protected joins, moderation, admin handlers or production configuration.
|
||||||
|
The subsequent voice checkpoint is described below.
|
||||||
|
|
||||||
|
**Voice checkpoint:** the managed server now advertises UDP, issues session-bound
|
||||||
|
tokens, implements voice subscription and multi-stream signaling, and relays encrypted
|
||||||
|
Opus with recipient-specific counters. The first UDP endpoint is fixed for the session;
|
||||||
|
reconnect for endpoint changes. Immutable routing snapshots separate control handlers
|
||||||
|
from the UDP crypto owner. Real-socket tests cover replay/forgery/SSRC rejection,
|
||||||
|
channel/subscription isolation, stream stop and disconnect. A native client oracle
|
||||||
|
exercises bidirectional microphone and screen audio in mono and stereo. The fan-out
|
||||||
|
core has a 50-subscriber allocation regression test; transport scheduling and the
|
||||||
|
BouncyCastle crypto fallback are excluded from its zero-allocation guarantee.
|
||||||
|
Two real C++ `vccli` processes also pass join/text/bidirectional voice tests using
|
||||||
|
finite `--test-tone-ms` external capture/playback. The transport load test delivers
|
||||||
|
all 2,500 recipient packets from a sender paced at 50 pps to 50 subscribers.
|
||||||
|
**Reaper checkpoint:** configurable 45-second idle expiry / 15-second sweep replaces
|
||||||
|
the TCP-only idle timeout. Control envelopes, authenticated voice and bound-endpoint
|
||||||
|
keepalives refresh shared monotonic activity; invalid media does not. Reaping removes
|
||||||
|
presence and media routing, and can be disabled. Tests inject a clock to cover silent
|
||||||
|
clients, UDP-only activity, forged media, single departure events and disabled expiry.
|
||||||
|
**Channel/administration checkpoint:** protected joins and channel CRUD now persist using
|
||||||
|
the native BLAKE2b password format (native verification in both directions). Permissions
|
||||||
|
gate moderation and account create/reset/delete/list. Mute/deafen/move update encrypted
|
||||||
|
routing; kick/ban retire media and emit one reason-bearing departure. Guest bans use
|
||||||
|
addresses, account bans use usernames, and wire milliseconds convert to database seconds.
|
||||||
|
Temporary-channel permission only creates temporary channels and only administrators
|
||||||
|
grant permissions; these deliberately tighten native policy. Tree validation and Lobby
|
||||||
|
protection prevent invalid mutations. Existing streams stop on channel edits/moves/deletion.
|
||||||
|
The C++ CLI creates protected channels and administers accounts against the managed server;
|
||||||
|
its channel argument lifetimes and default audio config were corrected. See api-dotnet.md
|
||||||
|
for limits, persistence and policy differences. Production configuration/publishing and
|
||||||
|
the remaining server readiness checks still precede Phase 4 completion.
|
||||||
|
|
||||||
|
1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry.
|
||||||
|
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
|
||||||
|
question here** (§4).
|
||||||
|
3. `MediaRelay` — the allocation-free SFU fan-out.
|
||||||
|
4. Keepalive reaper, moderation/admin handlers.
|
||||||
|
5. Port `test_m1_integration`, `test_m5_*`, `test_disconnect_left`, `test_reaper_timeout`.
|
||||||
|
|
||||||
|
**Exit criterion:** ▶ **C++ `vccli` connects to the C# server, authenticates, joins a
|
||||||
|
channel, sends text, and exchanges voice with a second C++ `vccli`.** That is the M1+M2 exit
|
||||||
|
criterion from `roadmap.md`, re-proven against the new server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5 — Audio engine (est. 4–5 weeks — the hardest phase)
|
||||||
|
|
||||||
|
1. Write and validate `voicecat_audio_shim.c` standalone (a C test that loops mic→speaker
|
||||||
|
through the rings, no .NET involved).
|
||||||
|
2. `VoiceCat.Audio`: device enumeration, the allocation-free jitter buffer (§5.4), per-ssrc
|
||||||
|
decode with the **DRED → FEC → PLC** ladder, per-stream NR/gain/mute, the mixer, level
|
||||||
|
meters, talk-state edge detection.
|
||||||
|
3. The dedicated mixer thread + capture thread.
|
||||||
|
4. External feed/tap/mixed-sink — now the *normal* path, not special modes.
|
||||||
|
5. Port `test_jitter_depth`, `test_external_pcm`, `test_external_playback`,
|
||||||
|
`test_channel_samplerate`, `test_plc_cap`, `test_recv_noise_reduction`,
|
||||||
|
`test_frame_ms_reframe`.
|
||||||
|
6. **Add the allocation-regression test.**
|
||||||
|
|
||||||
|
**Exit criterion:** `test_jitter_depth`'s bounded-depth invariant holds; zero allocations per
|
||||||
|
mix cycle; a manual listen test on Windows and macOS with no audible glitching over 10
|
||||||
|
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)
|
||||||
|
|
||||||
|
1. `VoiceCatClient`: connect/TOFU/auth state machine, request/response correlation via
|
||||||
|
`TaskCompletionSource`, channel/user/stream snapshots, stream lifecycle, reframing, the
|
||||||
|
send path, the event stream.
|
||||||
|
2. `VoiceCat.Cli` — the `vccli` replacement, plus `voicecat-admin`.
|
||||||
|
3. Port `test_m2_voice`, `test_m3_multistream`, `test_vad_ptt_devices`; rewrite the two ABI
|
||||||
|
tests as API-shape tests.
|
||||||
|
|
||||||
|
**Exit criterion:** ▶ **Two C# `vccli` instances hold a multi-channel voice + text
|
||||||
|
conversation through the C# server**, and a C# `vccli` interoperates with a C++ `vccli` on
|
||||||
|
the same server. This is the full M0–M3 criterion re-proven end to end.
|
||||||
|
|
||||||
|
**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)
|
||||||
|
|
||||||
|
Per §8.1. Ship this first of the three GUIs — it's the cheapest and it validates the C# API
|
||||||
|
shape against a real, complete UI before you commit to two rewrites.
|
||||||
|
|
||||||
|
**Exit criterion:** feature parity with the current WinForms build, NVDA smoke-tested.
|
||||||
|
|
||||||
|
**Checkpoint (2026-09-16):** the shipped WinForms project references `VoiceCat.Managed`, not
|
||||||
|
the P/Invoke core. The compatibility facade preserves UI-thread event pumping and stable
|
||||||
|
capture IDs while delegating all protocol and audio state to the idiomatic managed projects.
|
||||||
|
Channel moves automatically renegotiate active streams. A published build passed real WASAPI
|
||||||
|
capture/playback and form-startup smoke tests and contains `voicecat_media.dll` but no
|
||||||
|
`voicecat.dll`. Automated text, bidirectional PCM voice, multi-frame audio and stream-move
|
||||||
|
tests pass. NVDA and the manual endurance/listen pass remain before the Phase 7 exit criterion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 8 — macOS client (est. 4–5 weeks)
|
||||||
|
|
||||||
|
Per §8.2. AppKit port, ScreenCaptureKit per-app audio selection, VoiceOver parity.
|
||||||
|
|
||||||
|
**Exit criterion:** feature parity with `VoiceCatMac`, VoiceOver smoke-tested, notarized
|
||||||
|
build produced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 9 — iOS client (est. 5–7 weeks)
|
||||||
|
|
||||||
|
Per §8.3/§8.4. UIKit rewrite, `AVAudioSession` router port, `AVAudioEngine` VPIO path, and
|
||||||
|
the C# `BroadcastAudioPump` reading the **unchanged Swift** extension's ring.
|
||||||
|
|
||||||
|
**Exit criterion:** feature parity with `VoiceCatiOS`; screen-audio sharing works with the
|
||||||
|
Swift extension untouched; A2DP/stereo/VPIO preset matrix re-verified (this is where the
|
||||||
|
known stereo-A2DP class of bug lives — re-test it explicitly).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 10 — Cutover (est. 1–2 weeks)
|
||||||
|
|
||||||
|
1. Run both trees in CI for one full release cycle.
|
||||||
|
2. Delete `core/`, `server/`, `tools/`, `clients/apple/` (except `VoiceCatBroadcast/` and
|
||||||
|
`Shared/BroadcastAudioRing.swift`), `vcpkg/`, `CMakePresets.json`, root `CMakeLists.txt`.
|
||||||
|
3. Update every doc per §10.
|
||||||
|
4. Tag the last C++ commit so the oracle stays reachable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.5 Total estimate
|
||||||
|
|
||||||
|
**~7–9 months of focused single-developer work**, front-loaded with risk (Phase 2) and
|
||||||
|
back-loaded with volume (Phases 8–9). The server + core (Phases 0–6) is roughly 4 months and
|
||||||
|
is the part that removes the most complexity; the three GUIs are roughly half the calendar
|
||||||
|
time and almost none of the difficulty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Risk register
|
||||||
|
|
||||||
|
| # | Risk | Severity | Mitigation |
|
||||||
|
|---|------|----------|------------|
|
||||||
|
| 1 | **BouncyCastle's exporter doesn't interoperate with mbedTLS's** | 🔴 Critical | Prove it in Phase 2 step 2, before any other work depends on it. Both implement RFC 8446 §7.5, so it should — but *verify*, don't assume. Fallback: Option B (§3.3). |
|
||||||
|
| 2 | **GC pauses cause audio glitches** | 🔴 High | Native shim owns the RT thread (§5.2); ~120 ms ring; allocation-regression test; `SustainedLowLatency`. This is the design's whole answer. |
|
||||||
|
| 3 | **iOS audio regressions under Mono AOT** | 🟠 Medium-High | The iOS audio path is already the most delicate part of the product (see the A2DP/VPIO invariants in `voice.md` §8). Re-test the full preset × route matrix. Do not port the invariants "roughly". |
|
||||||
|
| 4 | **Argon2id hashes don't verify → existing accounts locked out** | 🟠 Medium | Decide in Phase 4. Preferred: parse libsodium's PHC string and pass m/t/p to Konscious; verify against real hashes from an existing `voicecat.db` *before* writing the rest of `Db`. |
|
||||||
|
| 5 | **SFU relay throughput regression** | 🟠 Medium | Benchmark early (Phase 4): N=50 subscribers × 50 pps. Allocation-free `SocketAddress` receive + pooled buffers. .NET's socket layer is good; this should be fine, but measure. |
|
||||||
|
| 6 | **`ChaCha20Poly1305.IsSupported == false`** on some target | 🟡 Low | Startup check + BouncyCastle fallback. One-line risk. |
|
||||||
|
| 7 | **`opus_encoder_ctl` varargs breaks on a future ABI** | 🟡 Low | Only single-`int` CTLs are used; document it, add a test that exercises every CTL used. |
|
||||||
|
| 8 | **NativeAOT trimming breaks protobuf/SQLite reflection** | 🟡 Low | All three are AOT-tested upstream. Add an AOT-published smoke test to CI from Phase 4. |
|
||||||
|
| 9 | **macOS/iOS bindings lag a new Xcode** | 🟡 Low | dotnet/macios tracks Xcode closely (bindings exist through Xcode 26). Pin the workload version. |
|
||||||
|
| 10 | **Scope creep — "while we're rewriting, let's also…"** | 🟠 Medium | The port is a *translation*. The API-shape improvements in §6.1 are the only sanctioned redesign. Everything else goes in `roadmap.md`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. What you gain
|
||||||
|
|
||||||
|
Worth being explicit, since this is 7+ months:
|
||||||
|
|
||||||
|
- **One language, one toolchain, one debugger.** No more CMake presets, vcpkg triplets,
|
||||||
|
MinGW-vs-PowerShell execution pathologies, XCFramework fat-static-lib packaging, or a C ABI
|
||||||
|
that has to be hand-mirrored into both Swift and C#.
|
||||||
|
- **Three UI codebases instead of three UI codebases *plus* a core plus two binding layers.**
|
||||||
|
The `VoiceCat.Interop` P/Invoke layer, the `VoiceCatCore` Swift wrapper, the module map, and
|
||||||
|
the whole `voicecat.h` ABI surface all cease to exist.
|
||||||
|
- **Better API.** `await client.JoinChannelAsync()` instead of "call this, then wait for
|
||||||
|
`VC_EVENT_JOIN_RESULT`, and hope it's yours."
|
||||||
|
- **Memory safety** across the entire protocol-parsing surface — the part most exposed to
|
||||||
|
hostile input.
|
||||||
|
- **8 native dependencies → 3**, each small and vendored.
|
||||||
|
- **Tests that can assert things C++ couldn't**, notably zero-allocation invariants.
|
||||||
|
|
||||||
|
And what you keep: the protocol, the wire format, the security model, the audio design, the
|
||||||
|
accessibility-first UI toolkit choices, and every single one of the 29 behavioural tests.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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" }
|
||||||
@@ -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."
|
||||||
@@ -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.'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"sdk": { "version": "10.0.203", "rollForward": "latestFeature" }
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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/
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(); }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(); }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user