Retire legacy sources and verify managed iOS deployment
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s

This commit is contained in:
2026-09-19 22:40:48 +02:00
parent 42e3bbe14c
commit c9ed832459
109 changed files with 877 additions and 4981 deletions
+46 -166
View File
@@ -1,182 +1,62 @@
# VoiceCat .NET rewrite
# VoiceCat .NET implementation
The .NET 10 implementation includes protocol, TLS/TOFU and media crypto, the control and
UDP server, client state, real-time audio, a console client and the Windows application.
The existing C++ implementation remains the conformance oracle while Apple clients move.
This is the supported VoiceCat implementation. It contains the protocol, TLS and media
cryptography, server, client state, codec/DSP bindings, audio engine, and headless CLI.
Codec/DSP wrappers now cover Opus, DRED recovery, RNNoise, and energy VAD. Build
the desktop native library before running their tests (CMake and a C compiler required):
## Build and test
Stage the small native Opus/RNNoise library, then build the managed solution:
```powershell
./dotnet/build-native.ps1
```
The script downloads upstream Opus 1.5.2 with a pinned SHA-256, builds DRED-enabled
Opus and the existing vendored RNNoise model, and stages `voicecat_media` plus license
notices under `dotnet/artifacts/native/`. It builds independently of the C++ core and
vcpkg. On Windows, Visual Studio's C++ workload works with the default generator;
for this repository's MinGW toolchain use:
```powershell
./dotnet/build-native.ps1 -Generator Ninja -CCompiler C:/tools/msys64/ucrt64/bin/cc.exe
```
Linux/macOS can run the same script with PowerShell, or use CMake directly:
```sh
cmake -S dotnet/native -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release
cmake --build dotnet/artifacts/native-build --target voicecat_media --parallel 2
cmake --install dotnet/artifacts/native-build --component DotnetMedia --prefix dotnet/artifacts/native
```
MSBuild copies the staged library into managed build/publish output for the selected
RID. Override `VoiceCatNativeRid` or `VoiceCatNativeDirectory` for explicit staging;
`RuntimeIdentifier` takes priority over the SDK's host RID. Cross-compilation is not
automatic. iOS static linking and audio-device shims belong to later client phases.
Native codec/DSP tests require this library; they do not silently skip.
From the repository root:
```powershell
dotnet restore dotnet/VoiceCat.slnx --locked-mode
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
./dotnet/check-licenses.ps1
```
Run the managed console client interactively:
The native source lives in `native/media` and `native/rnnoise`; build output is staged under
`dotnet/artifacts/native`. The shim exposes only fixed Opus/DRED and RNNoise entry points. It
does not contain protocol, networking, cryptography, client state, or server behavior.
For an explicit native build:
```bash
cmake -S native/media -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release
cmake --build dotnet/artifacts/native-build --target voicecat_media --parallel 2
cmake --install dotnet/artifacts/native-build --component DotnetMedia \
--prefix dotnet/artifacts/native
```
## Projects
- `VoiceCat.Protocol` — generated protobuf types and bounded control framing.
- `VoiceCat.Crypto` — BouncyCastle TLS 1.3/exporters, TOFU, identity, AEAD, replay protection,
and Argon2id.
- `VoiceCat.Codec` / `VoiceCat.Dsp` — managed owners of the narrow native media ABI.
- `VoiceCat.Audio` — jitter, loss recovery, mixing, input activation, and PCM rings.
- `VoiceCat.Core` — managed client connection and session state.
- `VoiceCat.Server` — TLS control, encrypted UDP relay, SQLite state, administration, and CLI.
- `VoiceCat.Cli` — supported interactive and deterministic headless client.
- `VoiceCat.Tests` — managed unit, integration, allocation, and end-to-end behavior tests.
`proto/voicecat.proto` is the only protobuf schema. Generated C# is build output.
## Run
```powershell
dotnet run --project dotnet/src/VoiceCat.Cli -- --host 127.0.0.1 --port 8384 --nickname Alice --trust-first
dotnet run --project dotnet/src/VoiceCat.Server -- --data-dir ./voicecat-data
dotnet run --project dotnet/src/VoiceCat.Cli -- \
--host 127.0.0.1 --port 8384 --nickname Alice --trust-first
```
Plain input sends channel text; `/join ID` changes channel and `/quit` exits. Headless
conformance options include `--voice`, `--send-text`, `--expect-text`, `--expect-voice`,
`--start-delay-ms` and `--timeout-seconds`; `--help` lists the complete syntax.
Use `--help` on either executable for current options. Server publishing is handled by
`dotnet/publish-server.ps1` and the platform packaging files under `packaging/`.
Dependencies are pinned in project files and lock files. Generated protobuf is build
output; the schema remains `core/proto/voicecat.proto`. Production dependencies are
Google.Protobuf (BSD-3-Clause), BouncyCastle.Cryptography (MIT), and the build-only
Grpc.Tools (Apache-2.0). No GPL/LGPL dependencies are permitted.
## Compatibility policy
## C# conventions
Use file-scoped namespaces, standard .NET naming, immutable values where useful, and
spans for binary data. Invalid arguments throw; invalid network packets use parsing
results or protocol exceptions. Async APIs accept cancellation tokens.
Comments explain constraints that cannot be made clear in code. Avoid banners,
implementation history, and narration. Keep durable design explanations in `docs/`.
## Regenerating C++ fixtures
The optional oracle target calls the existing C++ protobuf, header serializer, and
libsodium media implementation. From the root, with the development dependencies:
```powershell
cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON
cmake --build --preset dev --target voicecat-dotnet-oracle
New-Item -ItemType Directory -Force dotnet/tests/VoiceCat.Tests/Fixtures
./build/dev/bin/voicecat-dotnet-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
git diff -- dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
```
On Linux/macOS, omit `.exe` and create the directory with `mkdir -p`.
The oracle writes deterministic JSON directly, avoiding shell output encoding.
Fixtures contain a framed ClientHello and media packets at counters 0, 1, 65535,
and 65536. Keys contain bytes 031; payload bytes count upward from zero. The
20-byte header has type 1, marker flag, codec 0, SSRC `0xcafebabe`, timestamp 960.
Both managed crypto backends must match these bytes.
The DSP oracle calls the existing C++ `ApmProcessor` with 200 deterministic noise
frames and records the final 960 samples. Regenerate its fixture with:
```powershell
cmake --build --preset dev --target voicecat-dotnet-dsp-oracle
./build/dev/bin/voicecat-dotnet-dsp-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json
```
The managed test allows a one-unit PCM difference for floating-point rounding.
## TLS interoperability
The optional TLS oracle uses the existing mbedTLS context and libsodium media crypto.
The test authenticates an encrypted challenge in both directions, proving exporter
compatibility without sending raw keys. It also loads the C++ server's credential files.
```powershell
cmake --build --preset dev --target voicecat-dotnet-tls-oracle
$env:VOICECAT_TLS_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-tls-oracle.exe).Path
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
```
On Linux/macOS, set `VOICECAT_TLS_ORACLE` to the absolute executable path without
`.exe`. Without that variable, only this native interoperability test is skipped;
managed TLS loopback, rejection, persistence, and wire tests still run. CI's C++
conformance job requires the native test. See `docs/api-dotnet.md` for ownership
and certificate acceptance requirements.
## Managed server checkpoint
Run the TLS control server on loopback (optional arguments: data directory, TCP port):
```powershell
dotnet run --project dotnet/src/VoiceCat.Server -c Release -- ./voicecat-data 7443
./build/dev/bin/vccli.exe --host 127.0.0.1 --port 7443 --nick Guest --text "hello"
```
It creates or imports `server_identity.key`, `server.crt`, `server.key`, and
`voicecat.db`. An empty channel table gets Lobby and Music Room; existing channels
are preserved. Guests are enabled by the CLI; hosting `VoiceServer` directly can
disable them. Existing accounts authenticate without resetting passwords. Account
creation is currently available through `AccountStore`; bootstrap/admin CLI and
wire administration are pending.
Tests cover real TLS sockets, authentication retries, snapshots, channel moves,
text routing, sender attribution, ping, and disconnect events. Enable native checks:
```powershell
cmake --build --preset dev --target voicecat-dotnet-password-oracle voicecat-dotnet-database-oracle vccli
$env:VOICECAT_DATABASE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-database-oracle.exe).Path
$env:VOICECAT_VCCLI = (Resolve-Path build/dev/bin/vccli.exe).Path
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
```
The database oracle creates an account/channel using the shipped C++ database code;
managed code imports and authenticates it, then C++ authenticates a managed-created
account. CI also regenerates the libsodium password fixture. Native checks require
the optional `VOICECAT_BUILD_DOTNET_ORACLE=ON` configure flag and a real-deps build.
The server also advertises UDP on the TCP port number, supports voice subscription
and stream signaling, and reseals encoded audio for subscribers in the same channel.
UDP binding fixes the first endpoint for the session; reconnect after endpoint changes.
Protected joins, administration, moderation and production configuration remain
before Phase 4 completion. The server's media-aware reaper defaults to 45 seconds
of inactivity with a 15-second sweep. Parsed control envelopes, valid encrypted
voice and keepalives from bound endpoints refresh activity; invalid media does not.
`VoiceServerOptions` configures timeouts and capacity; zero idle timeout disables
reaping. The constructor overload accepts `TimeProvider` for deterministic expiry tests.
Enable deterministic native voice interoperability (no audio hardware required):
```powershell
cmake --build --preset dev --target voicecat-dotnet-voice-oracle
$env:VOICECAT_VOICE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-voice-oracle.exe).Path
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
```
Two existing C++ clients authenticate, join Lobby or Music Room, publish three
concurrent streams, feed PCM, and verify decoded energy and metadata in both directions.
The native clients use external capture/playback to avoid device dependencies in CI.
`MediaFanoutTests` separately verifies 50-subscriber routing/resealing without managed
allocations after warm-up and reports throughput; socket scheduling is excluded.
The transport load test delivers all 2,500 recipient packets from a paced 50 pps sender.
Native `vccli --test-tone-ms 4000` runs finite external capture/playback, feeds a tone,
and fails without decoded remote audio. Tests start two CLI processes in mono/stereo
channels and also verify channel text. Normal `--voice` now explicitly subscribes before
announcing its microphone stream. No C ABI or wire changes were needed.
The managed CLI also accepts `--test-tone-seconds N` (maximum 3600) for a finite, accurately
paced peer used during device checks. Use a channel with Opus DTX disabled, such as the seeded
Music Room: a steady sine is intentionally classified as non-speech by DTX and becomes comfort
noise after its hangover period in Lobby. The finite peer prints received voice energy and exits
normally when its requested duration completes.
The managed implementation is the source of truth. Frozen vectors under
`tests/VoiceCat.Tests/Fixtures` protect concrete wire, Argon2id, and RNNoise behavior, but the
repository no longer builds or tests against the retired C++ implementation. Protocol or
persistence changes must be versioned when current supported releases need migration; they do
not need to retain compatibility with unsupported pre-rewrite releases.
+1 -1
View File
@@ -10,7 +10,7 @@ build_one() {
local compiler
compiler=$(xcrun --sdk "$sdk" --find clang)
local build_dir="$script_dir/artifacts/native-build-$rid-cmake"
cmake -S "$script_dir/native" -B "$build_dir" \
cmake -S "$script_dir/../native/media" -B "$build_dir" \
-DCMAKE_SYSTEM_NAME=iOS \
-DCMAKE_C_COMPILER="$compiler" \
-DCMAKE_OSX_SYSROOT="$sdk_path" \
+1 -1
View File
@@ -6,7 +6,7 @@ param(
)
$ErrorActionPreference = 'Stop'
$configure = @('-S', "$PSScriptRoot/native", '-B', $BuildDirectory,
$configure = @('-S', "$PSScriptRoot/../native/media", '-B', $BuildDirectory,
'-DCMAKE_BUILD_TYPE=Release', "-DVOICECAT_DOTNET_RID=$RuntimeIdentifier")
if ($Generator) { $configure += @('-G', $Generator) }
if ($CCompiler) { $configure += "-DCMAKE_C_COMPILER=$CCompiler" }
-13
View File
@@ -1,13 +0,0 @@
param(
[Parameter(Mandatory)][string]$ExpectedPath,
[Parameter(Mandatory)][string]$ActualPath
)
$ErrorActionPreference = 'Stop'
$expected = (Get-Content -Raw -LiteralPath $ExpectedPath | ConvertFrom-Json).samples
$actual = (Get-Content -Raw -LiteralPath $ActualPath | ConvertFrom-Json).samples
if ($expected.Count -ne 960 -or $actual.Count -ne $expected.Count) { throw 'DSP fixture sample counts differ.' }
for ($i = 0; $i -lt $expected.Count; $i++) {
if ([Math]::Abs($expected[$i] - $actual[$i]) -gt 1) { throw "DSP fixture differs at sample $i." }
}
Write-Output 'C++ DSP fixture matches within one PCM unit.'
-103
View File
@@ -1,103 +0,0 @@
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()
set(VOICECAT_MEDIA_LIBRARY_TYPE SHARED)
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
set(VOICECAT_MEDIA_LIBRARY_TYPE STATIC)
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 ${VOICECAT_MEDIA_LIBRARY_TYPE} 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
ARCHIVE DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia)
install(FILES ${RNNOISE_DIR}/COPYING DESTINATION licenses RENAME RNNoise.txt COMPONENT DotnetMedia)
install(FILES ${CMAKE_CURRENT_LIST_DIR}/NOTICE.txt DESTINATION licenses COMPONENT DotnetMedia)
if(VOICECAT_OPUS_LICENSE)
install(FILES ${VOICECAT_OPUS_LICENSE} DESTINATION licenses RENAME Opus.txt COMPONENT DotnetMedia)
endif()
-12
View File
@@ -1,12 +0,0 @@
VoiceCat desktop codec/DSP bindings
Opus 1.5.2: BSD-3-Clause. See Opus.txt for copyright, license, and patent notices.
Upstream: https://opus-codec.org/
Release: https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
SHA-256: 65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1
RNNoise code: BSD-3-Clause. See RNNoise.txt.
RNNoise model weights: CC0-1.0, as recorded in third_party/README.md.
Upstream: https://github.com/xiph/rnnoise
Vendored commit: 70f1d256acd4b34a572f999a05c87bf00b67730d
CC0: https://creativecommons.org/publicdomain/zero/1.0/
-55
View File
@@ -1,55 +0,0 @@
#include <opus.h>
#include "rnnoise.h"
#ifdef _WIN32
#define VC_EXPORT __declspec(dllexport)
#else
#define VC_EXPORT __attribute__((visibility("default")))
#endif
VC_EXPORT const char *vcm_opus_version(void) { return opus_get_version_string(); }
VC_EXPORT const char *vcm_opus_error(int error) { return opus_strerror(error); }
VC_EXPORT OpusEncoder *vcm_encoder_create(int rate, int channels, int application, int *error) {
return opus_encoder_create(rate, channels, application, error);
}
VC_EXPORT void vcm_encoder_destroy(OpusEncoder *encoder) { opus_encoder_destroy(encoder); }
/* C varargs are called here, not through P/Invoke: Apple arm64 uses a distinct varargs ABI. */
VC_EXPORT int vcm_encoder_set(OpusEncoder *encoder, int request, int value) {
switch (request) {
case OPUS_SET_BITRATE_REQUEST: case OPUS_SET_MAX_BANDWIDTH_REQUEST:
case OPUS_SET_COMPLEXITY_REQUEST: case OPUS_SET_INBAND_FEC_REQUEST:
case OPUS_SET_DTX_REQUEST: case OPUS_SET_PACKET_LOSS_PERC_REQUEST:
case OPUS_SET_DRED_DURATION_REQUEST:
return opus_encoder_ctl(encoder, request, value);
default: return OPUS_BAD_ARG;
}
}
VC_EXPORT int vcm_encoder_get_dred(OpusEncoder *encoder, int *duration) {
return opus_encoder_ctl(encoder, OPUS_GET_DRED_DURATION(duration));
}
VC_EXPORT int vcm_encode(OpusEncoder *encoder, const short *pcm, int samples, unsigned char *packet, int capacity) {
return opus_encode(encoder, pcm, samples, packet, capacity);
}
VC_EXPORT OpusDecoder *vcm_decoder_create(int rate, int channels, int *error) {
return opus_decoder_create(rate, channels, error);
}
VC_EXPORT void vcm_decoder_destroy(OpusDecoder *decoder) { opus_decoder_destroy(decoder); }
VC_EXPORT int vcm_decode(OpusDecoder *decoder, const unsigned char *packet, int length, short *pcm, int samples, int fec) {
return opus_decode(decoder, packet, length, pcm, samples, fec);
}
VC_EXPORT OpusDREDDecoder *vcm_dred_decoder_create(int *error) { return opus_dred_decoder_create(error); }
VC_EXPORT void vcm_dred_decoder_destroy(OpusDREDDecoder *decoder) { opus_dred_decoder_destroy(decoder); }
VC_EXPORT OpusDRED *vcm_dred_create(int *error) { return opus_dred_alloc(error); }
VC_EXPORT void vcm_dred_destroy(OpusDRED *dred) { opus_dred_free(dred); }
VC_EXPORT int vcm_dred_parse(OpusDREDDecoder *decoder, OpusDRED *dred, const unsigned char *packet,
int length, int samples, int rate, int *end) {
return opus_dred_parse(decoder, dred, packet, length, samples, rate, end, 0);
}
VC_EXPORT int vcm_dred_decode(OpusDecoder *decoder, OpusDRED *dred, int offset, short *pcm, int samples) {
return opus_decoder_dred_decode(decoder, dred, offset, pcm, samples);
}
VC_EXPORT DenoiseState *vcm_rnnoise_create(void) { return rnnoise_create(NULL); }
VC_EXPORT void vcm_rnnoise_destroy(DenoiseState *state) { rnnoise_destroy(state); }
VC_EXPORT float vcm_rnnoise_process(DenoiseState *state, float *output, const float *input) {
return rnnoise_process_frame(state, output, input);
}
-29
View File
@@ -1,29 +0,0 @@
add_executable(voicecat-dotnet-oracle main.cpp)
target_link_libraries(voicecat-dotnet-oracle PRIVATE voicecat::voicecat)
target_include_directories(voicecat-dotnet-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
target_compile_features(voicecat-dotnet-oracle PRIVATE cxx_std_20)
add_executable(voicecat-dotnet-tls-oracle tls.cpp)
target_link_libraries(voicecat-dotnet-tls-oracle PRIVATE voicecat::voicecat)
target_include_directories(voicecat-dotnet-tls-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
target_compile_features(voicecat-dotnet-tls-oracle PRIVATE cxx_std_20)
add_executable(voicecat-dotnet-voice-oracle voice.cpp)
target_link_libraries(voicecat-dotnet-voice-oracle PRIVATE voicecat::voicecat)
target_compile_features(voicecat-dotnet-voice-oracle PRIVATE cxx_std_20)
add_executable(voicecat-dotnet-dsp-oracle dsp.cpp)
target_link_libraries(voicecat-dotnet-dsp-oracle PRIVATE voicecat::voicecat)
target_include_directories(voicecat-dotnet-dsp-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
target_compile_features(voicecat-dotnet-dsp-oracle PRIVATE cxx_std_20)
find_package(unofficial-sodium CONFIG REQUIRED)
add_executable(voicecat-dotnet-password-oracle passwords.cpp)
target_link_libraries(voicecat-dotnet-password-oracle PRIVATE unofficial-sodium::sodium)
target_compile_features(voicecat-dotnet-password-oracle PRIVATE cxx_std_20)
if(VOICECAT_BUILD_SERVER)
add_executable(voicecat-dotnet-database-oracle database.cpp)
target_link_libraries(voicecat-dotnet-database-oracle PRIVATE voicecat::server)
target_compile_features(voicecat-dotnet-database-oracle PRIVATE cxx_std_20)
endif()
-41
View File
@@ -1,41 +0,0 @@
#include "db.h"
#include <string>
int main(int argc, char **argv) {
if (argc != 3) return 1;
voicecat::server::Database database(argv[2]);
std::string error;
if (!database.open(error)) return 1;
if (std::string(argv[1]) == "create-protected") {
voicecat::server::ChannelRecord channel;
channel.name = "Native protected";
channel.audio.set_sample_rate(48000);
channel.audio.set_bitrate_bps(24000);
channel.audio.set_frame_ms(20);
return database.create_channel(channel, "channel password", error) ? 0 : 1;
}
if (std::string(argv[1]) == "verify-protected") {
for (const auto& channel : database.list_channels()) {
if (channel.name == "Managed protected")
return database.check_channel_password(channel.id, "channel password") &&
!database.check_channel_password(channel.id, "wrong") ? 0 : 1;
}
return 1;
}
if (std::string(argv[1]) == "create") {
if (!database.create_account("legacy", "legacy password", true, error)) return 1;
voicecat::server::ChannelRecord lobby;
lobby.name = "Lobby";
lobby.topic = "Preserved native topic";
lobby.max_users = 7;
lobby.audio.set_sample_rate(48000);
lobby.audio.set_bitrate_bps(32000);
lobby.audio.set_frame_ms(20);
return database.create_channel(lobby, "", error) ? 0 : 1;
}
if (std::string(argv[1]) == "verify") {
auto account = database.authenticate("managed", "managed password");
return account && account->is_admin ? 0 : 1;
}
return 1;
}
-30
View File
@@ -1,30 +0,0 @@
#include "audio/apm_processor.h"
#include <cstdint>
#include <fstream>
#include <vector>
int main(int argc, char **argv) {
if (argc != 2) return 2;
auto processor = voicecat::audio::ApmProcessor::create();
if (!processor) return 1;
std::vector<int16_t> pcm(960);
uint32_t random = 0x12345678;
for (int frame = 0; frame < 200; ++frame) {
for (auto &sample : pcm) {
random ^= random << 13;
random ^= random >> 17;
random ^= random << 5;
sample = static_cast<int16_t>(static_cast<int>(random % 6001) - 3000);
}
if (!processor->process_capture(pcm.data(), static_cast<int>(pcm.size()), 48000)) return 1;
}
std::ofstream output(argv[1]);
output << "{\"samples\":[";
for (size_t i = 0; i < pcm.size(); ++i) {
if (i) output << ',';
output << pcm[i];
}
output << "]}\n";
return output ? 0 : 1;
}
-56
View File
@@ -1,56 +0,0 @@
#include "crypto/crypto.h"
#include "net/voice_frame.h"
#include "protocol/envelope.h"
#include <fstream>
#include <iomanip>
#include <sstream>
#include <stdexcept>
static std::string hex(const std::vector<uint8_t>& bytes) {
std::ostringstream result;
result << std::hex << std::setfill('0');
for (auto byte : bytes) result << std::setw(2) << unsigned(byte);
return result.str();
}
int main(int argc, char** argv) {
if (argc != 2 || sodium_init() < 0) return 1;
std::ofstream output(argv[1], std::ios::binary);
if (!output) return 1;
voicecat::v1::Envelope envelope;
envelope.set_request_id(42);
auto* hello = envelope.mutable_client_hello();
hello->set_proto_version(1);
hello->set_client_name("test-client");
hello->set_client_version("0.0.1");
hello->add_features("text");
std::vector<uint8_t> framed;
if (!voicecat::protocol::encode_envelope(envelope, framed)) return 1;
output << "{\n \"envelope\": \"" << hex(framed) << "\",\n \"media\": [\n";
std::array<uint8_t, 32> key{};
for (size_t i = 0; i < key.size(); ++i) key[i] = uint8_t(i);
voicecat::crypto::SodiumMediaCrypto sender(key.data());
for (uint64_t sequence = 0; sequence <= 65536; ++sequence) {
voicecat::net::VoiceFrame header;
header.flags = voicecat::net::kFlagMarker;
header.ssrc = 0xcafebabe;
header.seq = sender.peek_send_counter();
header.timestamp = 960;
const size_t length = sequence == 0 ? 0 : sequence == 1 ? 100 : 8;
std::vector<uint8_t> plaintext(length);
for (size_t i = 0; i < length; ++i) plaintext[i] = uint8_t(i);
std::vector<uint8_t> packet(voicecat::net::kVoiceHeaderSize + length + 16);
voicecat::net::serialize_header(header, packet.data());
if (sender.seal(plaintext.data(), length, packet.data(), 20, packet.data() + 20, length + 16) < 0) return 1;
if (sequence == 0 || sequence == 1 || sequence == 65535 || sequence == 65536) {
if (sequence != 0) output << ",\n";
output << " {\"sequence\": " << sequence << ", \"key\": \""
<< hex(std::vector<uint8_t>(key.begin(), key.end()))
<< "\", \"plaintext\": \"" << hex(plaintext)
<< "\", \"packet\": \"" << hex(packet) << "\"}";
}
}
output << "\n ]\n}\n";
return output ? 0 : 1;
}
-29
View File
@@ -1,29 +0,0 @@
#include <sodium.h>
#include <fstream>
#include <string>
#include <array>
static std::string base64(const unsigned char *data, size_t length) {
std::array<char, 128> output{};
sodium_bin2base64(output.data(), output.size(), data, length, sodium_base64_VARIANT_ORIGINAL_NO_PADDING);
return output.data();
}
int main(int argc, char **argv) {
if (argc != 2 || sodium_init() < 0) return 1;
std::ofstream output(argv[1]);
output << "{\"hashes\":[";
const std::array<std::string, 3> passwords{"voicecat test", "caf\xc3\xa9", std::string("a\0b", 3)};
std::array<unsigned char, 16> salt{};
for (size_t i = 0; i < salt.size(); ++i) salt[i] = static_cast<unsigned char>(i);
for (size_t i = 0; i < passwords.size(); ++i) {
std::array<unsigned char, 32> hash{};
if (crypto_pwhash(hash.data(), hash.size(), passwords[i].data(), passwords[i].size(), salt.data(), 2,
64 * 1024 * 1024, crypto_pwhash_ALG_ARGON2ID13) != 0) return 1;
if (i) output << ',';
output << "{\"passwordBase64\":\"" << base64(reinterpret_cast<const unsigned char *>(passwords[i].data()), passwords[i].size())
<< "\",\"hash\":\"$argon2id$v=19$m=65536,t=2,p=1$" << base64(salt.data(), salt.size()) << '$' << base64(hash.data(), hash.size()) << "\"}";
}
output << "]}\n";
return output ? 0 : 1;
}
-76
View File
@@ -1,76 +0,0 @@
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
using socket_type = SOCKET;
static void close_socket(socket_type socket) { closesocket(socket); }
#else
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
using socket_type = int;
static void close_socket(socket_type socket) { close(socket); }
#endif
#include "crypto/crypto.h"
#include "net/voice_frame.h"
#include <filesystem>
#include <fstream>
#include <iostream>
static bool transfer(voicecat::crypto::TlsContext& tls, uint8_t* data, size_t size, bool writing) {
while (size != 0) {
int count = writing ? tls.write(data, size) : tls.read(data, size);
if (count <= 0) return false;
data += count;
size -= count;
}
return true;
}
int main(int argc, char** argv) {
if (argc != 2 || sodium_init() < 0) return 1;
#ifdef _WIN32
WSADATA data{};
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return 1;
#endif
try {
auto certificate = voicecat::crypto::ServerCert::generate("dotnet-tls-oracle");
auto directory = std::filesystem::path(argv[1]);
socket_type listener = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(listener, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0 || listen(listener, 1) != 0) return 1;
socklen_t length = sizeof(address);
if (getsockname(listener, reinterpret_cast<sockaddr*>(&address), &length) != 0) return 1;
certificate.save(directory / "server.crt", directory / "server.key");
voicecat::crypto::ServerIdentity::generate().save(directory / "identity.key");
std::ofstream(directory / "port.txt") << ntohs(address.sin_port);
socket_type peer = accept(listener, nullptr, nullptr);
close_socket(listener);
if (peer == static_cast<socket_type>(-1)) return 1;
voicecat::crypto::TlsContext tls(voicecat::crypto::TlsContext::Role::Server, &certificate);
tls.set_read_timeout(10000);
std::string error;
if (!tls.handshake(static_cast<int>(peer), error)) { std::cerr << error; return 1; }
auto sender = voicecat::crypto::SodiumMediaCrypto::derive_send(tls, false);
auto receiver = voicecat::crypto::SodiumMediaCrypto::derive_recv(tls, false);
if (!sender || !receiver) return 1;
voicecat::net::VoiceFrame header;
header.ssrc = 42;
header.seq = sender->peek_send_counter();
std::array<uint8_t, 41> packet{};
voicecat::net::serialize_header(header, packet.data());
const std::array<uint8_t, 5> message{ 'h', 'e', 'l', 'l', 'o' };
if (sender->seal(message.data(), message.size(), packet.data(), 20, packet.data() + 20, 21) != 21) return 1;
if (!transfer(tls, packet.data(), packet.size(), true) || !transfer(tls, packet.data(), packet.size(), false)) return 1;
std::array<uint8_t, 5> recovered{};
if (receiver->open(packet.data() + 20, 21, packet.data(), 20, recovered.data(), recovered.size()) != 5 || recovered != message) return 1;
uint8_t acknowledgement = 1;
if (!transfer(tls, &acknowledgement, 1, true)) return 1;
return 0;
} catch (const std::exception& error) {
std::cerr << error.what();
return 1;
}
}
-127
View File
@@ -1,127 +0,0 @@
#include "voicecat.h"
#include <array>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdio>
#include <cstdlib>
#include <memory>
#include <mutex>
#include <thread>
#include <vector>
struct ClientState {
vc_client* client = nullptr;
std::mutex gate;
std::condition_variable changed;
bool authenticated = false;
bool subscribed = false;
bool joined = false;
uint32_t user = 0;
std::vector<std::pair<uint32_t, uint32_t>> streams;
std::array<int, 3> received{};
long long energy = 0;
uint32_t channels = 0;
};
static void event(void* context, const vc_event* value) {
auto& state = *static_cast<ClientState*>(context);
if (value->type == VC_EVENT_SERVER_IDENTITY) {
vc_confirm_server_identity(state.client, 1);
return;
}
std::lock_guard lock(state.gate);
switch (value->type) {
case VC_EVENT_AUTH_RESULT:
state.authenticated = value->result == VC_OK;
state.user = value->user_id;
break;
case VC_EVENT_VOICE_STATE: state.subscribed = value->u32a == 1; break;
case VC_EVENT_JOIN_RESULT: state.joined = value->result == VC_OK; break;
case VC_EVENT_STREAM_STARTED: state.streams.emplace_back(value->user_id, value->stream_id); break;
default: break;
}
state.changed.notify_all();
}
static void sink(void* context, uint32_t, uint32_t stream, const int16_t* pcm,
size_t samples, uint32_t channels, uint32_t rate) {
auto& state = *static_cast<ClientState*>(context);
if (rate != 48000 || stream >= state.received.size()) return;
std::lock_guard lock(state.gate);
++state.received[stream];
state.channels = channels;
for (size_t index = 0; index < samples * channels; ++index) state.energy += std::abs(static_cast<int>(pcm[index]));
state.changed.notify_all();
}
template<class Predicate>
static bool wait(ClientState& state, Predicate predicate) {
std::unique_lock lock(state.gate);
return state.changed.wait_for(lock, std::chrono::seconds(8), predicate);
}
struct Destroy {
void operator()(vc_client* client) const { vc_disconnect(client); vc_client_destroy(client); }
};
using Client = std::unique_ptr<vc_client, Destroy>;
static Client connect(ClientState& state, uint16_t port, uint32_t channel, const char* nickname) {
vc_config config{"dotnet-voice-oracle", "1", VC_LOG_OFF};
Client client(vc_client_create(&config, {event, nullptr, &state}));
state.client = client.get();
if (!client || vc_set_external_playback(client.get(), 1) != VC_OK ||
vc_connect(client.get(), "127.0.0.1", port) != VC_OK ||
vc_authenticate_guest(client.get(), nickname) != VC_OK ||
!wait(state, [&] { return state.authenticated; }) ||
vc_join_channel(client.get(), channel, nullptr) != VC_OK ||
!wait(state, [&] { return state.joined; }) ||
vc_join_voice(client.get()) != VC_OK ||
!wait(state, [&] { return state.subscribed; }) ||
vc_set_pcm_sink(client.get(), sink, &state) != VC_OK) return {};
return client;
}
int main(int argc, char** argv) {
if (argc != 3) return 1;
uint16_t port = static_cast<uint16_t>(std::strtoul(argv[1], nullptr, 10));
uint32_t channel = static_cast<uint32_t>(std::strtoul(argv[2], nullptr, 10));
ClientState alice, bob;
Client a = connect(alice, port, channel, "Native Alice");
Client b = connect(bob, port, channel, "Native Bob");
if (!a || !b) { std::fprintf(stderr, "native authentication/join/subscription failed\n"); return 1; }
std::array<uint32_t, 3> ids{};
vc_stream_desc mic{};
mic.kind = VC_STREAM_MIC;
mic.external_feed = 1;
vc_stream_desc screen = mic;
screen.kind = VC_STREAM_SCREEN_AUDIO;
if (vc_stream_start(a.get(), &mic, &ids[0]) != VC_OK ||
vc_stream_start(a.get(), &screen, &ids[1]) != VC_OK ||
vc_stream_start(b.get(), &mic, &ids[2]) != VC_OK ||
!wait(alice, [&] { return alice.streams.size() >= 3; }) ||
!wait(bob, [&] { return bob.streams.size() >= 3; })) {
std::fprintf(stderr, "native stream signaling failed\n"); return 1;
}
uint32_t channels = channel == 2 ? 2 : 1;
std::vector<int16_t> pcm(960 * channels);
for (size_t sample = 0; sample < 960; ++sample)
for (uint32_t side = 0; side < channels; ++side)
pcm[sample * channels + side] = static_cast<int16_t>(12000 * std::sin(sample * (side == 0 ? 0.058 : 0.083)));
for (int frame = 0; frame < 100; ++frame) {
if (vc_stream_feed_pcm(a.get(), ids[0], pcm.data(), 960, channels) != VC_OK ||
vc_stream_feed_pcm(a.get(), ids[1], pcm.data(), 960, channels) != VC_OK ||
vc_stream_feed_pcm(b.get(), ids[2], pcm.data(), 960, channels) != VC_OK) return 1;
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
bool received = wait(alice, [&] { return alice.received[ids[2]] >= 5 && alice.energy > 0; }) &&
wait(bob, [&] { return bob.received[ids[0]] >= 5 && bob.received[ids[1]] >= 5 && bob.energy > 0; });
{
std::scoped_lock lock(alice.gate, bob.gate);
std::printf("channel=%u channels=%u alice=%d bob-mic=%d bob-screen=%d energy=%lld/%lld\n",
channel, channels, alice.received[ids[2]], bob.received[ids[0]], bob.received[ids[1]], alice.energy, bob.energy);
received = received && alice.channels == channels && bob.channels == channels;
}
return received ? 0 : 1;
}
+1 -1
View File
@@ -23,4 +23,4 @@
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
+1 -1
View File
@@ -5,4 +5,4 @@
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
+1 -1
View File
@@ -43,4 +43,4 @@
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
@@ -23,4 +23,4 @@
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
+1 -1
View File
@@ -5,4 +5,4 @@
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
@@ -7,6 +7,6 @@
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.36.1" />
<PackageReference Include="Grpc.Tools" Version="2.83.0" PrivateAssets="all" />
<Protobuf Include="../../../core/proto/voicecat.proto" GrpcServices="None" />
<Protobuf Include="../../../proto/voicecat.proto" GrpcServices="None" />
</ItemGroup>
</Project>
@@ -18,4 +18,4 @@
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
}
@@ -1,5 +1,4 @@
using Microsoft.Data.Sqlite;
using System.Diagnostics;
using VoiceCat.Server.Data;
namespace VoiceCat.Tests;
@@ -27,71 +26,6 @@ public sealed class AccountStoreTests
finally { File.Delete(path); File.Delete(path + "-wal"); File.Delete(path + "-shm"); }
}
[NativeDatabaseFact]
public async Task ExistingCppDatabaseAndManagedAccountsWorkInBothImplementations()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-import-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "voicecat.db");
try
{
await RunOracleAsync("create", path);
using (var store = new AccountStore(path))
{
Account account = Assert.IsType<Account>(await store.AuthenticateAsync("legacy", "legacy password"));
Assert.True(account.IsAdmin);
var channel = Assert.Single(store.LoadChannels());
Assert.Equal("Preserved native topic", channel.Topic);
Assert.Equal(7U, channel.MaxUsers);
Assert.Equal(32000U, channel.Audio.BitrateBps);
await store.CreateAccountAsync("managed", "managed password", true);
}
await RunOracleAsync("verify", path);
}
finally { Directory.Delete(directory, true); }
}
private static async Task RunOracleAsync(string mode, string path)
{
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE")!) { UseShellExecute = false, CreateNoWindow = true };
start.ArgumentList.Add(mode);
start.ArgumentList.Add(path);
using var process = Process.Start(start)!;
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try { await process.WaitForExitAsync(timeout.Token); Assert.Equal(0, process.ExitCode); }
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
[NativeDatabaseFact]
public async Task ChannelPasswordHashesWorkInBothImplementations()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-channels-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "voicecat.db");
try
{
await RunOracleAsync("create-protected", path);
using (var store = new AccountStore(path))
{
var channel = Assert.Single(store.LoadChannels());
Assert.True(store.CheckChannelPassword(channel.Id, "channel password"));
Assert.False(store.CheckChannelPassword(channel.Id, "wrong"));
channel.Name = "Managed protected";
store.SaveChannel(channel, "channel password", true);
}
await RunOracleAsync("verify-protected", path);
}
finally { Directory.Delete(directory, true); }
}
private sealed class NativeDatabaseFactAttribute : FactAttribute
{
public NativeDatabaseFactAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE"))) Skip = "Set VOICECAT_DATABASE_ORACLE to the native database oracle.";
}
}
[Fact]
public async Task AccountsSurviveRestartAndFailedAuthDoesNotChangeLastLogin()
{
@@ -8,41 +8,6 @@ namespace VoiceCat.Tests;
public class AdministrationTests
{
[NativeCliFact]
public async Task ExistingCppCliCreatesProtectedChannelsAndAdministersAccounts()
{
await using var fixture = new ServerFixture();
await using var admin = await AdminAsync(fixture);
var start = new System.Diagnostics.ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
{
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true
};
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--username", "Admin", "--password", "secret",
"--create-channel", "--new-channel-name", "Native room", "--new-channel-password", "protected", "--create-account", "native", "secret", "--list-accounts", "--wait-ms", "10000" })
start.ArgumentList.Add(argument);
using var process = System.Diagnostics.Process.Start(start)!;
Task<string> stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(admin.Timeout.Token);
Assert.True(process.ExitCode == 0, await stdout + await stderr);
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
var room = store.LoadChannels().Single(c => c.Name == "Native room");
Assert.True(store.CheckChannelPassword(room.Id, "protected"));
Assert.NotNull(await store.AuthenticateAsync("native", "secret"));
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
private sealed class NativeCliFactAttribute : FactAttribute
{
public NativeCliFactAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
}
}
[Fact]
public async Task AccountAdministrationIsPermissionGatedAndPersistsPasswordChanges()
{
+1 -1
View File
@@ -20,7 +20,7 @@ public sealed class DspTests
if (frame >= 60) foreach (short value in pcm) outputEnergy += (double)value * value;
}
Assert.True(Math.Sqrt(outputEnergy / inputEnergy) < 0.2);
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-noise.json")));
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "rnnoise.json")));
short[] expected = fixture.RootElement.GetProperty("samples").EnumerateArray().Select(value => value.GetInt16()).ToArray();
Assert.Equal(pcm.Length, expected.Length);
for (int i = 0; i < pcm.Length; i++) Assert.InRange(Math.Abs(pcm[i] - expected[i]), 0, 1);
+3 -3
View File
@@ -9,7 +9,7 @@ namespace VoiceCat.Tests;
public class GoldenTests
{
[Fact]
public void EnvelopeMatchesCppFixture()
public void EnvelopeMatchesCanonicalWireVector()
{
using var fixture = Load();
var expected = Convert.FromHexString(fixture.RootElement.GetProperty("envelope").GetString()!);
@@ -23,7 +23,7 @@ public class GoldenTests
[Theory]
[InlineData(false)]
[InlineData(true)]
public void MediaPacketsMatchCppFixtures(bool managed)
public void MediaPacketsMatchCanonicalWireVectors(bool managed)
{
using var fixture = Load();
foreach (var vector in fixture.RootElement.GetProperty("media").EnumerateArray())
@@ -46,5 +46,5 @@ public class GoldenTests
}
}
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-wire.json")));
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "wire.json")));
}
+1 -1
View File
@@ -41,7 +41,7 @@ public class IdentityTests
}
[Fact]
public void TofuRequiresExplicitPinAndPreservesCppFileFormat()
public void TofuRequiresExplicitPinAndPreservesFileFormat()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-pins-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
@@ -5,30 +5,6 @@ namespace VoiceCat.Tests;
public class ManagedCliTests
{
[Fact]
public async Task ManagedCliInteroperatesWithExistingCppCli()
{
string? nativeCli = Environment.GetEnvironmentVariable("VOICECAT_VCCLI");
if (string.IsNullOrEmpty(nativeCli)) return; // Full conformance runs set this explicitly.
await using var fixture = new ServerFixture();
string cli = Path.Combine(FindRoot(), "dotnet", "src", "VoiceCat.Cli", "bin", "Release", "net10.0", "VoiceCat.Cli.dll");
using Process managed = Start(cli, fixture, "Managed", "Managed checkpoint", "Native checkpoint", 2);
await Task.Delay(750); // Native vccli sends its one-shot text immediately after auth.
var nativeStart = new ProcessStartInfo(nativeCli) { WorkingDirectory = fixture.Directory, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true };
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Native",
"--channel", "2", "--text", "Native checkpoint", "--test-tone-ms", "5000" }) nativeStart.ArgumentList.Add(argument);
using Process native = Process.Start(nativeStart)!;
Task<string> managedOut = managed.StandardOutput.ReadToEndAsync(), managedError = managed.StandardError.ReadToEndAsync();
Task<string> nativeOut = native.StandardOutput.ReadToEndAsync(), nativeError = native.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35));
await Task.WhenAll(managed.WaitForExitAsync(timeout.Token), native.WaitForExitAsync(timeout.Token));
string mout = await managedOut, nout = await nativeOut;
Assert.True(managed.ExitCode == 0, await managedError + Environment.NewLine + mout);
Assert.True(native.ExitCode == 0, await nativeError + Environment.NewLine + nout);
Assert.Contains("Native checkpoint", mout); Assert.Contains("Managed checkpoint", nout);
Assert.Contains("[test-tone] received=", nout); Assert.DoesNotContain("\"voiceEnergy\":0", mout);
}
[Theory]
[InlineData(1u)]
[InlineData(2u)]
@@ -1,6 +1,5 @@
using VoiceCat.Transport;
using System.Net;
using System.Diagnostics;
using System.Net.Sockets;
using VoiceCat.Protocol;
using VoiceCat.Server.Transport;
@@ -11,89 +10,6 @@ namespace VoiceCat.Tests;
public sealed class MediaRelayTests
{
[CppCliVoiceTheory]
[InlineData(1)]
[InlineData(2)]
public async Task TwoCppCliProcessesJoinChatAndExchangeVoice(int channel)
{
await using var fixture = new ServerFixture();
await using var observer = await fixture.ConnectAsync();
await observer.LoginAsync("Observer");
observer.Send(new() { JoinChannel = new() { ChannelId = checked((uint)channel) } });
Assert.True((await observer.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await Task.WhenAll(RunAsync("Cli Alice"), RunAsync("Cli Bob"));
var first = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
var second = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
Assert.Equal("CLI voice checkpoint", first.Body);
Assert.Equal(first.Body, second.Body);
Assert.NotEqual(first.SenderId, second.SenderId);
async Task RunAsync(string nickname)
{
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
{
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true
};
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
"--nick", nickname, "--channel", channel.ToString(System.Globalization.CultureInfo.InvariantCulture), "--text", "CLI voice checkpoint", "--test-tone-ms", "4000" })
start.ArgumentList.Add(argument);
using var process = Process.Start(start)!;
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
Task<string> stderr = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(timeout.Token);
string log = await stdout + await stderr;
Assert.True(process.ExitCode == 0, log);
Assert.Contains("[test-tone] received=", log);
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
}
private sealed class CppCliVoiceTheoryAttribute : TheoryAttribute
{
public CppCliVoiceTheoryAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
}
}
[VoiceOracleTheory]
[InlineData(1)]
[InlineData(2)]
public async Task ExistingCppClientsExchangeBidirectionalVoiceThroughManagedServer(int channel)
{
await using var fixture = new ServerFixture();
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE")!)
{
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true
};
start.ArgumentList.Add(fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture));
start.ArgumentList.Add(channel.ToString(System.Globalization.CultureInfo.InvariantCulture));
using var process = Process.Start(start)!;
Task<string> output = process.StandardOutput.ReadToEndAsync();
Task<string> error = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(45));
try
{
await process.WaitForExitAsync(timeout.Token);
Assert.True(process.ExitCode == 0, await output + await error);
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
private sealed class VoiceOracleTheoryAttribute : TheoryAttribute
{
public VoiceOracleTheoryAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE"))) Skip = "Set VOICECAT_VOICE_ORACLE to the native voice conformance executable.";
}
}
[Fact]
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
{
+1 -1
View File
@@ -10,7 +10,7 @@ public sealed class PasswordTests
public void VerifiesLibsodiumHashesWithoutPasswordNormalization()
{
var hasher = new PasswordHasher();
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-passwords.json")));
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "argon2id.json")));
foreach (var item in fixture.RootElement.GetProperty("hashes").EnumerateArray())
{
string encodedPassword = item.GetProperty("passwordBase64").GetString()!;
@@ -95,43 +95,6 @@ public sealed class ServerTests
Assert.NotEqual(0U, (await client.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Code);
}
[CppCliFact]
public async Task ExistingCppCliAuthenticatesAndChatsThroughManagedServer()
{
await using var fixture = new ServerFixture();
await using var receiver = await fixture.ConnectAsync();
User self = await receiver.LoginAsync("Managed");
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
{
WorkingDirectory = fixture.Directory, UseShellExecute = false,
RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true
};
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Cpp", "--text", "native interoperability", "--wait-ms", "10000" })
start.ArgumentList.Add(argument);
using var process = Process.Start(start)!;
Task<string> output = process.StandardOutput.ReadToEndAsync();
Task<string> error = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(receiver.Timeout.Token);
string log = await output + await error;
Assert.True(process.ExitCode == 0, log);
TextMessage text = (await receiver.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
Assert.Equal("native interoperability", text.Body);
Assert.NotEqual(self.Id, text.SenderId);
Assert.Contains("native interoperability", log);
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
private sealed class CppCliFactAttribute : FactAttribute
{
public CppCliFactAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
}
}
internal sealed class ServerFixture : IAsyncDisposable
{
public string Directory { get; } = Path.Combine(Path.GetTempPath(), "voicecat-server-" + Guid.NewGuid().ToString("N"));
@@ -1,97 +0,0 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
namespace VoiceCat.Tests;
public class TlsInteropTests
{
[TlsOracleFact]
public async Task ManagedClientAndCppServerAgreeOnExporterKeysAndCertificate()
{
string? oracle = Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE");
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tls-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
var start = new ProcessStartInfo(oracle!) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true };
start.ArgumentList.Add(directory);
using var process = Process.Start(start)!;
var error = process.StandardError.ReadToEndAsync();
var stdout = process.StandardOutput.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
int port = 0;
while (!int.TryParse(File.Exists(Path.Combine(directory, "port.txt")) ? await File.ReadAllTextAsync(Path.Combine(directory, "port.txt"), timeout.Token) : "", out port))
{
Assert.False(process.HasExited, "C++ TLS oracle exited before listening.");
await Task.Delay(20, timeout.Token);
}
using var certificate = X509Certificate2.CreateFromPem(await File.ReadAllTextAsync(Path.Combine(directory, "server.crt"), timeout.Token));
string fingerprint = Convert.ToHexString(SHA256.HashData(certificate.RawData));
using var credentials = ServerCredentials.LoadOrCreate(directory, "existing C++ identity");
Assert.Equal(fingerprint, credentials.CertificateFingerprint);
Assert.Equal(32, credentials.Identity.PublicKey.Length);
using var client = TlsSession.CreateClient(value => value == fingerprint);
using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync("127.0.0.1", port, timeout.Token);
byte[] buffer = new byte[16384];
async Task Flush()
{
while (client.PendingCiphertextBytes > 0)
{
int count = client.DrainCiphertext(buffer);
int sent = 0;
while (sent < count) sent += await socket.SendAsync(buffer.AsMemory(sent, count - sent), SocketFlags.None, timeout.Token);
}
}
async Task Receive()
{
int count = await socket.ReceiveAsync(buffer, SocketFlags.None, timeout.Token);
Assert.True(count > 0, "TLS oracle closed unexpectedly.");
client.ReceiveCiphertext(buffer.AsSpan(0, count));
}
while (!client.IsReady) { await Flush(); await Receive(); }
await Flush();
byte[] packet = new byte[41];
int received = 0;
while (received < packet.Length)
{
int count = client.ReadPlaintext(packet.AsSpan(received));
received += count;
if (count == 0) { await Flush(); await Receive(); }
}
using var decryptor = client.CreateMediaDecryptor();
byte[] plaintext = new byte[5];
Assert.True(decryptor.TryDecrypt(packet, plaintext, out var header, out _));
Assert.Equal("hello"u8.ToArray(), plaintext);
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
using var encryptor = client.CreateMediaEncryptor();
encryptor.Encrypt(header, plaintext, packet);
client.WritePlaintext(packet);
await Flush();
byte[] ack = new byte[1];
while (client.ReadPlaintext(ack) == 0) { await Flush(); await Receive(); }
Assert.Equal(1, ack[0]);
await process.WaitForExitAsync(timeout.Token);
Assert.True(process.ExitCode == 0, await error);
await stdout;
}
finally
{
if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); }
Directory.Delete(directory, recursive: true);
}
}
}
public sealed class TlsOracleFactAttribute : FactAttribute
{
public TlsOracleFactAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE")))
Skip = "Build the native TLS oracle and set VOICECAT_TLS_ORACLE to its executable path.";
}
}