Add managed codec DSP and initial control server
This commit is contained in:
@@ -2,9 +2,9 @@ name: .NET port
|
||||
|
||||
on:
|
||||
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:
|
||||
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:
|
||||
|
||||
jobs:
|
||||
@@ -21,6 +21,9 @@ jobs:
|
||||
global-json-file: dotnet/global.json
|
||||
cache: true
|
||||
cache-dependency-path: dotnet/**/packages.lock.json
|
||||
- name: Build and stage native codec/DSP
|
||||
shell: pwsh
|
||||
run: ./dotnet/build-native.ps1
|
||||
- run: dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
- run: dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
- run: dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
@@ -52,5 +55,10 @@ jobs:
|
||||
ctest --preset dev
|
||||
./build/dev/bin/voicecat-dotnet-oracle build/dev/cpp-wire.json
|
||||
diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json build/dev/cpp-wire.json
|
||||
./build/dev/bin/voicecat-dotnet-password-oracle build/dev/cpp-passwords.json
|
||||
diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-passwords.json build/dev/cpp-passwords.json
|
||||
./build/dev/bin/voicecat-dotnet-dsp-oracle build/dev/cpp-noise.json
|
||||
pwsh -File dotnet/compare-dsp-fixtures.ps1 dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json build/dev/cpp-noise.json
|
||||
pwsh -File dotnet/build-native.ps1
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
VOICECAT_TLS_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-tls-oracle" 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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/dotnet/**/bin/
|
||||
/dotnet/**/obj/
|
||||
/dotnet/**/TestResults/
|
||||
/dotnet/artifacts/
|
||||
/build/
|
||||
/out/
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](
|
||||
on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23).
|
||||
> 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
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
```powershell
|
||||
./dotnet/build-native.ps1 # CMake + C compiler; pinned Opus with DRED + RNNoise
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
|
||||
@@ -49,6 +49,11 @@ endif()
|
||||
# ── Targets ───────────────────────────────────────────────────────────────────
|
||||
add_subdirectory(core)
|
||||
|
||||
option(VOICECAT_BUILD_DOTNET_NATIVE "Build native codec/DSP bindings for the .NET rewrite" OFF)
|
||||
if(VOICECAT_BUILD_DOTNET_NATIVE)
|
||||
add_subdirectory(dotnet/native)
|
||||
endif()
|
||||
|
||||
option(VOICECAT_BUILD_DOTNET_ORACLE "Build the .NET port conformance fixture generator" OFF)
|
||||
if(VOICECAT_BUILD_DOTNET_ORACLE)
|
||||
add_subdirectory(dotnet/oracle)
|
||||
|
||||
+133
@@ -10,6 +10,139 @@ up instantly. Newest status at the top.
|
||||
|
||||
## ▶ 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
|
||||
`b76181d` pushed to `origin/dotnet/foundations`. Added a nonblocking managed TLS 1.3
|
||||
session with certificate acceptance gate and directional media factories. Managed
|
||||
|
||||
@@ -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
|
||||
library internal copies are managed memory; owned-array clearing does not promise
|
||||
erasure of every runtime/library copy.
|
||||
|
||||
## Codec and DSP
|
||||
|
||||
`VoiceCat.Codec` and `VoiceCat.Dsp` call the desktop `voicecat_media` native library
|
||||
through source-generated `LibraryImport`. It links pinned Opus 1.5.2 and the existing
|
||||
vendored RNNoise; it has no dependency on libvoicecat or its C ABI. Fixed C signatures
|
||||
wrap Opus controls so P/Invoke never calls C varargs. SafeHandle owns every native
|
||||
encoder, decoder, DRED parser/state, and denoiser, including failed initialization.
|
||||
|
||||
`OpusOptions` is an immutable record. Supported PCM rates are 8/12/16/24/48 kHz,
|
||||
one or two interleaved channels, and integral 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
@@ -2,9 +2,11 @@
|
||||
|
||||
## .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
|
||||
./dotnet/build-native.ps1
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Porting VoiceCat to pure .NET / C#
|
||||
|
||||
**Status:** wire/media crypto and TLS/exporter foundations implemented under `dotnet/`,
|
||||
including C++ interoperability, persisted TOFU, and compatible server credentials.
|
||||
Codec/audio, managed server/client state, and UI phases remain planned.
|
||||
including C++ interoperability, persisted TOFU, compatible server credentials,
|
||||
codec/DSP wrappers, desktop native staging, and the initial managed TLS control server.
|
||||
Phase 4 remains in progress; 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.
|
||||
**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
|
||||
@@ -233,7 +235,7 @@ has been carrying.
|
||||
| TLS 1.3 | mbedTLS | **BouncyCastle `Org.BouncyCastle.Tls`** | MIT | See §3. **Not `SslStream`.** |
|
||||
| Media AEAD | libsodium ChaCha20-Poly1305 | **`System.Security.Cryptography.ChaCha20Poly1305`** | built-in | ⚠️ Check `ChaCha20Poly1305.IsSupported` at startup — it is OS-backed (Windows 10 1903+ / OpenSSL 1.1+). Fall back to BouncyCastle's `ChaCha20Poly1305` if false. Same 12-byte nonce, 16-byte tag → identical wire bytes. |
|
||||
| Anti-replay window | hand-rolled 64-bit | port verbatim | — | ~40 lines. Keep the RFC 3711 §3.3 ordering (replay-check → authenticate → *then* advance). This ordering is load-bearing; `test_media_aead.cpp` covers it. |
|
||||
| Argon2id | libsodium `crypto_pwhash` | **`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. |
|
||||
| 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). |
|
||||
@@ -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. |
|
||||
| Energy VAD | hand-rolled | port verbatim | — | ~60 lines. Trivial. |
|
||||
| Protobuf | protobuf-lite (C++) | **`Google.Protobuf`** + `Grpc.Tools` | BSD | ⚠️ Reference `Grpc.Tools` for the `protoc` MSBuild integration even though there is no gRPC here — it is the standard way to codegen `.proto` in a `.csproj`. `<Protobuf Include="../../proto/voicecat.proto" GrpcServices="None" />`. The `.proto` needs **zero changes**. |
|
||||
| SQLite | sqlite3 | **`Microsoft.Data.Sqlite`** | 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. |
|
||||
| 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. |
|
||||
@@ -356,11 +358,10 @@ internal static partial int opus_encode(IntPtr st, ReadOnlySpan<short> pcm, int
|
||||
Span<byte> data, int maxDataBytes);
|
||||
```
|
||||
|
||||
- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. Declare one
|
||||
overload per argument shape (`int`, `out int`) with `EntryPoint = "opus_encoder_ctl"`. This
|
||||
works on all the ABIs we target (x64 SysV, x64 Win, arm64 AAPCS) because all the CTLs we use
|
||||
take a single `int`/`int*`. **Note this explicitly in code comments** — it's a real
|
||||
portability caveat if a future CTL takes a different shape.
|
||||
- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. The implemented
|
||||
desktop binding uses fixed C entry points in `dotnet/native/media.c`; C calls the
|
||||
varargs function with the correct ABI. This also handles Apple arm64's different
|
||||
varargs calling convention. Only whitelisted single-int controls are accepted.
|
||||
- DRED (`opus_dred_alloc`, `opus_dred_parse`, `opus_decoder_dred_decode`) binds the same way.
|
||||
Guard with a runtime feature check as `opus_codec.cpp` does today.
|
||||
- **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link
|
||||
@@ -700,6 +701,19 @@ interoperability test command.
|
||||
**Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery
|
||||
test green; RNNoise output matches the C++ within tolerance.
|
||||
|
||||
**Checkpoint (2026-09-15):** implemented `VoiceCat.Codec`, `VoiceCat.Dsp`, safe native
|
||||
handles, fixed-signature C bindings, and independent desktop native staging. The native
|
||||
build pins the upstream Opus 1.5.2 release/checksum (matching the actual vcpkg baseline,
|
||||
despite older code comments referring to 1.6), enables DRED, and shares the existing
|
||||
vendored RNNoise sources/model. Tests cover 40 rate/channel/frame-size round trips,
|
||||
PLC, 40 dropped-frame DRED recovery formats, C++ denoising within one PCM unit, VAD
|
||||
hang time, and zero managed allocations over 1,000 combined processing cycles.
|
||||
At 8/12 kHz, DRED tests encode at 16 kHz and decode at the requested rate: this pinned
|
||||
encoder's activity analysis cannot emit DRED at 8/12 kHz. These encoding configurations
|
||||
are explicitly rejected. Its redundancy floor is 30 ms because two chunks are required
|
||||
to emit DRED; actual redundancy remains adaptive. Desktop CI builds/stages the library
|
||||
before testing. iOS static native packaging and device audio remain later phases.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Server (est. 3–4 weeks)
|
||||
@@ -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
|
||||
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.
|
||||
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
|
||||
question here** (§4).
|
||||
|
||||
@@ -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
@@ -1,10 +1,41 @@
|
||||
# VoiceCat .NET rewrite
|
||||
|
||||
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,
|
||||
audio, and UI migration are next. The existing
|
||||
encryption, TLS 1.3, persisted TOFU pins, server credentials, and an initial managed
|
||||
control server. Media relay, client state, audio, and UI migration are next. The existing
|
||||
C++ implementation remains the conformance oracle.
|
||||
|
||||
Codec/DSP wrappers now cover Opus, DRED recovery, RNNoise, and energy VAD. Build
|
||||
the desktop native library before running their tests (CMake and a C compiler required):
|
||||
|
||||
```powershell
|
||||
./dotnet/build-native.ps1
|
||||
```
|
||||
|
||||
The script downloads upstream Opus 1.5.2 with a pinned SHA-256, builds DRED-enabled
|
||||
Opus and the existing vendored RNNoise model, and stages `voicecat_media` plus license
|
||||
notices under `dotnet/artifacts/native/`. It builds independently of the C++ core and
|
||||
vcpkg. On Windows, Visual Studio's C++ workload works with the default generator;
|
||||
for this repository's MinGW toolchain use:
|
||||
|
||||
```powershell
|
||||
./dotnet/build-native.ps1 -Generator Ninja -CCompiler C:/tools/msys64/ucrt64/bin/cc.exe
|
||||
```
|
||||
|
||||
Linux/macOS can run the same script with PowerShell, or use CMake directly:
|
||||
|
||||
```sh
|
||||
cmake -S dotnet/native -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build dotnet/artifacts/native-build --target voicecat_media --parallel 2
|
||||
cmake --install dotnet/artifacts/native-build --component DotnetMedia --prefix dotnet/artifacts/native
|
||||
```
|
||||
|
||||
MSBuild copies the staged library into managed build/publish output for the selected
|
||||
RID. Override `VoiceCatNativeRid` or `VoiceCatNativeDirectory` for explicit staging;
|
||||
`RuntimeIdentifier` takes priority over the SDK's host RID. Cross-compilation is not
|
||||
automatic. iOS static linking and audio-device shims belong to later client phases.
|
||||
Native codec/DSP tests require this library; they do not silently skip.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
@@ -47,6 +78,16 @@ and 65536. Keys contain bytes 0–31; payload bytes count upward from zero. The
|
||||
20-byte header has type 1, marker flag, codec 0, SSRC `0xcafebabe`, timestamp 960.
|
||||
Both managed crypto backends must match these bytes.
|
||||
|
||||
The DSP oracle calls the existing C++ `ApmProcessor` with 200 deterministic noise
|
||||
frames and records the final 960 samples. Regenerate its fixture with:
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-dsp-oracle
|
||||
./build/dev/bin/voicecat-dotnet-dsp-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json
|
||||
```
|
||||
|
||||
The managed test allows a one-unit PCM difference for floating-point rounding.
|
||||
|
||||
## TLS interoperability
|
||||
|
||||
The optional TLS oracle uses the existing mbedTLS context and libsodium media crypto.
|
||||
@@ -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
|
||||
and certificate acceptance requirements.
|
||||
|
||||
## Next checkpoint
|
||||
## Managed server checkpoint
|
||||
|
||||
Port codec/DSP wrappers and their native packaging per Phase 3 of the porting plan.
|
||||
The managed server follows, tested first with the existing C++ CLI.
|
||||
Run the TLS control server on loopback (optional arguments: data directory, TCP port):
|
||||
|
||||
```powershell
|
||||
dotnet run --project dotnet/src/VoiceCat.Server -c Release -- ./voicecat-data 7443
|
||||
./build/dev/bin/vccli.exe --host 127.0.0.1 --port 7443 --nick Guest --text "hello"
|
||||
```
|
||||
|
||||
It creates or imports `server_identity.key`, `server.crt`, `server.key`, and
|
||||
`voicecat.db`. An empty channel table gets Lobby and Music Room; existing channels
|
||||
are preserved. Guests are enabled by the CLI; hosting `VoiceServer` directly can
|
||||
disable them. Existing accounts authenticate without resetting passwords. Account
|
||||
creation is currently available through `AccountStore`; bootstrap/admin CLI and
|
||||
wire administration are pending.
|
||||
|
||||
Tests cover real TLS sockets, authentication retries, snapshots, channel moves,
|
||||
text routing, sender attribution, ping, and disconnect events. Enable native checks:
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-password-oracle voicecat-dotnet-database-oracle vccli
|
||||
$env:VOICECAT_DATABASE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-database-oracle.exe).Path
|
||||
$env:VOICECAT_VCCLI = (Resolve-Path build/dev/bin/vccli.exe).Path
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
The database oracle creates an account/channel using the shipped C++ database code;
|
||||
managed code imports and authenticates it, then C++ authenticates a managed-created
|
||||
account. CI also regenerates the libsodium password fixture. Native checks require
|
||||
the optional `VOICECAT_BUILD_DOTNET_ORACLE=ON` configure flag and a real-deps build.
|
||||
|
||||
Phase 4 remains in progress: UDP/SFU relay, streams, protected channel joins,
|
||||
administration, moderation, and production configuration are the next server work.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<Project Path="src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<Project Path="src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
|
||||
<Project Path="src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
|
||||
<Project Path="src/VoiceCat.Server/VoiceCat.Server.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
|
||||
|
||||
@@ -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" }
|
||||
@@ -20,6 +20,10 @@ foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter packages
|
||||
[xml]$spec = Get-Content -Raw -LiteralPath $nuspec
|
||||
$license = $spec.package.metadata.license
|
||||
if ($license.type -eq 'expression' -and $allowed -contains $license.InnerText) { continue }
|
||||
# This pinned package contains public-domain SQLite builds; no NuGet license metadata.
|
||||
if ($id -eq 'sourcegear.sqlite3' -and $version -eq '3.50.4.2' -and
|
||||
$spec.package.metadata.projectUrl -eq 'https://sqlite.org/' -and
|
||||
$spec.package.metadata.repository.commit -eq '9a2d8281d8f714fe54f7cbcd122479d17b533e89') { continue }
|
||||
# This legacy pinned package predates NuGet license expressions (Apache-2.0).
|
||||
if ($id -eq 'xunit.abstractions' -and $version -eq '2.0.3' -and
|
||||
$spec.package.metadata.licenseUrl -eq 'https://raw.githubusercontent.com/xunit/xunit/master/license.txt') { continue }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$ExpectedPath,
|
||||
[Parameter(Mandatory)][string]$ActualPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expected = (Get-Content -Raw -LiteralPath $ExpectedPath | ConvertFrom-Json).samples
|
||||
$actual = (Get-Content -Raw -LiteralPath $ActualPath | ConvertFrom-Json).samples
|
||||
if ($expected.Count -ne 960 -or $actual.Count -ne $expected.Count) { throw 'DSP fixture sample counts differ.' }
|
||||
for ($i = 0; $i -lt $expected.Count; $i++) {
|
||||
if ([Math]::Abs($expected[$i] - $actual[$i]) -gt 1) { throw "DSP fixture differs at sample $i." }
|
||||
}
|
||||
Write-Output 'C++ DSP fixture matches within one PCM unit.'
|
||||
@@ -0,0 +1,101 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(VoiceCatMedia LANGUAGES C)
|
||||
|
||||
if(MSVC)
|
||||
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
set(OPUS_STATIC_RUNTIME ON CACHE BOOL "" FORCE)
|
||||
endif()
|
||||
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
|
||||
message(FATAL_ERROR "iOS static NativeReference packaging belongs to the later client phase.")
|
||||
endif()
|
||||
|
||||
if(NOT TARGET Opus::opus)
|
||||
set(bundled_default OFF)
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
set(bundled_default ON)
|
||||
endif()
|
||||
option(VOICECAT_BUNDLED_OPUS "Build pinned Opus with DRED support" ${bundled_default})
|
||||
if(VOICECAT_BUNDLED_OPUS)
|
||||
include(FetchContent)
|
||||
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
||||
set(OPUS_DRED ON CACHE BOOL "" FORCE)
|
||||
set(OPUS_DEEP_PLC ON CACHE BOOL "" FORCE)
|
||||
set(OPUS_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
|
||||
set(OPUS_BUILD_TESTING OFF CACHE BOOL "" FORCE)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
FetchContent_Declare(opus
|
||||
URL https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
|
||||
URL_HASH SHA256=65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1
|
||||
TIMEOUT 60
|
||||
INACTIVITY_TIMEOUT 30
|
||||
DOWNLOAD_EXTRACT_TIMESTAMP TRUE)
|
||||
FetchContent_MakeAvailable(opus)
|
||||
set(VOICECAT_OPUS_LICENSE "${opus_SOURCE_DIR}/COPYING")
|
||||
else()
|
||||
find_package(Opus CONFIG REQUIRED)
|
||||
endif()
|
||||
endif()
|
||||
if(NOT VOICECAT_OPUS_LICENSE)
|
||||
find_file(VOICECAT_OPUS_LICENSE NAMES copyright COPYING HINTS "${Opus_DIR}" NO_DEFAULT_PATH)
|
||||
endif()
|
||||
if(NOT VOICECAT_OPUS_LICENSE)
|
||||
message(FATAL_ERROR "Set VOICECAT_OPUS_LICENSE to the imported Opus copyright file for native staging.")
|
||||
endif()
|
||||
set(RNNOISE_DIR "${CMAKE_CURRENT_LIST_DIR}/../../third_party/rnnoise")
|
||||
if(NOT TARGET rnnoise)
|
||||
add_library(rnnoise STATIC
|
||||
${RNNOISE_DIR}/src/denoise.c ${RNNOISE_DIR}/src/rnn.c
|
||||
${RNNOISE_DIR}/src/pitch.c ${RNNOISE_DIR}/src/kiss_fft.c
|
||||
${RNNOISE_DIR}/src/celt_lpc.c ${RNNOISE_DIR}/src/nnet.c
|
||||
${RNNOISE_DIR}/src/nnet_default.c ${RNNOISE_DIR}/src/parse_lpcnet_weights.c
|
||||
${RNNOISE_DIR}/src/rnnoise_data.c ${RNNOISE_DIR}/src/rnnoise_tables.c)
|
||||
target_include_directories(rnnoise PUBLIC ${RNNOISE_DIR}/include PRIVATE ${RNNOISE_DIR}/src)
|
||||
target_compile_definitions(rnnoise PRIVATE DISABLE_DEBUG_FLOAT)
|
||||
if(MSVC)
|
||||
target_compile_definitions(rnnoise PRIVATE restrict=__restrict)
|
||||
endif()
|
||||
target_compile_features(rnnoise PRIVATE c_std_11)
|
||||
set_target_properties(rnnoise PROPERTIES POSITION_INDEPENDENT_CODE ON C_VISIBILITY_PRESET hidden)
|
||||
endif()
|
||||
|
||||
add_library(voicecat_media SHARED media.c)
|
||||
target_compile_features(voicecat_media PRIVATE c_std_99)
|
||||
target_link_libraries(voicecat_media PRIVATE Opus::opus rnnoise)
|
||||
set_target_properties(voicecat_media PROPERTIES C_VISIBILITY_PRESET hidden)
|
||||
if(WIN32)
|
||||
set_target_properties(voicecat_media PROPERTIES PREFIX "")
|
||||
endif()
|
||||
if(NOT WIN32)
|
||||
target_link_libraries(voicecat_media PRIVATE m)
|
||||
elseif(MINGW)
|
||||
target_link_options(voicecat_media PRIVATE -static-libgcc -static)
|
||||
endif()
|
||||
|
||||
if(NOT VOICECAT_DOTNET_RID)
|
||||
string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" architecture)
|
||||
if(architecture MATCHES "^(amd64|x86_64)$")
|
||||
set(architecture x64)
|
||||
elseif(architecture MATCHES "^(aarch64|arm64)$")
|
||||
set(architecture arm64)
|
||||
else()
|
||||
message(FATAL_ERROR "Set VOICECAT_DOTNET_RID for architecture ${architecture}")
|
||||
endif()
|
||||
if(WIN32)
|
||||
set(platform win)
|
||||
elseif(APPLE)
|
||||
set(platform osx)
|
||||
else()
|
||||
set(platform linux)
|
||||
endif()
|
||||
set(VOICECAT_DOTNET_RID "${platform}-${architecture}")
|
||||
endif()
|
||||
|
||||
install(TARGETS voicecat_media
|
||||
RUNTIME DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia
|
||||
LIBRARY DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia)
|
||||
install(FILES ${RNNOISE_DIR}/COPYING DESTINATION licenses RENAME RNNoise.txt COMPONENT DotnetMedia)
|
||||
install(FILES ${CMAKE_CURRENT_LIST_DIR}/NOTICE.txt DESTINATION licenses COMPONENT DotnetMedia)
|
||||
if(VOICECAT_OPUS_LICENSE)
|
||||
install(FILES ${VOICECAT_OPUS_LICENSE} DESTINATION licenses RENAME Opus.txt COMPONENT DotnetMedia)
|
||||
endif()
|
||||
@@ -0,0 +1,12 @@
|
||||
VoiceCat desktop codec/DSP bindings
|
||||
|
||||
Opus 1.5.2: BSD-3-Clause. See Opus.txt for copyright, license, and patent notices.
|
||||
Upstream: https://opus-codec.org/
|
||||
Release: https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
|
||||
SHA-256: 65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1
|
||||
|
||||
RNNoise code: BSD-3-Clause. See RNNoise.txt.
|
||||
RNNoise model weights: CC0-1.0, as recorded in third_party/README.md.
|
||||
Upstream: https://github.com/xiph/rnnoise
|
||||
Vendored commit: 70f1d256acd4b34a572f999a05c87bf00b67730d
|
||||
CC0: https://creativecommons.org/publicdomain/zero/1.0/
|
||||
@@ -0,0 +1,55 @@
|
||||
#include <opus.h>
|
||||
#include "rnnoise.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#define VC_EXPORT __declspec(dllexport)
|
||||
#else
|
||||
#define VC_EXPORT __attribute__((visibility("default")))
|
||||
#endif
|
||||
|
||||
VC_EXPORT const char *vcm_opus_version(void) { return opus_get_version_string(); }
|
||||
VC_EXPORT const char *vcm_opus_error(int error) { return opus_strerror(error); }
|
||||
VC_EXPORT OpusEncoder *vcm_encoder_create(int rate, int channels, int application, int *error) {
|
||||
return opus_encoder_create(rate, channels, application, error);
|
||||
}
|
||||
VC_EXPORT void vcm_encoder_destroy(OpusEncoder *encoder) { opus_encoder_destroy(encoder); }
|
||||
/* C varargs are called here, not through P/Invoke: Apple arm64 uses a distinct varargs ABI. */
|
||||
VC_EXPORT int vcm_encoder_set(OpusEncoder *encoder, int request, int value) {
|
||||
switch (request) {
|
||||
case OPUS_SET_BITRATE_REQUEST: case OPUS_SET_MAX_BANDWIDTH_REQUEST:
|
||||
case OPUS_SET_COMPLEXITY_REQUEST: case OPUS_SET_INBAND_FEC_REQUEST:
|
||||
case OPUS_SET_DTX_REQUEST: case OPUS_SET_PACKET_LOSS_PERC_REQUEST:
|
||||
case OPUS_SET_DRED_DURATION_REQUEST:
|
||||
return opus_encoder_ctl(encoder, request, value);
|
||||
default: return OPUS_BAD_ARG;
|
||||
}
|
||||
}
|
||||
VC_EXPORT int vcm_encoder_get_dred(OpusEncoder *encoder, int *duration) {
|
||||
return opus_encoder_ctl(encoder, OPUS_GET_DRED_DURATION(duration));
|
||||
}
|
||||
VC_EXPORT int vcm_encode(OpusEncoder *encoder, const short *pcm, int samples, unsigned char *packet, int capacity) {
|
||||
return opus_encode(encoder, pcm, samples, packet, capacity);
|
||||
}
|
||||
VC_EXPORT OpusDecoder *vcm_decoder_create(int rate, int channels, int *error) {
|
||||
return opus_decoder_create(rate, channels, error);
|
||||
}
|
||||
VC_EXPORT void vcm_decoder_destroy(OpusDecoder *decoder) { opus_decoder_destroy(decoder); }
|
||||
VC_EXPORT int vcm_decode(OpusDecoder *decoder, const unsigned char *packet, int length, short *pcm, int samples, int fec) {
|
||||
return opus_decode(decoder, packet, length, pcm, samples, fec);
|
||||
}
|
||||
VC_EXPORT OpusDREDDecoder *vcm_dred_decoder_create(int *error) { return opus_dred_decoder_create(error); }
|
||||
VC_EXPORT void vcm_dred_decoder_destroy(OpusDREDDecoder *decoder) { opus_dred_decoder_destroy(decoder); }
|
||||
VC_EXPORT OpusDRED *vcm_dred_create(int *error) { return opus_dred_alloc(error); }
|
||||
VC_EXPORT void vcm_dred_destroy(OpusDRED *dred) { opus_dred_free(dred); }
|
||||
VC_EXPORT int vcm_dred_parse(OpusDREDDecoder *decoder, OpusDRED *dred, const unsigned char *packet,
|
||||
int length, int samples, int rate, int *end) {
|
||||
return opus_dred_parse(decoder, dred, packet, length, samples, rate, end, 0);
|
||||
}
|
||||
VC_EXPORT int vcm_dred_decode(OpusDecoder *decoder, OpusDRED *dred, int offset, short *pcm, int samples) {
|
||||
return opus_decoder_dred_decode(decoder, dred, offset, pcm, samples);
|
||||
}
|
||||
VC_EXPORT DenoiseState *vcm_rnnoise_create(void) { return rnnoise_create(NULL); }
|
||||
VC_EXPORT void vcm_rnnoise_destroy(DenoiseState *state) { rnnoise_destroy(state); }
|
||||
VC_EXPORT float vcm_rnnoise_process(DenoiseState *state, float *output, const float *input) {
|
||||
return rnnoise_process_frame(state, output, input);
|
||||
}
|
||||
@@ -7,3 +7,19 @@ add_executable(voicecat-dotnet-tls-oracle tls.cpp)
|
||||
target_link_libraries(voicecat-dotnet-tls-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-tls-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-tls-oracle PRIVATE cxx_std_20)
|
||||
|
||||
add_executable(voicecat-dotnet-dsp-oracle dsp.cpp)
|
||||
target_link_libraries(voicecat-dotnet-dsp-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-dsp-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-dsp-oracle PRIVATE cxx_std_20)
|
||||
|
||||
find_package(unofficial-sodium CONFIG REQUIRED)
|
||||
add_executable(voicecat-dotnet-password-oracle passwords.cpp)
|
||||
target_link_libraries(voicecat-dotnet-password-oracle PRIVATE unofficial-sodium::sodium)
|
||||
target_compile_features(voicecat-dotnet-password-oracle PRIVATE cxx_std_20)
|
||||
|
||||
if(VOICECAT_BUILD_SERVER)
|
||||
add_executable(voicecat-dotnet-database-oracle database.cpp)
|
||||
target_link_libraries(voicecat-dotnet-database-oracle PRIVATE voicecat::server)
|
||||
target_compile_features(voicecat-dotnet-database-oracle PRIVATE cxx_std_20)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#include "audio/apm_processor.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) return 2;
|
||||
auto processor = voicecat::audio::ApmProcessor::create();
|
||||
if (!processor) return 1;
|
||||
std::vector<int16_t> pcm(960);
|
||||
uint32_t random = 0x12345678;
|
||||
for (int frame = 0; frame < 200; ++frame) {
|
||||
for (auto &sample : pcm) {
|
||||
random ^= random << 13;
|
||||
random ^= random >> 17;
|
||||
random ^= random << 5;
|
||||
sample = static_cast<int16_t>(static_cast<int>(random % 6001) - 3000);
|
||||
}
|
||||
if (!processor->process_capture(pcm.data(), static_cast<int>(pcm.size()), 48000)) return 1;
|
||||
}
|
||||
std::ofstream output(argv[1]);
|
||||
output << "{\"samples\":[";
|
||||
for (size_t i = 0; i < pcm.size(); ++i) {
|
||||
if (i) output << ',';
|
||||
output << pcm[i];
|
||||
}
|
||||
output << "]}\n";
|
||||
return output ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace VoiceCat.Codec;
|
||||
|
||||
public sealed class OpusEncoder : IDisposable
|
||||
{
|
||||
private readonly OpusEncoderHandle handle;
|
||||
public OpusOptions Options { get; }
|
||||
public bool SupportsDeepRedundancy { get; }
|
||||
public static string Version => Marshal.PtrToStringUTF8(NativeMethods.Version())!;
|
||||
|
||||
public OpusEncoder(OpusOptions? options = null)
|
||||
{
|
||||
Options = options ?? new();
|
||||
Options.Validate();
|
||||
handle = new(NativeMethods.EncoderCreate(Options.SampleRate, Options.Channels, (int)Options.Application, out int error));
|
||||
try
|
||||
{
|
||||
OpusException.Check(error);
|
||||
if (handle.IsInvalid) throw new OutOfMemoryException();
|
||||
Set(4002, Options.Bitrate);
|
||||
Set(4004, Options.MaximumBandwidthHz switch { 0 => 1105, <= 8000 => 1101, <= 12000 => 1102, <= 16000 => 1103, <= 24000 => 1104, _ => 1105 });
|
||||
Set(4010, Options.Complexity);
|
||||
Set(4012, Options.ForwardErrorCorrection ? 1 : 0);
|
||||
Set(4016, Options.DiscontinuousTransmission ? 1 : 0);
|
||||
Set(4014, Options.ExpectedPacketLossPercent);
|
||||
int support = NativeMethods.EncoderGetDred(handle, out _);
|
||||
if (support != -5) OpusException.Check(support);
|
||||
SupportsDeepRedundancy = support == 0 && Options.SampleRate >= 16000;
|
||||
if (Options.DeepRedundancy && !SupportsDeepRedundancy)
|
||||
throw new NotSupportedException("DRED encoding requires a DRED-enabled libopus build and a PCM rate of at least 16 kHz.");
|
||||
if (SupportsDeepRedundancy)
|
||||
// Opus 1.5.2 requires two redundancy chunks; 20 ms alone cannot produce DRED.
|
||||
Set(4050, Options.DeepRedundancy ? Math.Max(3, (Options.FrameDurationMilliseconds + 9) / 10) : 0);
|
||||
}
|
||||
catch { handle.Dispose(); throw; }
|
||||
}
|
||||
|
||||
private void Set(int request, int value) => OpusException.Check(NativeMethods.EncoderSet(handle, request, value));
|
||||
|
||||
public unsafe int Encode(ReadOnlySpan<short> pcm, Span<byte> packet)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
|
||||
if (pcm.Length != Options.SamplesPerChannel * Options.Channels) throw new ArgumentException("PCM must contain exactly one interleaved frame.", nameof(pcm));
|
||||
if (packet.IsEmpty) throw new ArgumentException("Packet storage must not be empty.", nameof(packet));
|
||||
if (MemoryMarshal.AsBytes(pcm).Overlaps(packet)) throw new ArgumentException("PCM and packet storage must not overlap.");
|
||||
fixed (short* input = pcm)
|
||||
fixed (byte* output = packet)
|
||||
return OpusException.Check(NativeMethods.Encode(handle, input, Options.SamplesPerChannel, output, packet.Length));
|
||||
}
|
||||
|
||||
public void Dispose() => handle.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace VoiceCat.Codec;
|
||||
|
||||
public sealed class OpusException : Exception
|
||||
{
|
||||
public int ErrorCode { get; }
|
||||
internal OpusException(int error) : base(Marshal.PtrToStringUTF8(NativeMethods.Error(error))) => ErrorCode = error;
|
||||
internal static int Check(int result) => result < 0 ? throw new OpusException(result) : result;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace VoiceCat.Codec;
|
||||
|
||||
public enum OpusApplication { Voip = 2048, Audio = 2049, LowDelay = 2051 }
|
||||
|
||||
public sealed record OpusOptions
|
||||
{
|
||||
public int SampleRate { get; init; } = 48000;
|
||||
public int Channels { get; init; } = 1;
|
||||
public int FrameDurationMilliseconds { get; init; } = 20;
|
||||
public int Bitrate { get; init; } = 24000;
|
||||
public int MaximumBandwidthHz { get; init; }
|
||||
public int Complexity { get; init; } = 10;
|
||||
public int ExpectedPacketLossPercent { get; init; }
|
||||
public bool ForwardErrorCorrection { get; init; } = true;
|
||||
public bool DiscontinuousTransmission { get; init; }
|
||||
public bool DeepRedundancy { get; init; }
|
||||
public OpusApplication Application { get; init; } = OpusApplication.Voip;
|
||||
public int SamplesPerChannel => SampleRate / 1000 * FrameDurationMilliseconds;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (SampleRate is not (8000 or 12000 or 16000 or 24000 or 48000)) throw new ArgumentOutOfRangeException(nameof(SampleRate));
|
||||
if (Channels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(Channels));
|
||||
if (FrameDurationMilliseconds is not (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);
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Protocol/VoiceCat.Protocol.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" />
|
||||
<None Update="Fixtures/*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -44,6 +44,14 @@
|
||||
"resolved": "17.14.1",
|
||||
"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": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.14.1",
|
||||
@@ -63,6 +71,41 @@
|
||||
"resolved": "13.0.3",
|
||||
"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": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.3",
|
||||
@@ -103,6 +146,9 @@
|
||||
"xunit.extensibility.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"voicecat.codec": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
@@ -110,11 +156,23 @@
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"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, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user