Add managed codec DSP and initial control server

This commit is contained in:
2026-09-15 22:51:33 +02:00
parent 2df79cdd4c
commit 4067bab7c2
52 changed files with 2503 additions and 20 deletions
+11 -3
View File
@@ -2,9 +2,9 @@ name: .NET port
on: on:
push: push:
paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml'] paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
pull_request: pull_request:
paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml'] paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -21,6 +21,9 @@ jobs:
global-json-file: dotnet/global.json global-json-file: dotnet/global.json
cache: true cache: true
cache-dependency-path: dotnet/**/packages.lock.json 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 restore dotnet/VoiceCat.slnx --locked-mode
- run: dotnet build dotnet/VoiceCat.slnx -c Release --no-restore - run: dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
- run: dotnet test dotnet/VoiceCat.slnx -c Release --no-build - run: dotnet test dotnet/VoiceCat.slnx -c Release --no-build
@@ -52,5 +55,10 @@ jobs:
ctest --preset dev ctest --preset dev
./build/dev/bin/voicecat-dotnet-oracle build/dev/cpp-wire.json ./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 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 dotnet restore dotnet/VoiceCat.slnx --locked-mode
VOICECAT_TLS_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-tls-oracle" dotnet test dotnet/VoiceCat.slnx -c Release --no-restore VOICECAT_TLS_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-tls-oracle" VOICECAT_DATABASE_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-database-oracle" VOICECAT_VCCLI="$PWD/build/dev/bin/vccli" dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
+1
View File
@@ -2,6 +2,7 @@
/dotnet/**/bin/ /dotnet/**/bin/
/dotnet/**/obj/ /dotnet/**/obj/
/dotnet/**/TestResults/ /dotnet/**/TestResults/
/dotnet/artifacts/
/build/ /build/
/out/ /out/
+3 -2
View File
@@ -17,7 +17,7 @@ and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](
on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23). on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23).
> See [`PROGRESS.md`](PROGRESS.md). > See [`PROGRESS.md`](PROGRESS.md).
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control) VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). TLS over TCP (control)
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives + UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
native clients (Swift on macOS/iOS, C# on Windows) and the server. native clients (Swift on macOS/iOS, C# on Windows) and the server.
@@ -25,10 +25,11 @@ 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 initial wire/crypto slice The .NET rewrite lives under `dotnet/`. Build and test its wire/crypto, TLS, codec/DSP, and initial control server slices
alongside the existing C++ tree: alongside the existing C++ tree:
```powershell ```powershell
./dotnet/build-native.ps1 # CMake + C compiler; pinned Opus with DRED + RNNoise
dotnet restore dotnet/VoiceCat.slnx --locked-mode dotnet restore dotnet/VoiceCat.slnx --locked-mode
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
dotnet test dotnet/VoiceCat.slnx -c Release --no-build dotnet test dotnet/VoiceCat.slnx -c Release --no-build
+5
View File
@@ -49,6 +49,11 @@ 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) option(VOICECAT_BUILD_DOTNET_ORACLE "Build the .NET port conformance fixture generator" OFF)
if(VOICECAT_BUILD_DOTNET_ORACLE) if(VOICECAT_BUILD_DOTNET_ORACLE)
add_subdirectory(dotnet/oracle) add_subdirectory(dotnet/oracle)
+133
View File
@@ -10,6 +10,139 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ Where we left off / next action
- **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 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.
- **Server 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
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
./dotnet/check-licenses.ps1
```
Last results: **142/142 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.
**Next behavior to implement:** read `docs/protocol.md`, `docs/security.md` and the
existing UDP binding/relay/session handlers. Write a real-client test for authenticated
UDP binding and encrypted relay between two clients, preserving SSRC/timestamp/flags
and encoded Opus while resealing with each recipient's directional key/counter.
Then implement binding tokens, stream announce/stop/subscription and channel routing.
Reject unauthenticated/replayed media and verify channel/subscription isolation.
Never decode audio on the SFU. Phase 4 exits only when two existing C++ clients can
exchange voice through the managed server as well as authenticate, join and chat.
- **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 - **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 `b76181d` pushed to `origin/dotnet/foundations`. Added a nonblocking managed TLS 1.3
session with certificate acceptance gate and directional media factories. Managed session with certificate acceptance gate and directional media factories. Managed
+96
View File
@@ -115,3 +115,99 @@ On Unix new files use owner read/write permissions; Windows inherits directory A
The credential directory must have one provisioning owner. PEM strings and crypto The credential directory must have one provisioning owner. PEM strings and crypto
library internal copies are managed memory; owned-array clearing does not promise library internal copies are managed memory; owned-array clearing does not promise
erasure of every runtime/library copy. 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 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; completed TLS connections have a 60-second receive-idle timeout.
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 channel
joins and all admin/moderation handlers are pending. No UDP port or media features
are advertised; voice subscription explicitly fails until the SFU is implemented.
`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. Account provisioning currently uses this API or the
existing native administration path; 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.
+3 -1
View File
@@ -2,9 +2,11 @@
## .NET rewrite ## .NET rewrite
The initial managed wire/crypto slice is under `dotnet/`, targeting .NET 10. From the root: 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 ```powershell
./dotnet/build-native.ps1
dotnet restore dotnet/VoiceCat.slnx --locked-mode dotnet restore dotnet/VoiceCat.slnx --locked-mode
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
dotnet test dotnet/VoiceCat.slnx -c Release --no-build dotnet test dotnet/VoiceCat.slnx -c Release --no-build
+35 -9
View File
@@ -1,8 +1,10 @@
# Porting VoiceCat to pure .NET / C# # Porting VoiceCat to pure .NET / C#
**Status:** wire/media crypto and TLS/exporter foundations implemented under `dotnet/`, **Status:** wire/media crypto and TLS/exporter foundations implemented under `dotnet/`,
including C++ interoperability, persisted TOFU, and compatible server credentials. including C++ interoperability, persisted TOFU, compatible server credentials,
Codec/audio, managed server/client state, and UI phases remain planned. codec/DSP wrappers, desktop native staging, and the initial managed TLS control server.
Phase 4 remains in progress; UDP relay, 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. See `dotnet/README.md`, `docs/api-dotnet.md`, and `PROGRESS.md` for verification and next steps.
**Target runtime:** .NET 10 LTS (in-service to Nov 2028), with .NET 11 as the follow-on. **Target runtime:** .NET 10 LTS (in-service to Nov 2028), with .NET 11 as the follow-on.
**Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the **Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the
@@ -233,7 +235,7 @@ has been carrying.
| TLS 1.3 | mbedTLS | **BouncyCastle `Org.BouncyCastle.Tls`** | MIT | See §3. **Not `SslStream`.** | | TLS 1.3 | mbedTLS | **BouncyCastle `Org.BouncyCastle.Tls`** | MIT | See §3. **Not `SslStream`.** |
| Media AEAD | libsodium ChaCha20-Poly1305 | **`System.Security.Cryptography.ChaCha20Poly1305`** | built-in | ⚠️ Check `ChaCha20Poly1305.IsSupported` at startup — it is OS-backed (Windows 10 1903+ / OpenSSL 1.1+). Fall back to BouncyCastle's `ChaCha20Poly1305` if false. Same 12-byte nonce, 16-byte tag → identical wire bytes. | | Media AEAD | libsodium ChaCha20-Poly1305 | **`System.Security.Cryptography.ChaCha20Poly1305`** | built-in | ⚠️ Check `ChaCha20Poly1305.IsSupported` at startup — it is OS-backed (Windows 10 1903+ / OpenSSL 1.1+). Fall back to BouncyCastle's `ChaCha20Poly1305` if false. Same 12-byte nonce, 16-byte tag → identical wire bytes. |
| Anti-replay window | hand-rolled 64-bit | port verbatim | — | ~40 lines. Keep the RFC 3711 §3.3 ordering (replay-check → authenticate → *then* advance). This ordering is load-bearing; `test_media_aead.cpp` covers it. | | Anti-replay window | hand-rolled 64-bit | port verbatim | — | ~40 lines. Keep the RFC 3711 §3.3 ordering (replay-check → authenticate → *then* advance). This ordering is load-bearing; `test_media_aead.cpp` covers it. |
| Argon2id | libsodium `crypto_pwhash` | **`Konscious.Security.Cryptography.Argon2`** | MIT | Pure managed. ⚠️ **Existing password hashes will not verify** — libsodium emits `$argon2id$...` PHC strings with its own tuned m/t/p. Either implement a PHC-string parser and feed those params to Konscious (doable, recommended), or force a password reset on migration. Decide early; `db.cpp` migration depends on it. | | Argon2id | libsodium `crypto_pwhash` | **BouncyCastle `Argon2BytesGenerator`** | MIT | Implemented with a strict libsodium PHC parser and original costs; native database tests prove existing-account import and managed-account verification by C++. See `docs/api-dotnet.md` for cost bounds. |
| BLAKE2b (channel passwords) | libsodium `crypto_generichash` | **`Blake2Fast`** (MIT) or BouncyCastle `Blake2bDigest` | MIT | Salted BLAKE2b-256, must produce identical digests to keep existing channel passwords working. Blake2Fast is SIMD and fast enough for the net thread, preserving the reason BLAKE2b was chosen over Argon2 here. | | BLAKE2b (channel passwords) | libsodium `crypto_generichash` | **`Blake2Fast`** (MIT) or BouncyCastle `Blake2bDigest` | MIT | Salted BLAKE2b-256, must produce identical digests to keep existing channel passwords working. Blake2Fast is SIMD and fast enough for the net thread, preserving the reason BLAKE2b was chosen over Argon2 here. |
| Ed25519 identity | libsodium | **BouncyCastle `Ed25519Signer`** | MIT | ⚠️ **Not in .NET 10.** [dotnet/runtime#63174](https://github.com/dotnet/runtime/issues/63174) is api-approved but milestoned **11.0.0**. Since Option A already pulls in BouncyCastle, this is free. | | Ed25519 identity | libsodium | **BouncyCastle `Ed25519Signer`** | MIT | ⚠️ **Not in .NET 10.** [dotnet/runtime#63174](https://github.com/dotnet/runtime/issues/63174) is api-approved but milestoned **11.0.0**. Since Option A already pulls in BouncyCastle, this is free. |
| Self-signed cert gen | mbedTLS x509write | **`CertificateRequest.CreateSelfSigned`** | built-in | Much nicer than the C++ version. ECDSA-P256, same as today. Add the Ed25519 SAN (§3.4). | | Self-signed cert gen | mbedTLS x509write | **`CertificateRequest.CreateSelfSigned`** | built-in | Much nicer than the C++ version. ECDSA-P256, same as today. Add the Ed25519 SAN (§3.4). |
@@ -243,7 +245,7 @@ has been carrying.
| Audio capture/playback | miniaudio | **P/Invoke miniaudio via a shim** | MIT-0/PD | **Keep native**, see §5.2. Managed alternatives exist ([SoundFlow](https://www.nuget.org/packages/SoundFlow), [MiniaudioSharp](https://www.nuget.org/packages/MiniaudioSharp), NAudio/CSCore for Windows-only) but auto-generated bindings marshal the callback into managed code, which is exactly what you must avoid (§5.1). Write the shim yourself. | | Audio capture/playback | miniaudio | **P/Invoke miniaudio via a shim** | MIT-0/PD | **Keep native**, see §5.2. Managed alternatives exist ([SoundFlow](https://www.nuget.org/packages/SoundFlow), [MiniaudioSharp](https://www.nuget.org/packages/MiniaudioSharp), NAudio/CSCore for Windows-only) but auto-generated bindings marshal the callback into managed code, which is exactly what you must avoid (§5.1). Write the shim yourself. |
| Energy VAD | hand-rolled | port verbatim | — | ~60 lines. Trivial. | | Energy VAD | hand-rolled | port verbatim | — | ~60 lines. Trivial. |
| Protobuf | protobuf-lite (C++) | **`Google.Protobuf`** + `Grpc.Tools` | BSD | ⚠️ Reference `Grpc.Tools` for the `protoc` MSBuild integration even though there is no gRPC here — it is the standard way to codegen `.proto` in a `.csproj`. `<Protobuf Include="../../proto/voicecat.proto" GrpcServices="None" />`. The `.proto` needs **zero changes**. | | Protobuf | protobuf-lite (C++) | **`Google.Protobuf`** + `Grpc.Tools` | BSD | ⚠️ Reference `Grpc.Tools` for the `protoc` MSBuild integration even though there is no gRPC here — it is the standard way to codegen `.proto` in a `.csproj`. `<Protobuf Include="../../proto/voicecat.proto" GrpcServices="None" />`. The `.proto` needs **zero changes**. |
| SQLite | sqlite3 | **`Microsoft.Data.Sqlite`** | MIT | Bundles SQLitePCLRaw; works with NativeAOT. Same schema, same file — an existing `voicecat.db` opens unchanged. | | SQLite | sqlite3 | **`Microsoft.Data.Sqlite.Core` + SQLitePCLRaw** | MIT / public domain | Implemented with provider 10.0.5, bundle 3.0.2 and pinned SQLite 3.50.4.2. Same schema/file; native database import is tested. NativeAOT publishing remains to be validated. |
| Logging | spdlog | **`Microsoft.Extensions.Logging`** (+ Serilog console sink) | MIT/Apache | Use `LoggerMessage` source generators on any path near the hot loop. Never log from an audio path. | | Logging | spdlog | **`Microsoft.Extensions.Logging`** (+ Serilog console sink) | MIT/Apache | Use `LoggerMessage` source generators on any path near the hot loop. Never log from an audio path. |
| Server config | `server.toml` | **`Tomlyn`** (MIT) or switch to JSON + `System.Text.Json` | MIT | Tomlyn keeps `server.toml` compatible; recommended, since operator-facing config shouldn't churn. | | Server config | `server.toml` | **`Tomlyn`** (MIT) or switch to JSON + `System.Text.Json` | MIT | Tomlyn keeps `server.toml` compatible; recommended, since operator-facing config shouldn't churn. |
| CLI arg parsing | hand-rolled | **`System.CommandLine`** | MIT | For `VoiceCat.Cli` and the server. | | CLI arg parsing | hand-rolled | **`System.CommandLine`** | MIT | For `VoiceCat.Cli` and the server. |
@@ -356,11 +358,10 @@ internal static partial int opus_encode(IntPtr st, ReadOnlySpan<short> pcm, int
Span<byte> data, int maxDataBytes); Span<byte> data, int maxDataBytes);
``` ```
- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. Declare one - `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. The implemented
overload per argument shape (`int`, `out int`) with `EntryPoint = "opus_encoder_ctl"`. This desktop binding uses fixed C entry points in `dotnet/native/media.c`; C calls the
works on all the ABIs we target (x64 SysV, x64 Win, arm64 AAPCS) because all the CTLs we use varargs function with the correct ABI. This also handles Apple arm64's different
take a single `int`/`int*`. **Note this explicitly in code comments** — it's a real varargs calling convention. Only whitelisted single-int controls are accepted.
portability caveat if a future CTL takes a different shape.
- DRED (`opus_dred_alloc`, `opus_dred_parse`, `opus_decoder_dred_decode`) binds the same way. - DRED (`opus_dred_alloc`, `opus_dred_parse`, `opus_decoder_dred_decode`) binds the same way.
Guard with a runtime feature check as `opus_codec.cpp` does today. Guard with a runtime feature check as `opus_codec.cpp` does today.
- **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link - **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link
@@ -700,6 +701,19 @@ interoperability test command.
**Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery **Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery
test green; RNNoise output matches the C++ within tolerance. test green; RNNoise output matches the C++ within tolerance.
**Checkpoint (2026-09-15):** implemented `VoiceCat.Codec`, `VoiceCat.Dsp`, safe native
handles, fixed-signature C bindings, and independent desktop native staging. The native
build pins the upstream Opus 1.5.2 release/checksum (matching the actual vcpkg baseline,
despite older code comments referring to 1.6), enables DRED, and shares the existing
vendored RNNoise sources/model. Tests cover 40 rate/channel/frame-size round trips,
PLC, 40 dropped-frame DRED recovery formats, C++ denoising within one PCM unit, VAD
hang time, and zero managed allocations over 1,000 combined processing cycles.
At 8/12 kHz, DRED tests encode at 16 kHz and decode at the requested rate: this pinned
encoder's activity analysis cannot emit DRED at 8/12 kHz. These encoding configurations
are explicitly rejected. Its redundancy floor is 30 ms because two chunks are required
to emit DRED; actual redundancy remains adaptive. Desktop CI builds/stages the library
before testing. iOS static native packaging and device audio remain later phases.
--- ---
### Phase 4 — Server (est. 34 weeks) ### Phase 4 — Server (est. 34 weeks)
@@ -707,6 +721,18 @@ test green; RNNoise output matches the C++ within tolerance.
Do the server before the client: it lets you point the **existing, trusted C++ `vccli`** at Do the server before the client: it lets you point the **existing, trusted C++ `vccli`** at
it, which is a far better test client than a half-built C# one. it, which is a far better test client than a half-built C# one.
**Implemented checkpoint (2026-09-15):** bounded async TLS socket orchestration,
guest/password authentication, existing SQLite account/channel import, snapshots,
unprotected channel joins, channel/private/server text, ping and disconnect events.
The existing C++ CLI authenticates and sends text through this server. Argon2id uses
the existing BouncyCastle dependency with a strict libsodium PHC parser, not a new
Konscious dependency. Native database tests prove password compatibility in both
directions without resets. SQLite uses `Microsoft.Data.Sqlite.Core` 10.0.5,
SQLitePCLRaw bundle 3.0.2 and explicitly pinned SourceGear SQLite 3.50.4.2.
See `docs/api-dotnet.md` for limits. UDP voice, streams, protected joins, moderation,
admin handlers and production configuration remain pending. The exit criterion
below has not yet been met.
1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry. 1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry.
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat 2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
question here** (§4). question here** (§4).
+13
View File
@@ -0,0 +1,13 @@
<Project>
<PropertyGroup>
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == '' and '$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</VoiceCatNativeRid>
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</VoiceCatNativeRid>
<VoiceCatNativeDirectory Condition="'$(VoiceCatNativeDirectory)' == ''">$(MSBuildThisFileDirectory)artifacts/native/runtimes/$(VoiceCatNativeRid)/native</VoiceCatNativeDirectory>
</PropertyGroup>
<ItemGroup>
<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>
+75 -5
View File
@@ -1,10 +1,41 @@
# VoiceCat .NET rewrite # VoiceCat .NET rewrite
The first slice targets .NET 10: protobuf, control framing, voice headers, and media The first slice targets .NET 10: protobuf, control framing, voice headers, and media
encryption, TLS 1.3, persisted TOFU pins, and server credentials. Server/client state, encryption, TLS 1.3, persisted TOFU pins, server credentials, and an initial managed
audio, and UI migration are next. The existing control server. Media relay, client state, audio, and UI migration are next. The existing
C++ implementation remains the conformance oracle. 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: From the repository root:
```powershell ```powershell
@@ -47,6 +78,16 @@ and 65536. Keys contain bytes 031; payload bytes count upward from zero. The
20-byte header has type 1, marker flag, codec 0, SSRC `0xcafebabe`, timestamp 960. 20-byte header has type 1, marker flag, codec 0, SSRC `0xcafebabe`, timestamp 960.
Both managed crypto backends must match these bytes. 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 ## TLS interoperability
The optional TLS oracle uses the existing mbedTLS context and libsodium media crypto. The optional TLS oracle uses the existing mbedTLS context and libsodium media crypto.
@@ -65,7 +106,36 @@ 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 conformance job requires the native test. See `docs/api-dotnet.md` for ownership
and certificate acceptance requirements. and certificate acceptance requirements.
## Next checkpoint ## Managed server checkpoint
Port codec/DSP wrappers and their native packaging per Phase 3 of the porting plan. Run the TLS control server on loopback (optional arguments: data directory, TCP port):
The managed server follows, tested first with the existing C++ CLI.
```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.
Phase 4 remains in progress: UDP/SFU relay, streams, protected channel joins,
administration, moderation, and production configuration are the next server work.
+3
View File
@@ -2,6 +2,9 @@
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" /> <Project Path="src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<Project Path="src/VoiceCat.Crypto/VoiceCat.Crypto.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" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" /> <Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
+18
View File
@@ -0,0 +1,18 @@
param(
[string]$BuildDirectory = "$PSScriptRoot/artifacts/native-build",
[string]$RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier,
[string]$Generator,
[string]$CCompiler
)
$ErrorActionPreference = 'Stop'
$configure = @('-S', "$PSScriptRoot/native", '-B', $BuildDirectory,
'-DCMAKE_BUILD_TYPE=Release', "-DVOICECAT_DOTNET_RID=$RuntimeIdentifier")
if ($Generator) { $configure += @('-G', $Generator) }
if ($CCompiler) { $configure += "-DCMAKE_C_COMPILER=$CCompiler" }
& cmake @configure
if ($LASTEXITCODE) { throw "Native configure failed: $LASTEXITCODE" }
& cmake --build $BuildDirectory --config Release --target voicecat_media --parallel 2
if ($LASTEXITCODE) { throw "Native build failed: $LASTEXITCODE" }
& cmake --install $BuildDirectory --config Release --component DotnetMedia --prefix "$PSScriptRoot/artifacts/native"
if ($LASTEXITCODE) { throw "Native staging failed: $LASTEXITCODE" }
+4
View File
@@ -20,6 +20,10 @@ foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter packages
[xml]$spec = Get-Content -Raw -LiteralPath $nuspec [xml]$spec = Get-Content -Raw -LiteralPath $nuspec
$license = $spec.package.metadata.license $license = $spec.package.metadata.license
if ($license.type -eq 'expression' -and $allowed -contains $license.InnerText) { continue } 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). # This legacy pinned package predates NuGet license expressions (Apache-2.0).
if ($id -eq 'xunit.abstractions' -and $version -eq '2.0.3' -and 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 } $spec.package.metadata.licenseUrl -eq 'https://raw.githubusercontent.com/xunit/xunit/master/license.txt') { continue }
+13
View File
@@ -0,0 +1,13 @@
param(
[Parameter(Mandatory)][string]$ExpectedPath,
[Parameter(Mandatory)][string]$ActualPath
)
$ErrorActionPreference = 'Stop'
$expected = (Get-Content -Raw -LiteralPath $ExpectedPath | ConvertFrom-Json).samples
$actual = (Get-Content -Raw -LiteralPath $ActualPath | ConvertFrom-Json).samples
if ($expected.Count -ne 960 -or $actual.Count -ne $expected.Count) { throw 'DSP fixture sample counts differ.' }
for ($i = 0; $i -lt $expected.Count; $i++) {
if ([Math]::Abs($expected[$i] - $actual[$i]) -gt 1) { throw "DSP fixture differs at sample $i." }
}
Write-Output 'C++ DSP fixture matches within one PCM unit.'
+101
View File
@@ -0,0 +1,101 @@
cmake_minimum_required(VERSION 3.24)
project(VoiceCatMedia LANGUAGES C)
if(MSVC)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
set(OPUS_STATIC_RUNTIME ON CACHE BOOL "" FORCE)
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
message(FATAL_ERROR "iOS static NativeReference packaging belongs to the later client phase.")
endif()
if(NOT TARGET Opus::opus)
set(bundled_default OFF)
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
set(bundled_default ON)
endif()
option(VOICECAT_BUNDLED_OPUS "Build pinned Opus with DRED support" ${bundled_default})
if(VOICECAT_BUNDLED_OPUS)
include(FetchContent)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(OPUS_DRED ON CACHE BOOL "" FORCE)
set(OPUS_DEEP_PLC ON CACHE BOOL "" FORCE)
set(OPUS_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
set(OPUS_BUILD_TESTING OFF CACHE BOOL "" FORCE)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
FetchContent_Declare(opus
URL https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
URL_HASH SHA256=65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1
TIMEOUT 60
INACTIVITY_TIMEOUT 30
DOWNLOAD_EXTRACT_TIMESTAMP TRUE)
FetchContent_MakeAvailable(opus)
set(VOICECAT_OPUS_LICENSE "${opus_SOURCE_DIR}/COPYING")
else()
find_package(Opus CONFIG REQUIRED)
endif()
endif()
if(NOT VOICECAT_OPUS_LICENSE)
find_file(VOICECAT_OPUS_LICENSE NAMES copyright COPYING HINTS "${Opus_DIR}" NO_DEFAULT_PATH)
endif()
if(NOT VOICECAT_OPUS_LICENSE)
message(FATAL_ERROR "Set VOICECAT_OPUS_LICENSE to the imported Opus copyright file for native staging.")
endif()
set(RNNOISE_DIR "${CMAKE_CURRENT_LIST_DIR}/../../third_party/rnnoise")
if(NOT TARGET rnnoise)
add_library(rnnoise STATIC
${RNNOISE_DIR}/src/denoise.c ${RNNOISE_DIR}/src/rnn.c
${RNNOISE_DIR}/src/pitch.c ${RNNOISE_DIR}/src/kiss_fft.c
${RNNOISE_DIR}/src/celt_lpc.c ${RNNOISE_DIR}/src/nnet.c
${RNNOISE_DIR}/src/nnet_default.c ${RNNOISE_DIR}/src/parse_lpcnet_weights.c
${RNNOISE_DIR}/src/rnnoise_data.c ${RNNOISE_DIR}/src/rnnoise_tables.c)
target_include_directories(rnnoise PUBLIC ${RNNOISE_DIR}/include PRIVATE ${RNNOISE_DIR}/src)
target_compile_definitions(rnnoise PRIVATE DISABLE_DEBUG_FLOAT)
if(MSVC)
target_compile_definitions(rnnoise PRIVATE restrict=__restrict)
endif()
target_compile_features(rnnoise PRIVATE c_std_11)
set_target_properties(rnnoise PROPERTIES POSITION_INDEPENDENT_CODE ON C_VISIBILITY_PRESET hidden)
endif()
add_library(voicecat_media SHARED media.c)
target_compile_features(voicecat_media PRIVATE c_std_99)
target_link_libraries(voicecat_media PRIVATE Opus::opus rnnoise)
set_target_properties(voicecat_media PROPERTIES C_VISIBILITY_PRESET hidden)
if(WIN32)
set_target_properties(voicecat_media PROPERTIES PREFIX "")
endif()
if(NOT WIN32)
target_link_libraries(voicecat_media PRIVATE m)
elseif(MINGW)
target_link_options(voicecat_media PRIVATE -static-libgcc -static)
endif()
if(NOT VOICECAT_DOTNET_RID)
string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" architecture)
if(architecture MATCHES "^(amd64|x86_64)$")
set(architecture x64)
elseif(architecture MATCHES "^(aarch64|arm64)$")
set(architecture arm64)
else()
message(FATAL_ERROR "Set VOICECAT_DOTNET_RID for architecture ${architecture}")
endif()
if(WIN32)
set(platform win)
elseif(APPLE)
set(platform osx)
else()
set(platform linux)
endif()
set(VOICECAT_DOTNET_RID "${platform}-${architecture}")
endif()
install(TARGETS voicecat_media
RUNTIME DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia
LIBRARY DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia)
install(FILES ${RNNOISE_DIR}/COPYING DESTINATION licenses RENAME RNNoise.txt COMPONENT DotnetMedia)
install(FILES ${CMAKE_CURRENT_LIST_DIR}/NOTICE.txt DESTINATION licenses COMPONENT DotnetMedia)
if(VOICECAT_OPUS_LICENSE)
install(FILES ${VOICECAT_OPUS_LICENSE} DESTINATION licenses RENAME Opus.txt COMPONENT DotnetMedia)
endif()
+12
View File
@@ -0,0 +1,12 @@
VoiceCat desktop codec/DSP bindings
Opus 1.5.2: BSD-3-Clause. See Opus.txt for copyright, license, and patent notices.
Upstream: https://opus-codec.org/
Release: https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
SHA-256: 65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1
RNNoise code: BSD-3-Clause. See RNNoise.txt.
RNNoise model weights: CC0-1.0, as recorded in third_party/README.md.
Upstream: https://github.com/xiph/rnnoise
Vendored commit: 70f1d256acd4b34a572f999a05c87bf00b67730d
CC0: https://creativecommons.org/publicdomain/zero/1.0/
+55
View File
@@ -0,0 +1,55 @@
#include <opus.h>
#include "rnnoise.h"
#ifdef _WIN32
#define VC_EXPORT __declspec(dllexport)
#else
#define VC_EXPORT __attribute__((visibility("default")))
#endif
VC_EXPORT const char *vcm_opus_version(void) { return opus_get_version_string(); }
VC_EXPORT const char *vcm_opus_error(int error) { return opus_strerror(error); }
VC_EXPORT OpusEncoder *vcm_encoder_create(int rate, int channels, int application, int *error) {
return opus_encoder_create(rate, channels, application, error);
}
VC_EXPORT void vcm_encoder_destroy(OpusEncoder *encoder) { opus_encoder_destroy(encoder); }
/* C varargs are called here, not through P/Invoke: Apple arm64 uses a distinct varargs ABI. */
VC_EXPORT int vcm_encoder_set(OpusEncoder *encoder, int request, int value) {
switch (request) {
case OPUS_SET_BITRATE_REQUEST: case OPUS_SET_MAX_BANDWIDTH_REQUEST:
case OPUS_SET_COMPLEXITY_REQUEST: case OPUS_SET_INBAND_FEC_REQUEST:
case OPUS_SET_DTX_REQUEST: case OPUS_SET_PACKET_LOSS_PERC_REQUEST:
case OPUS_SET_DRED_DURATION_REQUEST:
return opus_encoder_ctl(encoder, request, value);
default: return OPUS_BAD_ARG;
}
}
VC_EXPORT int vcm_encoder_get_dred(OpusEncoder *encoder, int *duration) {
return opus_encoder_ctl(encoder, OPUS_GET_DRED_DURATION(duration));
}
VC_EXPORT int vcm_encode(OpusEncoder *encoder, const short *pcm, int samples, unsigned char *packet, int capacity) {
return opus_encode(encoder, pcm, samples, packet, capacity);
}
VC_EXPORT OpusDecoder *vcm_decoder_create(int rate, int channels, int *error) {
return opus_decoder_create(rate, channels, error);
}
VC_EXPORT void vcm_decoder_destroy(OpusDecoder *decoder) { opus_decoder_destroy(decoder); }
VC_EXPORT int vcm_decode(OpusDecoder *decoder, const unsigned char *packet, int length, short *pcm, int samples, int fec) {
return opus_decode(decoder, packet, length, pcm, samples, fec);
}
VC_EXPORT OpusDREDDecoder *vcm_dred_decoder_create(int *error) { return opus_dred_decoder_create(error); }
VC_EXPORT void vcm_dred_decoder_destroy(OpusDREDDecoder *decoder) { opus_dred_decoder_destroy(decoder); }
VC_EXPORT OpusDRED *vcm_dred_create(int *error) { return opus_dred_alloc(error); }
VC_EXPORT void vcm_dred_destroy(OpusDRED *dred) { opus_dred_free(dred); }
VC_EXPORT int vcm_dred_parse(OpusDREDDecoder *decoder, OpusDRED *dred, const unsigned char *packet,
int length, int samples, int rate, int *end) {
return opus_dred_parse(decoder, dred, packet, length, samples, rate, end, 0);
}
VC_EXPORT int vcm_dred_decode(OpusDecoder *decoder, OpusDRED *dred, int offset, short *pcm, int samples) {
return opus_decoder_dred_decode(decoder, dred, offset, pcm, samples);
}
VC_EXPORT DenoiseState *vcm_rnnoise_create(void) { return rnnoise_create(NULL); }
VC_EXPORT void vcm_rnnoise_destroy(DenoiseState *state) { rnnoise_destroy(state); }
VC_EXPORT float vcm_rnnoise_process(DenoiseState *state, float *output, const float *input) {
return rnnoise_process_frame(state, output, input);
}
+16
View File
@@ -7,3 +7,19 @@ add_executable(voicecat-dotnet-tls-oracle tls.cpp)
target_link_libraries(voicecat-dotnet-tls-oracle PRIVATE voicecat::voicecat) target_link_libraries(voicecat-dotnet-tls-oracle PRIVATE voicecat::voicecat)
target_include_directories(voicecat-dotnet-tls-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src) target_include_directories(voicecat-dotnet-tls-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
target_compile_features(voicecat-dotnet-tls-oracle PRIVATE cxx_std_20) target_compile_features(voicecat-dotnet-tls-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()
+25
View File
@@ -0,0 +1,25 @@
#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") {
if (!database.create_account("legacy", "legacy password", true, error)) return 1;
voicecat::server::ChannelRecord lobby;
lobby.name = "Lobby";
lobby.topic = "Preserved native topic";
lobby.max_users = 7;
lobby.audio.set_sample_rate(48000);
lobby.audio.set_bitrate_bps(32000);
lobby.audio.set_frame_ms(20);
return database.create_channel(lobby, "", error) ? 0 : 1;
}
if (std::string(argv[1]) == "verify") {
auto account = database.authenticate("managed", "managed password");
return account && account->is_admin ? 0 : 1;
}
return 1;
}
+30
View File
@@ -0,0 +1,30 @@
#include "audio/apm_processor.h"
#include <cstdint>
#include <fstream>
#include <vector>
int main(int argc, char **argv) {
if (argc != 2) return 2;
auto processor = voicecat::audio::ApmProcessor::create();
if (!processor) return 1;
std::vector<int16_t> pcm(960);
uint32_t random = 0x12345678;
for (int frame = 0; frame < 200; ++frame) {
for (auto &sample : pcm) {
random ^= random << 13;
random ^= random >> 17;
random ^= random << 5;
sample = static_cast<int16_t>(static_cast<int>(random % 6001) - 3000);
}
if (!processor->process_capture(pcm.data(), static_cast<int>(pcm.size()), 48000)) return 1;
}
std::ofstream output(argv[1]);
output << "{\"samples\":[";
for (size_t i = 0; i < pcm.size(); ++i) {
if (i) output << ',';
output << pcm[i];
}
output << "]}\n";
return output ? 0 : 1;
}
+29
View File
@@ -0,0 +1,29 @@
#include <sodium.h>
#include <fstream>
#include <string>
#include <array>
static std::string base64(const unsigned char *data, size_t length) {
std::array<char, 128> output{};
sodium_bin2base64(output.data(), output.size(), data, length, sodium_base64_VARIANT_ORIGINAL_NO_PADDING);
return output.data();
}
int main(int argc, char **argv) {
if (argc != 2 || sodium_init() < 0) return 1;
std::ofstream output(argv[1]);
output << "{\"hashes\":[";
const std::array<std::string, 3> passwords{"voicecat test", "caf\xc3\xa9", std::string("a\0b", 3)};
std::array<unsigned char, 16> salt{};
for (size_t i = 0; i < salt.size(); ++i) salt[i] = static_cast<unsigned char>(i);
for (size_t i = 0; i < passwords.size(); ++i) {
std::array<unsigned char, 32> hash{};
if (crypto_pwhash(hash.data(), hash.size(), passwords[i].data(), passwords[i].size(), salt.data(), 2,
64 * 1024 * 1024, crypto_pwhash_ALG_ARGON2ID13) != 0) return 1;
if (i) output << ',';
output << "{\"passwordBase64\":\"" << base64(reinterpret_cast<const unsigned char *>(passwords[i].data()), passwords[i].size())
<< "\",\"hash\":\"$argon2id$v=19$m=65536,t=2,p=1$" << base64(salt.data(), salt.size()) << '$' << base64(hash.data(), hash.size()) << "\"}";
}
output << "]}\n";
return output ? 0 : 1;
}
@@ -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);
}
+45
View File
@@ -0,0 +1,45 @@
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 void Dispose() => handle.Dispose();
}
@@ -0,0 +1,52 @@
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 = OpusException.Check(NativeMethods.DredParse(decoder, dred, packet, nextPacket.Length,
checked(samplesPerChannel + recoveryOffset), audioDecoder.SampleRate, out _));
if (parsed == 0) return false;
OpusException.Check(NativeMethods.DredDecode(audioDecoder.Handle, dred, recoveryOffset, output, samplesPerChannel));
return true;
}
}
public void Dispose() { dred.Dispose(); decoder.Dispose(); }
}
+53
View File
@@ -0,0 +1,53 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Codec;
public sealed class OpusEncoder : IDisposable
{
private readonly OpusEncoderHandle handle;
public OpusOptions Options { get; }
public bool SupportsDeepRedundancy { get; }
public static string Version => Marshal.PtrToStringUTF8(NativeMethods.Version())!;
public OpusEncoder(OpusOptions? options = null)
{
Options = options ?? new();
Options.Validate();
handle = new(NativeMethods.EncoderCreate(Options.SampleRate, Options.Channels, (int)Options.Application, out int error));
try
{
OpusException.Check(error);
if (handle.IsInvalid) throw new OutOfMemoryException();
Set(4002, Options.Bitrate);
Set(4004, Options.MaximumBandwidthHz switch { 0 => 1105, <= 8000 => 1101, <= 12000 => 1102, <= 16000 => 1103, <= 24000 => 1104, _ => 1105 });
Set(4010, Options.Complexity);
Set(4012, Options.ForwardErrorCorrection ? 1 : 0);
Set(4016, Options.DiscontinuousTransmission ? 1 : 0);
Set(4014, Options.ExpectedPacketLossPercent);
int support = NativeMethods.EncoderGetDred(handle, out _);
if (support != -5) OpusException.Check(support);
SupportsDeepRedundancy = support == 0 && Options.SampleRate >= 16000;
if (Options.DeepRedundancy && !SupportsDeepRedundancy)
throw new NotSupportedException("DRED encoding requires a DRED-enabled libopus build and a PCM rate of at least 16 kHz.");
if (SupportsDeepRedundancy)
// Opus 1.5.2 requires two redundancy chunks; 20 ms alone cannot produce DRED.
Set(4050, Options.DeepRedundancy ? Math.Max(3, (Options.FrameDurationMilliseconds + 9) / 10) : 0);
}
catch { handle.Dispose(); throw; }
}
private void Set(int request, int value) => OpusException.Check(NativeMethods.EncoderSet(handle, request, value));
public unsafe int Encode(ReadOnlySpan<short> pcm, Span<byte> packet)
{
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
if (pcm.Length != Options.SamplesPerChannel * Options.Channels) throw new ArgumentException("PCM must contain exactly one interleaved frame.", nameof(pcm));
if (packet.IsEmpty) throw new ArgumentException("Packet storage must not be empty.", nameof(packet));
if (MemoryMarshal.AsBytes(pcm).Overlaps(packet)) throw new ArgumentException("PCM and packet storage must not overlap.");
fixed (short* input = pcm)
fixed (byte* output = packet)
return OpusException.Check(NativeMethods.Encode(handle, input, Options.SamplesPerChannel, output, packet.Length));
}
public void Dispose() => handle.Dispose();
}
@@ -0,0 +1,10 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Codec;
public sealed class OpusException : Exception
{
public int ErrorCode { get; }
internal OpusException(int error) : base(Marshal.PtrToStringUTF8(NativeMethods.Error(error))) => ErrorCode = error;
internal static int Check(int result) => result < 0 ? throw new OpusException(result) : result;
}
+32
View File
@@ -0,0 +1,32 @@
namespace VoiceCat.Codec;
public enum OpusApplication { Voip = 2048, Audio = 2049, LowDelay = 2051 }
public sealed record OpusOptions
{
public int SampleRate { get; init; } = 48000;
public int Channels { get; init; } = 1;
public int FrameDurationMilliseconds { get; init; } = 20;
public int Bitrate { get; init; } = 24000;
public int MaximumBandwidthHz { get; init; }
public int Complexity { get; init; } = 10;
public int ExpectedPacketLossPercent { get; init; }
public bool ForwardErrorCorrection { get; init; } = true;
public bool DiscontinuousTransmission { get; init; }
public bool DeepRedundancy { get; init; }
public OpusApplication Application { get; init; } = OpusApplication.Voip;
public int SamplesPerChannel => SampleRate / 1000 * FrameDurationMilliseconds;
internal void Validate()
{
if (SampleRate is not (8000 or 12000 or 16000 or 24000 or 48000)) throw new ArgumentOutOfRangeException(nameof(SampleRate));
if (Channels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(Channels));
if (FrameDurationMilliseconds is not (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,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,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,159 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
using VoiceCat.Crypto;
namespace VoiceCat.Server.Data;
public sealed record Account(long Id, string Username, bool IsAdmin, long CreatedAt, long LastLogin);
public sealed class AccountStore : IDisposable
{
static AccountStore() => SQLitePCL.Batteries_V2.Init();
private readonly string connectionString;
private readonly PasswordHasher hasher = new();
private readonly SemaphoreSlim passwordWorkers = new(2);
private bool disposed;
private const string DummyHash = "$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$Ki9tdSYqOtze3s3LAS6gv6I0buTIh2abdjWzY3GeLiE";
public AccountStore(string path)
{
connectionString = new SqliteConnectionStringBuilder { DataSource = Path.GetFullPath(path), Pooling = false, DefaultTimeout = 5 }.ToString();
using var connection = Open();
using var setup = connection.CreateCommand();
setup.CommandText = "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);";
setup.ExecuteNonQuery();
using var transaction = connection.BeginTransaction();
using var version = connection.CreateCommand();
version.Transaction = transaction;
version.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
object? stored = version.ExecuteScalar();
if (stored is not null && (!int.TryParse((string)stored, NumberStyles.None, CultureInfo.InvariantCulture, out int revision) || revision is < 1 or > 2))
throw new InvalidDataException("Unsupported server database schema version.");
using var resource = typeof(AccountStore).Assembly.GetManifestResourceStream("VoiceCat.Server.Data.schema.sql")!;
using var reader = new StreamReader(resource);
using var migrate = connection.CreateCommand();
migrate.Transaction = transaction;
migrate.CommandText = reader.ReadToEnd() + "INSERT INTO server_meta (key,value) VALUES ('schema_version','2') ON CONFLICT(key) DO UPDATE SET value='2';";
migrate.ExecuteNonQuery();
transaction.Commit();
}
private SqliteConnection Open()
{
ObjectDisposedException.ThrowIf(disposed, this);
var connection = new SqliteConnection(connectionString);
try { connection.Open(); return connection; }
catch { connection.Dispose(); throw; }
}
public async Task<Account> CreateAccountAsync(string username, string password, bool isAdmin = false, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(username);
if (username.Length > 128) throw new ArgumentException("Username exceeds 128 characters.", nameof(username));
string hash = await PasswordWorkAsync(() => hasher.Hash(password), cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
using var connection = Open();
using var command = connection.CreateCommand();
long created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
command.CommandText = "INSERT INTO accounts (username,pw_hash,is_admin,created_at) VALUES ($user,$hash,$admin,$created) RETURNING id";
command.Parameters.AddWithValue("$user", username);
command.Parameters.AddWithValue("$hash", hash);
command.Parameters.AddWithValue("$admin", isAdmin ? 1 : 0);
command.Parameters.AddWithValue("$created", created);
return new((long)command.ExecuteScalar()!, username, isAdmin, created, 0);
}
public async Task<Account?> AuthenticateAsync(string username, string password, CancellationToken cancellationToken = default)
{
string? hash = null;
Account? account = null;
using (var connection = Open())
using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT id,pw_hash,is_admin,created_at,last_login FROM accounts WHERE username=$user";
command.Parameters.AddWithValue("$user", username);
using var reader = command.ExecuteReader();
if (reader.Read())
{
hash = reader.GetString(1);
account = new(reader.GetInt64(0), username, reader.GetInt64(2) != 0, reader.GetInt64(3), reader.GetInt64(4));
}
}
bool verified = await PasswordWorkAsync(() => hasher.Verify(password, hash ?? DummyHash), cancellationToken).ConfigureAwait(false);
if (hash is null || !verified) return null;
cancellationToken.ThrowIfCancellationRequested();
using var updated = Open();
using var update = updated.CreateCommand();
long login = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
update.CommandText = "UPDATE accounts SET last_login=$login WHERE id=$id AND pw_hash=$hash";
update.Parameters.AddWithValue("$login", login);
update.Parameters.AddWithValue("$id", account!.Id);
update.Parameters.AddWithValue("$hash", hash);
return update.ExecuteNonQuery() == 1 ? account with { LastLogin = login } : null;
}
private async Task<T> PasswordWorkAsync<T>(Func<T> work, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
await passwordWorkers.WaitAsync(cancellationToken).ConfigureAwait(false);
try { return await Task.Run(work, cancellationToken).ConfigureAwait(false); }
finally { passwordWorkers.Release(); }
}
public void Dispose() => disposed = true;
public IReadOnlyList<Voicecat.V1.Channel> LoadChannels()
{
using var connection = Open();
using var transaction = connection.BeginTransaction();
using var seed = connection.CreateCommand();
seed.Transaction = transaction;
seed.CommandText = "SELECT COUNT(*) FROM channels";
bool empty = (long)seed.ExecuteScalar()! == 0;
seed.CommandText = """
INSERT INTO channels (id,name,max_users) VALUES (1,'Lobby',20);
INSERT INTO channels (id,name,audio_mode,audio_bitrate_bps,audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity,sort_order)
VALUES (2,'Music Room',1,128000,1,0,0,0,8,1);
""";
if (empty) seed.ExecuteNonQuery();
transaction.Commit();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT id,parent_id,name,topic,password_hash,max_users,type,sort_order,
audio_codec,audio_mode,audio_sample_rate,audio_bitrate_bps,audio_frame_ms,
audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity
FROM channels ORDER BY sort_order,id
""";
using var reader = command.ExecuteReader();
var channels = new List<Voicecat.V1.Channel>();
while (reader.Read())
{
channels.Add(new()
{
Id = checked((uint)reader.GetInt64(0)), ParentId = checked((uint)reader.GetInt64(1)),
Name = reader.GetString(2), Topic = reader.GetString(3), PasswordProtected = reader.GetString(4).Length != 0,
MaxUsers = checked((uint)reader.GetInt64(5)), Type = (Voicecat.V1.ChannelType)reader.GetInt32(6), Order = reader.GetInt32(7),
Audio = new()
{
Codec = checked((uint)reader.GetInt64(8)), Mode = (Voicecat.V1.ChannelMode)reader.GetInt32(9),
SampleRate = checked((uint)reader.GetInt64(10)), BitrateBps = checked((uint)reader.GetInt64(11)),
FrameMs = checked((uint)reader.GetInt64(12)), Application = (Voicecat.V1.OpusApplication)reader.GetInt32(13),
Fec = reader.GetInt32(14) != 0, ExpectedPacketLoss = checked((uint)reader.GetInt64(15)),
Dtx = reader.GetInt32(16) != 0, Complexity = checked((uint)reader.GetInt64(17))
}
});
}
return channels;
}
public bool IsBanned(string subjectType, string subject)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT 1 FROM bans WHERE subject_type=$type AND subject=$subject AND (expires_at=0 OR expires_at>$now) LIMIT 1";
command.Parameters.AddWithValue("$type", subjectType);
command.Parameters.AddWithValue("$subject", subject);
command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
return command.ExecuteScalar() is not null;
}
}
@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
pw_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_login INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER NOT NULL DEFAULT 0,
name TEXT UNIQUE NOT NULL,
topic TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL DEFAULT '',
max_users INTEGER NOT NULL DEFAULT 0,
type INTEGER NOT NULL DEFAULT 0,
audio_codec INTEGER NOT NULL DEFAULT 0,
audio_mode INTEGER NOT NULL DEFAULT 0,
audio_sample_rate INTEGER NOT NULL DEFAULT 48000,
audio_bitrate_bps INTEGER NOT NULL DEFAULT 24000,
audio_frame_ms INTEGER NOT NULL DEFAULT 20,
audio_application INTEGER NOT NULL DEFAULT 0,
audio_fec INTEGER NOT NULL DEFAULT 1,
audio_expected_packet_loss INTEGER NOT NULL DEFAULT 10,
audio_dtx INTEGER NOT NULL DEFAULT 1,
audio_complexity INTEGER NOT NULL DEFAULT 5,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_type TEXT NOT NULL,
subject TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
expires_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_bans_subject ON bans(subject_type, subject);
+12
View File
@@ -0,0 +1,12 @@
using System.Net;
using VoiceCat.Server;
string directory = args.Length > 0 ? args[0] : "voicecat-data";
int port = args.Length > 1 ? int.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture) : 7443;
using var stop = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; stop.Cancel(); };
await using var server = new VoiceServer(directory, new IPEndPoint(IPAddress.Loopback, port));
server.ConnectionFailed += exception => Console.Error.WriteLine($"Connection closed: {exception.Message}");
Console.WriteLine($"VoiceCat managed control server listening on {server.EndPoint}");
try { await Task.Delay(Timeout.Infinite, stop.Token); }
catch (OperationCanceledException) { }
@@ -0,0 +1,169 @@
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.Server.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;
public Task Completion { get; }
public CancellationToken CancellationToken => lifetime.Token;
internal TlsControlConnection(Socket socket, TlsSession tls, CancellationToken cancellationToken)
{
this.socket = socket;
this.tls = tls;
lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
lifetime.CancelAfter(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();
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) lifetime.CancelAfter(TimeSpan.FromSeconds(60));
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
{
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();
await Completion.ConfigureAwait(false);
lifetime.Dispose();
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="10.0.5" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.2" />
<PackageReference Include="SourceGear.sqlite3" Version="3.50.4.2" />
<EmbeddedResource Include="Data/schema.sql" />
<InternalsVisibleTo Include="VoiceCat.Tests" />
</ItemGroup>
</Project>
+265
View File
@@ -0,0 +1,265 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using Google.Protobuf;
using VoiceCat.Crypto;
using VoiceCat.Server.Data;
using VoiceCat.Server.Transport;
using Voicecat.V1;
namespace VoiceCat.Server;
public sealed class VoiceServer : IAsyncDisposable
{
private readonly Socket listener;
private readonly ServerCredentials credentials;
private readonly AccountStore accounts;
private readonly IReadOnlyList<Voicecat.V1.Channel> channels;
private readonly bool allowGuests;
private readonly string name;
private readonly CancellationTokenSource shutdown = new();
private readonly object gate = new();
private readonly Dictionary<ulong, Session> sessions = [];
private readonly List<Task> connections = [];
private ulong nextSession;
private uint nextUser;
private readonly Task accepting;
private int disposed;
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
public event Action<Exception>? ConnectionFailed;
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
{
this.allowGuests = allowGuests;
this.name = name;
credentials = ServerCredentials.LoadOrCreate(directory, name);
try
{
accounts = new AccountStore(Path.Combine(directory, "voicecat.db"));
channels = accounts.LoadChannels();
listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
listener.Bind(endpoint);
listener.Listen(64);
}
catch
{
listener?.Dispose();
accounts?.Dispose();
credentials.Dispose();
shutdown.Dispose();
throw;
}
accepting = AcceptAsync();
}
private async Task AcceptAsync()
{
try
{
while (!shutdown.IsCancellationRequested)
{
Socket socket = await listener.AcceptAsync(shutdown.Token).ConfigureAwait(false);
lock (gate)
{
if (sessions.Count >= 64) { socket.Dispose(); continue; }
socket.NoDelay = true;
string address = ((IPEndPoint)socket.RemoteEndPoint!).Address.ToString();
var connection = new TlsControlConnection(socket, credentials.CreateTlsSession(), shutdown.Token);
var session = new Session(++nextSession, connection, address);
sessions.Add(session.Id, session);
connections.RemoveAll(task => task.IsCompleted);
connections.Add(HandleAsync(session));
}
}
}
catch (Exception exception) when (shutdown.IsCancellationRequested && exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
}
private async Task HandleAsync(Session session)
{
try
{
await foreach (Envelope envelope in session.Connection.ReadAsync(shutdown.Token).ConfigureAwait(false))
{
if (envelope.Ping is not null)
{
session.Connection.TrySend(new() { RequestId = envelope.RequestId, Pong = new() { Nonce = envelope.Ping.Nonce } });
continue;
}
if (envelope.Disconnect is not null) { session.Connection.CompleteWrites(); break; }
if (!session.HelloReceived)
{
if (envelope.ClientHello?.ProtoVersion != 2 || accounts.IsBanned("ip", session.Address))
{
Reject(session, "Unsupported protocol version or banned address.");
break;
}
var hello = new ServerHello { ProtoVersion = 2, ServerName = name, ServerVersion = "0.1.0-dotnet", ServerIdentityFingerprint = ByteString.CopyFrom(SHA256.HashData(credentials.Identity.PublicKey)) };
if (allowGuests) hello.AuthMethods.Add("guest");
hello.AuthMethods.Add("password");
session.Connection.TrySend(new() { RequestId = envelope.RequestId, ServerHello = hello });
session.HelloReceived = true;
continue;
}
if (session.User is null)
{
if (envelope.AuthRequest is null) { Reject(session, "Authentication required."); break; }
await AuthenticateAsync(session, envelope.RequestId, envelope.AuthRequest).ConfigureAwait(false);
continue;
}
switch (envelope.BodyCase)
{
case Envelope.BodyOneofCase.TextMessage: RelayText(session, envelope.TextMessage); break;
case Envelope.BodyOneofCase.Subscribe: SendSnapshot(session); break;
case Envelope.BodyOneofCase.JoinChannel: Join(session, envelope.RequestId, envelope.JoinChannel.ChannelId); break;
case Envelope.BodyOneofCase.SubscribeVoice:
session.Connection.TrySend(new() { RequestId = envelope.RequestId, VoiceSubscriptionResult = new() { Error = "Managed media relay is not implemented yet." } });
break;
default:
session.Connection.TrySend(new() { RequestId = envelope.RequestId, GenericResult = new() { Code = 1, Message = "Operation is not implemented by this server checkpoint." } });
break;
}
}
await session.Connection.Completion.ConfigureAwait(false);
}
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
{
if (!shutdown.IsCancellationRequested && exception is not OperationCanceledException) ConnectionFailed?.Invoke(exception);
}
finally
{
lock (gate)
{
sessions.Remove(session.Id);
if (session.User is not null) Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Left, LeftId = session.User.Id } });
}
await session.Connection.DisposeAsync().ConfigureAwait(false);
}
}
private static void Reject(Session session, string reason)
{
session.Connection.TrySend(new() { Disconnect = new() { Code = 1, Reason = reason } });
session.Connection.CompleteWrites();
}
private async Task AuthenticateAsync(Session session, ulong requestId, AuthRequest request)
{
User? user = null;
bool admin = false;
if (request.Guest is not null && allowGuests && request.Guest.Nickname.Length <= 128)
user = new() { Nickname = request.Guest.Nickname.Length == 0 ? "Guest" : request.Guest.Nickname, IsGuest = true, ChannelId = 1 };
else if (request.Password is not null && request.Password.Username.Length <= 128 && request.Password.Password.Length <= 1024 && !accounts.IsBanned("username", request.Password.Username))
{
Account? account = await accounts.AuthenticateAsync(request.Password.Username, request.Password.Password, session.Connection.CancellationToken).ConfigureAwait(false);
if (account is not null) { user = new() { Nickname = account.Username, ChannelId = 1 }; admin = account.IsAdmin; }
}
shutdown.Token.ThrowIfCancellationRequested();
session.Connection.CancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
var lobby = channels.FirstOrDefault(channel => channel.Id == 1);
if (user is null || lobby is null || lobby.PasswordProtected || lobby.MaxUsers != 0 && sessions.Values.Count(peer => peer.User?.ChannelId == 1) >= lobby.MaxUsers)
{
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new() { Error = "Invalid credentials or lobby unavailable." } });
return;
}
user.Id = checked(++nextUser);
session.User = user;
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new()
{
Ok = true, SessionId = session.Id, Self = user.Clone(),
Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin }
} });
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Joined, User = user.Clone() } }, session.Id);
SendSnapshot(session);
}
}
private void SendSnapshot(Session session)
{
lock (gate)
{
var snapshot = new ServerStateSnapshot();
snapshot.Channels.Add(channels.Select(channel => channel.Clone()));
snapshot.Users.Add(sessions.Values.Where(peer => peer.User is not null).Select(peer => peer.User!.Clone()));
session.Connection.TrySend(new() { ServerState = snapshot });
}
}
private void Join(Session session, ulong requestId, uint channelId)
{
lock (gate)
{
var channel = channels.FirstOrDefault(candidate => candidate.Id == channelId);
if (channel is null || channel.PasswordProtected || channel.MaxUsers != 0 && sessions.Values.Count(peer => peer.Id != session.Id && peer.User?.ChannelId == channelId) >= channel.MaxUsers)
{
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = new() { Error = "Channel unavailable." } });
return;
}
session.User!.ChannelId = channelId;
var result = new JoinChannelResult { Ok = true, ChannelId = channelId, Audio = channel.Audio.Clone() };
result.Members.Add(sessions.Values.Where(peer => peer.User?.ChannelId == channelId).Select(peer => peer.User!.Clone()));
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = result });
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Updated, User = session.User.Clone() } });
}
}
private void RelayText(Session sender, TextMessage message)
{
lock (gate)
{
bool permitted = Encoding.UTF8.GetByteCount(message.Body) <= 4096 && message.ClientMsgId.Length <= 128 &&
(message.Scope == TextScope.TextServer || message.Scope == TextScope.TextChannel && message.TargetId == sender.User!.ChannelId ||
message.Scope == TextScope.TextPrivate && sessions.Values.Any(peer => peer.User?.Id == message.TargetId));
if (permitted)
{
var relay = message.Clone();
relay.SenderId = sender.User!.Id;
relay.SentAtUnixMs = checked((ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
var envelope = new Envelope { TextMessage = relay };
foreach (Session recipient in sessions.Values.Where(peer => peer.User is not null))
if (message.Scope == TextScope.TextServer || message.Scope == TextScope.TextChannel && recipient.User!.ChannelId == message.TargetId ||
message.Scope == TextScope.TextPrivate && (recipient.User!.Id == message.TargetId || recipient.Id == sender.Id))
recipient.Connection.TrySend(envelope);
}
sender.Connection.TrySend(new() { TextMessageAck = new() { ClientMsgId = message.ClientMsgId, Ok = permitted } });
}
}
private void Broadcast(Envelope envelope, ulong excluded = 0)
{
foreach (Session recipient in sessions.Values.Where(peer => peer.Id != excluded && peer.User is not null)) recipient.Connection.TrySend(envelope);
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
shutdown.Cancel();
listener.Dispose();
try
{
await accepting.ConfigureAwait(false);
Task[] pending;
lock (gate) pending = connections.ToArray();
await Task.WhenAll(pending).ConfigureAwait(false);
}
finally
{
accounts.Dispose();
credentials.Dispose();
shutdown.Dispose();
}
}
private sealed class Session(ulong id, TlsControlConnection connection, string address)
{
public ulong Id { get; } = id;
public TlsControlConnection Connection { get; } = connection;
public string Address { get; } = address;
public bool HelloReceived { get; set; }
public User? User { get; set; }
}
}
@@ -0,0 +1,76 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.Data.Sqlite.Core": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"SourceGear.sqlite3": {
"type": "Direct",
"requested": "[3.50.4.2, )",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Direct",
"requested": "[3.0.2, )",
"resolved": "3.0.2",
"contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==",
"dependencies": {
"SQLitePCLRaw.config.e_sqlite3": "3.0.2",
"SourceGear.sqlite3": "3.50.4.2"
}
},
"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=="
},
"SQLitePCLRaw.config.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==",
"dependencies": {
"SQLitePCLRaw.provider.e_sqlite3": "3.0.2"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==",
"dependencies": {
"SQLitePCLRaw.core": "3.0.2"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}
@@ -0,0 +1,101 @@
using Microsoft.Data.Sqlite;
using System.Diagnostics;
using VoiceCat.Server.Data;
namespace VoiceCat.Tests;
public sealed class AccountStoreTests
{
[Fact]
public void UnsupportedSchemaIsRejectedWithoutCreatingAccountTables()
{
string path = Path.Combine(Path.GetTempPath(), "voicecat-future-" + Guid.NewGuid().ToString("N") + ".db");
try
{
SQLitePCL.Batteries_V2.Init();
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TABLE server_meta (key TEXT PRIMARY KEY,value TEXT NOT NULL); INSERT INTO server_meta VALUES ('schema_version','99');";
command.ExecuteNonQuery();
Assert.Throws<InvalidDataException>(() => new AccountStore(path));
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE name='accounts'";
Assert.Equal(0L, command.ExecuteScalar());
command.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
Assert.Equal("99", command.ExecuteScalar());
}
finally { File.Delete(path); File.Delete(path + "-wal"); File.Delete(path + "-shm"); }
}
[NativeDatabaseFact]
public async Task ExistingCppDatabaseAndManagedAccountsWorkInBothImplementations()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-import-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "voicecat.db");
try
{
await RunOracleAsync("create", path);
using (var store = new AccountStore(path))
{
Account account = Assert.IsType<Account>(await store.AuthenticateAsync("legacy", "legacy password"));
Assert.True(account.IsAdmin);
var channel = Assert.Single(store.LoadChannels());
Assert.Equal("Preserved native topic", channel.Topic);
Assert.Equal(7U, channel.MaxUsers);
Assert.Equal(32000U, channel.Audio.BitrateBps);
await store.CreateAccountAsync("managed", "managed password", true);
}
await RunOracleAsync("verify", path);
}
finally { Directory.Delete(directory, true); }
}
private static async Task RunOracleAsync(string mode, string path)
{
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE")!) { UseShellExecute = false, CreateNoWindow = true };
start.ArgumentList.Add(mode);
start.ArgumentList.Add(path);
using var process = Process.Start(start)!;
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try { await process.WaitForExitAsync(timeout.Token); Assert.Equal(0, process.ExitCode); }
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
private sealed class NativeDatabaseFactAttribute : FactAttribute
{
public NativeDatabaseFactAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE"))) Skip = "Set VOICECAT_DATABASE_ORACLE to the native database oracle.";
}
}
[Fact]
public async Task AccountsSurviveRestartAndFailedAuthDoesNotChangeLastLogin()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-db-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "voicecat.db");
try
{
Account account;
using (var store = new AccountStore(path)) account = await store.CreateAccountAsync("admin'", "secret", true);
using (var store = new AccountStore(path))
{
Assert.Null(await store.AuthenticateAsync("admin'", "wrong"));
Assert.Null(await store.AuthenticateAsync("missing", "secret"));
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT last_login FROM accounts WHERE id=$id";
command.Parameters.AddWithValue("$id", account.Id);
Assert.Equal(0L, command.ExecuteScalar());
Account authenticated = Assert.IsType<Account>(await store.AuthenticateAsync("admin'", "secret"));
Assert.Equal(account.Id, authenticated.Id);
Assert.True(authenticated.IsAdmin);
Assert.True(authenticated.LastLogin > 0);
}
}
finally { Directory.Delete(directory, true); }
}
}
+123
View File
@@ -0,0 +1,123 @@
using VoiceCat.Codec;
namespace VoiceCat.Tests;
public sealed class CodecTests
{
public static IEnumerable<object[]> Formats()
{
foreach (int rate in new[] { 8000, 12000, 16000, 24000, 48000 })
foreach (int channels in new[] { 1, 2 })
foreach (int duration in new[] { 10, 20, 40, 60 })
yield return [rate, channels, duration];
}
[Theory]
[MemberData(nameof(Formats))]
public void RoundTripAndLossConcealment(int sampleRate, int channels, int duration)
{
var options = new OpusOptions { SampleRate = sampleRate, Channels = channels, FrameDurationMilliseconds = duration, Bitrate = 64000 };
using var encoder = new OpusEncoder(options);
using var decoder = new OpusDecoder(sampleRate, channels);
short[] input = new short[options.SamplesPerChannel * channels];
short[] output = new short[input.Length];
byte[] packet = new byte[4000];
for (int frame = 0; frame < 12; frame++)
{
FillTone(input, options.SamplesPerChannel, channels, sampleRate, frame);
int bytes = encoder.Encode(input, packet);
Assert.InRange(bytes, 1, packet.Length);
Assert.Equal(options.SamplesPerChannel, decoder.Decode(packet.AsSpan(0, bytes), output, options.SamplesPerChannel));
}
double rms = Rms(output);
Assert.InRange(rms, 2000, 12000);
Assert.Equal(options.SamplesPerChannel, decoder.Decode([], output, options.SamplesPerChannel));
Assert.True(Rms(output) > 100);
}
[Fact]
public void RejectsInvalidStorageAndOptionsBeforeNativeCalls()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new OpusEncoder(new() { Channels = 3 }));
Assert.Throws<ArgumentOutOfRangeException>(() => new OpusEncoder(new() { FrameDurationMilliseconds = 30 }));
using var encoder = new OpusEncoder();
using var decoder = new OpusDecoder();
Assert.Throws<ArgumentException>(() => encoder.Encode(new short[959], new byte[4000]));
Assert.Throws<ArgumentException>(() => decoder.Decode([], new short[959], 960));
encoder.Dispose();
Assert.Throws<ObjectDisposedException>(() => encoder.Encode(new short[960], new byte[4000]));
}
[Fact]
public void DredIsExplicitlySupportedOrRejected()
{
using var probe = new OpusEncoder();
Assert.Contains("libopus", OpusEncoder.Version);
if (!probe.SupportsDeepRedundancy)
{
Assert.Throws<NotSupportedException>(() => new OpusEncoder(new() { DeepRedundancy = true }));
Assert.Throws<NotSupportedException>(() => new OpusDeepRedundancy());
return;
}
VerifyDredRecovery(new() { DeepRedundancy = true, ExpectedPacketLossPercent = 20, Bitrate = 64000 });
}
[Theory]
[MemberData(nameof(Formats))]
public void DredRecoversDroppedFrames(int sampleRate, int channels, int duration)
{
using var probe = new OpusEncoder();
Assert.True(probe.SupportsDeepRedundancy, "Build native bindings with dotnet/build-native.ps1 for DRED recovery tests.");
if (sampleRate < 16000)
Assert.Throws<NotSupportedException>(() => new OpusEncoder(new() { SampleRate = sampleRate, DeepRedundancy = true }));
VerifyDredRecovery(new() { SampleRate = sampleRate, Channels = channels,
FrameDurationMilliseconds = duration, DeepRedundancy = true, ExpectedPacketLossPercent = 20, Bitrate = 64000 });
}
private static void VerifyDredRecovery(OpusOptions options)
{
// The pinned encoder cannot emit DRED at 8/12 kHz; packets can still be decoded at those rates.
var encoderOptions = options with { SampleRate = Math.Max(16000, options.SampleRate) };
using var encoder = new OpusEncoder(encoderOptions);
using var decoder = new OpusDecoder(options.SampleRate, options.Channels);
using var recovery = new OpusDeepRedundancy();
short[] input = new short[encoderOptions.SamplesPerChannel * options.Channels];
short[] output = new short[options.SamplesPerChannel * options.Channels];
byte[] packet = new byte[4000];
bool missing = false;
int recovered = 0;
for (int frame = 0; frame < 40; frame++)
{
FillTone(input, encoderOptions.SamplesPerChannel, options.Channels, encoderOptions.SampleRate, frame);
int bytes = encoder.Encode(input, packet);
if (missing)
{
Assert.True(recovery.TryRecover(decoder, packet.AsSpan(0, bytes), output, options.SamplesPerChannel));
Assert.True(Rms(output) > 10);
recovered++;
missing = false;
}
if (frame > 20 && frame % 5 == 0)
{
missing = true;
continue;
}
decoder.Decode(packet.AsSpan(0, bytes), output, options.SamplesPerChannel);
}
Assert.Equal(3, recovered);
}
internal static void FillTone(Span<short> pcm, int samples, int channels, int rate, int frame)
{
for (int i = 0; i < samples; i++)
for (int channel = 0; channel < channels; channel++)
pcm[i * channels + channel] = (short)(8000 * Math.Sin(2 * Math.PI * (440 + 220 * channel) * (frame * samples + i) / rate));
}
internal static double Rms(ReadOnlySpan<short> pcm)
{
double sum = 0;
foreach (short value in pcm) sum += (double)value * value;
return Math.Sqrt(sum / pcm.Length);
}
}
+70
View File
@@ -0,0 +1,70 @@
using VoiceCat.Dsp;
using System.Text.Json;
namespace VoiceCat.Tests;
public sealed class DspTests
{
[Fact]
public void SuppressesNoiseAndPreservesUnsupportedSampleRates()
{
using var processor = new RnnoiseProcessor();
short[] pcm = new short[960];
uint random = 0x12345678;
double inputEnergy = 0, outputEnergy = 0;
for (int frame = 0; frame < 200; frame++)
{
FillNoise(pcm, ref random);
if (frame >= 60) foreach (short value in pcm) inputEnergy += (double)value * value;
processor.Process(pcm);
if (frame >= 60) foreach (short value in pcm) outputEnergy += (double)value * value;
}
Assert.True(Math.Sqrt(outputEnergy / inputEnergy) < 0.2);
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-noise.json")));
short[] expected = fixture.RootElement.GetProperty("samples").EnumerateArray().Select(value => value.GetInt16()).ToArray();
Assert.Equal(pcm.Length, expected.Length);
for (int i = 0; i < pcm.Length; i++) Assert.InRange(Math.Abs(pcm[i] - expected[i]), 0, 1);
FillNoise(pcm, ref random);
short[] original = (short[])pcm.Clone();
processor.Process(pcm, 16000);
Assert.Equal(original, pcm);
Assert.Throws<ArgumentException>(() => processor.Process(new short[481]));
processor.Dispose();
Assert.Throws<ObjectDisposedException>(() => processor.Process(pcm));
}
[Fact]
public void VadStartsClosedAndUsesMonotonicHangTime()
{
var clock = new ManualTimeProvider();
var processor = new EnergyVadProcessor(0.02f, TimeSpan.FromMilliseconds(300), clock);
Assert.False(processor.Process(new short[480]));
Assert.True(processor.Process(new short[] { 32767 }));
clock.Advance(299);
Assert.True(processor.Process([]));
clock.Advance(1);
Assert.False(processor.Process(new short[480]));
processor.Threshold = 0.5f;
Assert.False(processor.Process(new short[] { 1000 }));
Assert.Throws<ArgumentOutOfRangeException>(() => processor.Threshold = float.NaN);
}
internal static void FillNoise(Span<short> pcm, ref uint random)
{
for (int i = 0; i < pcm.Length; i++)
{
random ^= random << 13;
random ^= random >> 17;
random ^= random << 5;
pcm[i] = (short)((int)(random % 6001) - 3000);
}
}
private sealed class ManualTimeProvider : TimeProvider
{
private long timestamp;
public override long TimestampFrequency => 1000;
public override long GetTimestamp() => timestamp;
public void Advance(int milliseconds) => timestamp += milliseconds;
}
}
@@ -0,0 +1 @@
{"samples":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}
@@ -0,0 +1 @@
{"hashes":[{"passwordBase64":"dm9pY2VjYXQgdGVzdA","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$Ki9tdSYqOtze3s3LAS6gv6I0buTIh2abdjWzY3GeLiE"},{"passwordBase64":"Y2Fmw6k","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$lEpmh4tmC0xaD5DhMboQo/3Hw7JqT3VThdqq0n1pImc"},{"passwordBase64":"YQBi","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$XZZGeWLqPMYfYmkPOuDe9dOMu0w7kVG9WS8/Dl6sVI0"}]}
@@ -0,0 +1,34 @@
using VoiceCat.Codec;
using VoiceCat.Dsp;
namespace VoiceCat.Tests;
public sealed class MediaAllocationTests
{
[Fact]
public void SteadyStateCodecAndDspDoNotAllocateManagedMemory()
{
using var encoder = new OpusEncoder();
using var decoder = new OpusDecoder();
using var denoiser = new RnnoiseProcessor();
var vad = new EnergyVadProcessor();
short[] pcm = new short[960];
short[] decoded = new short[960];
byte[] packet = new byte[4000];
CodecTests.FillTone(pcm, 960, 1, 48000, 0);
for (int i = 0; i < 100; i++) Cycle(encoder, decoder, denoiser, vad, pcm, decoded, packet);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1000; i++) Cycle(encoder, decoder, denoiser, vad, pcm, decoded, packet);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
}
private static void Cycle(OpusEncoder encoder, OpusDecoder decoder, RnnoiseProcessor denoiser,
EnergyVadProcessor vad, short[] pcm, short[] decoded, byte[] packet)
{
int bytes = encoder.Encode(pcm, packet);
decoder.Decode(packet.AsSpan(0, bytes), decoded, 960);
denoiser.Process(decoded);
vad.Process(decoded);
}
}
@@ -0,0 +1,42 @@
using System.Text;
using System.Text.Json;
using VoiceCat.Crypto;
namespace VoiceCat.Tests;
public sealed class PasswordTests
{
[Fact]
public void VerifiesLibsodiumHashesWithoutPasswordNormalization()
{
var hasher = new PasswordHasher();
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-passwords.json")));
foreach (var item in fixture.RootElement.GetProperty("hashes").EnumerateArray())
{
string encodedPassword = item.GetProperty("passwordBase64").GetString()!;
string password = Encoding.UTF8.GetString(Convert.FromBase64String(encodedPassword.PadRight((encodedPassword.Length + 3) / 4 * 4, '=')));
string hash = item.GetProperty("hash").GetString()!;
Assert.True(hasher.Verify(password, hash));
Assert.False(hasher.Verify(password + "!", hash));
}
}
[Fact]
public void FreshHashesUseRandomSaltAndNativePhcFormat()
{
var hasher = new PasswordHasher();
string first = hasher.Hash("hello");
string second = hasher.Hash("hello");
Assert.NotEqual(first, second);
Assert.StartsWith("$argon2id$v=19$m=65536,t=2,p=1$", first);
Assert.True(hasher.Verify("hello", first));
Assert.False(hasher.Verify("wrong", first));
}
[Theory]
[InlineData("$argon2id$v=19$m=999999999,t=2,p=1$c2FsdA$aGFzaA")]
[InlineData("$argon2id$v=19$m=65536,t=99999,p=1$c2FsdA$aGFzaA")]
[InlineData("$argon2id$v=16$m=65536,t=2,p=1$c2FsdA$aGFzaA")]
[InlineData("$argon2id$v=19$m=65536,t=2,p=1$!!!$!!!")]
public void MalformedOrExcessiveHashesFailClosed(string hash) => Assert.False(new PasswordHasher().Verify("hello", hash));
}
+194
View File
@@ -0,0 +1,194 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using VoiceCat.Crypto;
using VoiceCat.Server;
using VoiceCat.Server.Transport;
using Voicecat.V1;
namespace VoiceCat.Tests;
public sealed class ServerTests
{
[Fact]
public async Task ControlFramesCanSpanMultipleTlsRecordsAndPingEchoesCorrelation()
{
await using var fixture = new ServerFixture();
await using var client = await fixture.ConnectAsync();
client.Send(new() { ClientHello = new() { ProtoVersion = 2, ClientName = new string('x', 48000) } });
await client.ReadUntilAsync(e => e.ServerHello is not null);
client.Send(new() { RequestId = 45, Ping = new() { Nonce = 123456 } });
Envelope pong = await client.ReadUntilAsync(e => e.Pong is not null);
Assert.Equal(45UL, pong.RequestId);
Assert.Equal(123456UL, pong.Pong.Nonce);
}
[Fact]
public async Task GuestsChatJoinChannelsAndDisconnectOverTls()
{
await using var fixture = new ServerFixture();
await using var alice = await fixture.ConnectAsync();
User a = await alice.LoginAsync("Alice");
await using var bob = await fixture.ConnectAsync();
User b = await bob.LoginAsync("Bob");
Assert.NotEqual(a.Id, b.Id);
Envelope joined = await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Joined);
Assert.Equal(b.Id, joined.UserEvent.User.Id);
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, SenderId = b.Id, Body = "hello", ClientMsgId = "one" } });
TextMessage text = (await bob.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
Assert.Equal("hello", text.Body);
Assert.Equal(a.Id, text.SenderId);
Assert.True(text.SentAtUnixMs > 0);
Assert.True((await alice.ReadUntilAsync(e => e.TextMessageAck is not null)).TextMessageAck.Ok);
bob.Send(new() { RequestId = 10, JoinChannel = new() { ChannelId = 2 } });
Envelope moved = await bob.ReadUntilAsync(e => e.JoinChannelResult is not null);
Assert.Equal(10UL, moved.RequestId);
Assert.True(moved.JoinChannelResult.Ok);
Assert.Equal(128000U, moved.JoinChannelResult.Audio.BitrateBps);
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 2, Body = "unauthorized", ClientMsgId = "two" } });
Assert.False((await alice.ReadUntilAsync(e => e.TextMessageAck is not null)).TextMessageAck.Ok);
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, Body = "isolated" } });
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextPrivate, TargetId = b.Id, Body = "private" } });
Assert.Equal("private", (await bob.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage.Body);
bob.Send(new() { Disconnect = new() });
Envelope left = await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left);
Assert.Equal(b.Id, left.UserEvent.LeftId);
alice.Send(new() { RequestId = 11, Subscribe = new() });
ServerStateSnapshot snapshot = (await alice.ReadUntilAsync(e => e.ServerState is not null)).ServerState;
Assert.Equal(a.Id, Assert.Single(snapshot.Users).Id);
}
[Fact]
public async Task PasswordAuthenticationCanRetryAndGuestAccessCanBeDisabled()
{
await using var fixture = new ServerFixture(false);
using (var accounts = new VoiceCat.Server.Data.AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
await accounts.CreateAccountAsync("Admin", "secret", true);
await using var client = await fixture.ConnectAsync();
client.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
ServerHello hello = (await client.ReadUntilAsync(e => e.ServerHello is not null)).ServerHello;
Assert.Equal(["password"], hello.AuthMethods);
client.Send(new() { AuthRequest = new() { Guest = new() { Nickname = "Guest" } } });
Assert.False((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "wrong" } } });
Assert.False((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
client.Send(new() { RequestId = 3, AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } });
Envelope authenticated = await client.ReadUntilAsync(e => e.AuthResult is not null);
Assert.True(authenticated.AuthResult.Ok);
Assert.Equal(3UL, authenticated.RequestId);
Assert.True(authenticated.AuthResult.Permissions.IsAdmin);
Assert.False(authenticated.AuthResult.Self.IsGuest);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task InvalidVersionAndUnauthenticatedTextAreDisconnected(bool invalidVersion)
{
await using var fixture = new ServerFixture();
await using var client = await fixture.ConnectAsync();
client.Send(invalidVersion ? new() { ClientHello = new() { ProtoVersion = 1 } } : new() { TextMessage = new() { Body = "pre-auth" } });
Assert.NotEqual(0U, (await client.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Code);
}
[CppCliFact]
public async Task ExistingCppCliAuthenticatesAndChatsThroughManagedServer()
{
await using var fixture = new ServerFixture();
await using var receiver = await fixture.ConnectAsync();
User self = await receiver.LoginAsync("Managed");
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
{
WorkingDirectory = fixture.Directory, UseShellExecute = false,
RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true
};
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Cpp", "--text", "native interoperability", "--wait-ms", "10000" })
start.ArgumentList.Add(argument);
using var process = Process.Start(start)!;
Task<string> output = process.StandardOutput.ReadToEndAsync();
Task<string> error = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(receiver.Timeout.Token);
string log = await output + await error;
Assert.True(process.ExitCode == 0, log);
TextMessage text = (await receiver.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
Assert.Equal("native interoperability", text.Body);
Assert.NotEqual(self.Id, text.SenderId);
Assert.Contains("native interoperability", log);
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
private sealed class CppCliFactAttribute : FactAttribute
{
public CppCliFactAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
}
}
private sealed class ServerFixture : IAsyncDisposable
{
public string Directory { get; } = Path.Combine(Path.GetTempPath(), "voicecat-server-" + Guid.NewGuid().ToString("N"));
public VoiceServer Server { get; }
private readonly string fingerprint;
public ServerFixture(bool guests = true)
{
System.IO.Directory.CreateDirectory(Directory);
Server = new(Directory, new(IPAddress.Loopback, 0), guests);
using var credentials = ServerCredentials.LoadOrCreate(Directory, "VoiceCat Server");
fingerprint = credentials.CertificateFingerprint;
}
public async Task<Client> ConnectAsync()
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(Server.EndPoint);
return new(new(socket, TlsSession.CreateClient(value => value == fingerprint), CancellationToken.None));
}
public async ValueTask DisposeAsync()
{
await Server.DisposeAsync();
System.IO.Directory.Delete(Directory, true);
}
}
private sealed class Client : IAsyncDisposable
{
public CancellationTokenSource Timeout { get; } = new(TimeSpan.FromSeconds(30));
private readonly TlsControlConnection connection;
private readonly IAsyncEnumerator<Envelope> messages;
public Client(TlsControlConnection connection)
{
this.connection = connection;
messages = connection.ReadAsync(Timeout.Token).GetAsyncEnumerator();
}
public void Send(Envelope envelope) => Assert.True(connection.TrySend(envelope));
public async Task<Envelope> ReadUntilAsync(Func<Envelope, bool> predicate)
{
while (await messages.MoveNextAsync()) if (predicate(messages.Current)) return messages.Current;
throw new IOException("Connection ended before the expected message.");
}
public async Task<User> LoginAsync(string nickname)
{
Send(new() { RequestId = 1, ClientHello = new() { ProtoVersion = 2, ClientName = "Managed test" } });
Assert.Equal(1UL, (await ReadUntilAsync(e => e.ServerHello is not null)).RequestId);
Send(new() { RequestId = 2, AuthRequest = new() { Guest = new() { Nickname = nickname } } });
AuthResult auth = (await ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
Assert.True(auth.Ok, auth.Error);
ServerStateSnapshot state = (await ReadUntilAsync(e => e.ServerState is not null)).ServerState;
Assert.Equal(2, state.Channels.Count);
Assert.Contains(state.Users, user => user.Id == auth.Self.Id);
return auth.Self;
}
public async ValueTask DisposeAsync()
{
await messages.DisposeAsync();
await connection.DisposeAsync();
Timeout.Dispose();
}
}
}
@@ -9,6 +9,9 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" PrivateAssets="all" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" PrivateAssets="all" />
<ProjectReference Include="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" /> <ProjectReference Include="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<ProjectReference Include="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" /> <ProjectReference Include="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<ProjectReference Include="../../src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
<ProjectReference Include="../../src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<ProjectReference Include="../../src/VoiceCat.Server/VoiceCat.Server.csproj" />
<Using Include="Xunit" /> <Using Include="Xunit" />
<None Update="Fixtures/*.json" CopyToOutputDirectory="PreserveNewest" /> <None Update="Fixtures/*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
@@ -44,6 +44,14 @@
"resolved": "17.14.1", "resolved": "17.14.1",
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
}, },
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.TestPlatform.ObjectModel": { "Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive", "type": "Transitive",
"resolved": "17.14.1", "resolved": "17.14.1",
@@ -63,6 +71,41 @@
"resolved": "13.0.3", "resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
}, },
"SourceGear.sqlite3": {
"type": "Transitive",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==",
"dependencies": {
"SQLitePCLRaw.config.e_sqlite3": "3.0.2",
"SourceGear.sqlite3": "3.50.4.2"
}
},
"SQLitePCLRaw.config.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==",
"dependencies": {
"SQLitePCLRaw.provider.e_sqlite3": "3.0.2"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==",
"dependencies": {
"SQLitePCLRaw.core": "3.0.2"
}
},
"xunit.abstractions": { "xunit.abstractions": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.0.3", "resolved": "2.0.3",
@@ -103,6 +146,9 @@
"xunit.extensibility.core": "[2.9.3]" "xunit.extensibility.core": "[2.9.3]"
} }
}, },
"voicecat.codec": {
"type": "Project"
},
"voicecat.crypto": { "voicecat.crypto": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
@@ -110,11 +156,23 @@
"VoiceCat.Protocol": "[1.0.0, )" "VoiceCat.Protocol": "[1.0.0, )"
} }
}, },
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": { "voicecat.protocol": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
"Google.Protobuf": "[3.36.1, )" "Google.Protobuf": "[3.36.1, )"
} }
},
"voicecat.server": {
"type": "Project",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "[10.0.5, )",
"SQLitePCLRaw.bundle_e_sqlite3": "[3.0.2, )",
"SourceGear.sqlite3": "[3.50.4.2, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
} }
} }
} }