diff --git a/.clang-format b/.clang-format deleted file mode 100644 index 28e0d33..0000000 --- a/.clang-format +++ /dev/null @@ -1,15 +0,0 @@ -# VoiceCat C++ style. Keep formatting boring and consistent. -BasedOnStyle: Google -Language: Cpp -Standard: c++20 -ColumnLimit: 100 -IndentWidth: 4 -TabWidth: 4 -UseTab: Never -AccessModifierOffset: -2 -PointerAlignment: Left -DerivePointerAlignment: false -AllowShortFunctionsOnASingleLine: Inline -AllowShortIfStatementsOnASingleLine: false -SortIncludes: CaseInsensitive -IncludeBlocks: Regroup diff --git a/.dockerignore b/.dockerignore index 2b25ffb..1b3c2cb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,9 +3,9 @@ # Previous build outputs build/ -dotnet/artifacts/ -dotnet/**/bin/ -dotnet/**/obj/ +artifacts/ +**/bin/ +**/obj/ # Native GUI client code (Swift/Xcode, C#/WinForms) — server build doesn't need these clients/ @@ -18,7 +18,6 @@ PROGRESS.md CLAUDE.md # Editor / tooling config -.clang-format .vscode/ .idea/ diff --git a/dotnet/.editorconfig b/.editorconfig similarity index 100% rename from dotnet/.editorconfig rename to .editorconfig diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml deleted file mode 100644 index 7f4a86f..0000000 --- a/.github/workflows/build-linux.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Build Linux Binaries - -# Builds stripped voicecat-server + voicecat-admin for linux/amd64 and linux/arm64. -# Run manually from the Actions tab, or on any push to main. -# Artifacts are downloadable from the workflow run for ~90 days. - -on: - workflow_dispatch: # manual trigger from the Actions tab - push: - branches: [main] - paths: - - 'core/**' - - 'server/**' - - 'tools/**' - - 'cmake/**' - - 'CMakeLists.txt' - - 'CMakePresets.json' - - 'vcpkg.json' - - 'Dockerfile' - - '.github/workflows/build-linux.yml' - -jobs: - build: - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - runner: ubuntu-24.04 - vcpkg_triplet: x64-linux - - arch: arm64 - runner: ubuntu-24.04-arm - vcpkg_triplet: arm64-linux - - name: linux/${{ matrix.arch }} - runs-on: ${{ matrix.runner }} - - steps: - - uses: actions/checkout@v4 - - - name: Cache vcpkg packages - uses: actions/cache@v4 - with: - path: | - ~/.cache/vcpkg - /usr/local/share/vcpkg/buildtrees - key: vcpkg-${{ matrix.vcpkg_triplet }}-${{ hashFiles('vcpkg.json') }} - restore-keys: | - vcpkg-${{ matrix.vcpkg_triplet }}- - - - name: Install build dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential cmake ninja-build git curl zip unzip tar \ - pkg-config autoconf autoconf-archive automake libtool nasm python3 - - - name: Set up vcpkg - run: | - VCPKG_COMMIT=$(jq -r '."builtin-baseline"' vcpkg.json) - git init /tmp/vcpkg - git -C /tmp/vcpkg remote add origin https://github.com/microsoft/vcpkg.git - git -C /tmp/vcpkg fetch --depth=1 origin "$VCPKG_COMMIT" - git -C /tmp/vcpkg checkout FETCH_HEAD - /tmp/vcpkg/bootstrap-vcpkg.sh -disableMetrics - echo "VCPKG_ROOT=/tmp/vcpkg" >> "$GITHUB_ENV" - echo "VCPKG_DISABLE_METRICS=1" >> "$GITHUB_ENV" - - - name: Build (server-release preset) - run: | - cmake --preset server-release - cmake --build --preset server-release - - - name: Collect binaries - run: | - mkdir -p dist - cp build/server-release/bin/voicecat-server dist/ - cp build/server-release/bin/voicecat-admin dist/ - file dist/voicecat-server dist/voicecat-admin - ls -lh dist/ - - - name: Upload binaries - uses: actions/upload-artifact@v4 - with: - name: voicecat-linux-${{ matrix.arch }} - path: dist/ - retention-days: 90 diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index be3dbac..f17e31e 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,10 +1,10 @@ -name: .NET port +name: Build and test on: push: - paths: ['dotnet/**', 'clients/windows/**', 'clients/apple/dotnet/**', 'native/**', 'proto/**', 'assets/**', 'Dockerfile', '.github/workflows/dotnet.yml'] + paths: ['src/**', 'tests/**', 'clients/**', 'native/**', 'proto/**', 'assets/**', 'scripts/**', '*.slnx', '*.props', '*.targets', 'global.json', 'Dockerfile', '.github/workflows/dotnet.yml'] pull_request: - paths: ['dotnet/**', 'clients/windows/**', 'clients/apple/dotnet/**', 'native/**', 'proto/**', 'assets/**', 'Dockerfile', '.github/workflows/dotnet.yml'] + paths: ['src/**', 'tests/**', 'clients/**', 'native/**', 'proto/**', 'assets/**', 'scripts/**', '*.slnx', '*.props', '*.targets', 'global.json', 'Dockerfile', '.github/workflows/dotnet.yml'] workflow_dispatch: jobs: @@ -18,17 +18,17 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: - global-json-file: dotnet/global.json + global-json-file: global.json cache: true - cache-dependency-path: dotnet/**/packages.lock.json + cache-dependency-path: '**/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 + run: ./scripts/build-native.ps1 + - run: dotnet restore VoiceCat.slnx --locked-mode + - run: dotnet build VoiceCat.slnx -c Release --no-restore + - run: dotnet test VoiceCat.slnx -c Release --no-build - shell: pwsh - run: ./dotnet/check-licenses.ps1 + run: ./scripts/check-licenses.ps1 apple-client: runs-on: macos-latest @@ -36,17 +36,17 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: - global-json-file: dotnet/global.json + global-json-file: global.json cache: true - cache-dependency-path: dotnet/**/packages.lock.json + cache-dependency-path: '**/packages.lock.json' - name: Install Apple workloads run: dotnet workload install macos ios --version 10.0.401 - name: Build and stage native codec/DSP shell: pwsh - run: ./dotnet/build-native.ps1 + run: ./scripts/build-native.ps1 - name: Build and stage static iOS codec/DSP - run: ./dotnet/build-native-ios.sh + run: ./scripts/build-native-ios.sh - name: Restore managed Apple clients - run: dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx + run: dotnet restore clients/apple/VoiceCat.Apple.slnx - name: Build managed AppKit and UIKit clients - run: dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug --no-restore + run: dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore diff --git a/.gitignore b/.gitignore index 913cd41..2086baa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ # Build output -/dotnet/**/bin/ -/dotnet/**/obj/ -/dotnet/**/TestResults/ -/dotnet/artifacts/ +**/bin/ +**/obj/ +**/TestResults/ +/artifacts/ /build/ /out/ @@ -19,7 +19,6 @@ *.pdb # vcpkg (bundled as a submodule at /vcpkg — see docs/building.md §2) -/vcpkg_installed/ # Generated protobuf *.pb.cc @@ -49,13 +48,8 @@ clients/apple/**/*.xcframework/ clients/windows/**/bin/ clients/windows/**/obj/ # SwiftPM build artifacts -clients/apple/.build/ -clients/apple/.swiftpm/ -clients/apple/Package.resolved # Test artifacts: TOFU pin store written by vc_client during headless tests -# (core/src/core/client.cpp falls back to this relative path when tofu_store_path is unset). voicecat_tofu_pins.txt # Python bytecode cache (e.g. scripts/asc_api.py) -__pycache__/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index a0a57f3..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "vcpkg"] - path = vcpkg - url = https://github.com/microsoft/vcpkg.git diff --git a/AGENTS.md b/AGENTS.md index 1bfe552..838e86a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,7 @@ # VoiceCat working method Start with `CLAUDE.md` for commands and architecture and `PROGRESS.md` for the current handoff. -The supported product is the .NET 10 implementation. Old C++ and Swift application code is -retirement material, not a source of truth. +The supported product is the .NET 10 implementation. ## Definition of done @@ -22,10 +21,10 @@ For implementation work: | Path | Purpose | |---|---| | `proto/voicecat.proto` | Control-plane wire schema | -| `dotnet/src/` | Managed server, core, protocol, crypto, audio, codec/DSP, and CLI | -| `dotnet/tests/VoiceCat.Tests/` | Managed behavior tests | +| `src/` | Managed server, core, protocol, crypto, audio, codec/DSP, and CLI | +| `tests/VoiceCat.Tests/` | Managed behavior tests | | `clients/windows/` | Supported WinForms client | -| `clients/apple/dotnet/` | Supported AppKit and UIKit clients | +| `clients/apple/` | Supported AppKit and UIKit clients | | `native/media/` | Required narrow Opus/RNNoise C ABI | | `native/rnnoise/` | Vendored RNNoise source/model | | `native/apple/broadcast/` | Required ReplayKit extension and shared-memory producer | @@ -33,15 +32,15 @@ For implementation work: ## Core verification ```bash -./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 -./dotnet/check-licenses.ps1 +./scripts/build-native.ps1 +dotnet restore VoiceCat.slnx --locked-mode +dotnet build VoiceCat.slnx -c Release --no-restore +dotnet test VoiceCat.slnx -c Release --no-build +./scripts/check-licenses.ps1 ``` -Apple builds additionally use `./dotnet/build-native-ios.sh` and -`clients/apple/dotnet/VoiceCat.Apple.slnx` on macOS. +Apple builds additionally use `./scripts/build-native-ios.sh` and +`clients/apple/VoiceCat.Apple.slnx` on macOS. ## Hard rules @@ -50,5 +49,4 @@ Apple builds additionally use `./dotnet/build-native-ios.sh` and - Real-time audio callbacks never allocate, lock, block, or perform I/O. - Preserve accessible names, keyboard operation, and curated screen-reader announcements. - Wire, database, and shared-ring changes are deliberate versioned changes. -- Do not restore compatibility tests for retired implementations unless explicitly requested. - Keep `PROGRESS.md` short. Use Git history rather than accumulating completed-work prose. diff --git a/CLAUDE.md b/CLAUDE.md index b3ffa30..9c85cbf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,42 +6,38 @@ is .NET 10. Read `AGENTS.md` for working rules and `PROGRESS.md` for the current ## Build and test ```bash -./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 -./dotnet/check-licenses.ps1 +./scripts/build-native.ps1 +dotnet restore VoiceCat.slnx --locked-mode +dotnet build VoiceCat.slnx -c Release --no-restore +dotnet test VoiceCat.slnx -c Release --no-build +./scripts/check-licenses.ps1 ``` Apple client builds require macOS, Xcode, and the .NET macOS/iOS workloads: ```bash -./dotnet/build-native-ios.sh -dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx -dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug --no-restore +./scripts/build-native-ios.sh +dotnet restore clients/apple/VoiceCat.Apple.slnx +dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore ``` Windows publishing uses `clients/windows/publish-client.ps1`. Server publishing uses -`dotnet/publish-server.ps1`. See `docs/building.md` while it is being rewritten; prefer the -scripts themselves when historical text disagrees with them. +`scripts/publish-server.ps1`. ## Current architecture - `proto/voicecat.proto` is the control-plane wire schema. -- `dotnet/src/VoiceCat.Protocol` owns protobuf framing and generated types. -- `dotnet/src/VoiceCat.Crypto` owns TLS, TOFU, media AEAD, identity, and password hashing. -- `dotnet/src/VoiceCat.Server` owns the TLS/UDP server and SQLite state. -- `dotnet/src/VoiceCat.Core` owns client connection and protocol state. -- `dotnet/src/VoiceCat.Audio`, `.Codec`, and `.Dsp` own voice processing. -- `dotnet/src/VoiceCat.Cli` is the supported headless client. +- `src/VoiceCat.Protocol` owns protobuf framing and generated types. +- `src/VoiceCat.Crypto` owns TLS, TOFU, media AEAD, identity, and password hashing. +- `src/VoiceCat.Server` owns the TLS/UDP server and SQLite state. +- `src/VoiceCat.Core` owns client connection and protocol state. +- `src/VoiceCat.Audio`, `.Codec`, and `.Dsp` own voice processing. +- `src/VoiceCat.Cli` is the supported headless client. - `clients/windows` is the WinForms client. -- `clients/apple/dotnet` contains the AppKit and UIKit clients. +- `clients/apple` contains the AppKit and UIKit clients. - `native/media` and `native/rnnoise` are the required Opus/RNNoise native boundary. - `native/apple/broadcast` is the required Swift ReplayKit extension. -The old C++ implementation and old Swift applications are unsupported retirement sources. -They are not architectural authorities and compatibility with them is not a requirement. - ## Invariants - TLS control and encrypted UDP media are mandatory; do not add plaintext transports. diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index 056f293..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,72 +0,0 @@ -cmake_minimum_required(VERSION 3.25) - -project(voicecat - VERSION 0.0.1 - DESCRIPTION "Self-hosted native voice & text chat (see docs/)" - # C is needed for the retained native media shim under native/. - LANGUAGES CXX C) - -# On iOS, audio_engine.cpp includes miniaudio.h which pulls in AVFoundation Objective-C -# headers. Those cannot be compiled as C++; we set audio_engine.cpp's LANGUAGE to OBJCXX -# in core/CMakeLists.txt, but that requires the language to be enabled first. -if(CMAKE_SYSTEM_NAME STREQUAL "iOS") - enable_language(OBJCXX) -endif() - -# ── Options ─────────────────────────────────────────────────────────────────── -option(VOICECAT_BUILD_SERVER "Build voicecat-server" ON) -option(VOICECAT_BUILD_TOOLS "Build the vccli headless test client" ON) -option(VOICECAT_BUILD_TESTS "Build tests" ON) -option(VOICECAT_BUILD_SHARED "Build libvoicecat as a shared library" OFF) - -# ── Global settings ─────────────────────────────────────────────────────────── -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE) -endif() - -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) -set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) -set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) - -# ── Static MinGW runtime (Windows) ──────────────────────────────────────────── -# x64-mingw-static only statically links vcpkg's OWN deps (protobuf, sodium, ...); -# the GCC/MinGW runtime stays dynamic by default, so every produced .exe/.dll -# otherwise depends on libgcc_s_seh-1.dll / libwinpthread-1.dll / libstdc++-6.dll -# at runtime — DLLs that exist on a dev box (MSYS2/UCRT64) but not on a clean -# Windows machine, where the server then fails to start with "… was not found". -# Apply the fully-static-MinGW recipe to ALL targets so binaries are portable. -# (The SHARED voicecat.dll already sets these in core/CMakeLists.txt; the duplicate -# is harmless. Verify with: objdump -p build//bin/voicecat-server.exe | -# grep "DLL Name" — only Windows system DLLs should remain.) -if(WIN32 AND MINGW) - add_link_options(-static-libgcc -static-libstdc++ -static -lwinpthread) -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(native/media) -endif() - -if(VOICECAT_BUILD_SERVER) - add_subdirectory(server) -endif() - -if(VOICECAT_BUILD_TOOLS) - add_subdirectory(tools/vccli) - add_subdirectory(tools/voicecat-admin) -endif() - -if(VOICECAT_BUILD_TESTS) - enable_testing() - add_subdirectory(tests) -endif() - -message(STATUS "VoiceCat ${PROJECT_VERSION} configured " - "(server: ${VOICECAT_BUILD_SERVER}, tools: ${VOICECAT_BUILD_TOOLS})") diff --git a/CMakePresets.json b/CMakePresets.json deleted file mode 100644 index cd706e0..0000000 --- a/CMakePresets.json +++ /dev/null @@ -1,134 +0,0 @@ -{ - "version": 6, - "cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 }, - "configurePresets": [ - { - "name": "vcpkg-common", - "hidden": true, - "description": "Shared base for all presets that link real deps via vcpkg. Uses cmake/voicecat-toolchain.cmake, which auto-resolves VCPKG_TARGET_TRIPLET / VCPKG_HOST_TRIPLET from the host platform (x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon). Cross-compile presets override VCPKG_TARGET_TRIPLET in their cacheVariables. Resolves vcpkg from the bundled git submodule (vcpkg/) unless VCPKG_ROOT points at an external checkout.", - "generator": "Ninja", - "toolchainFile": "${sourceDir}/cmake/voicecat-toolchain.cmake", - "cacheVariables": { - "VOICECAT_USE_VCPKG_DEPS": "ON" - } - }, - { - "name": "dev", - "displayName": "Dev (full real-deps build, vcpkg)", - "description": "Day-to-day development preset. Real protocol, crypto, voice, server. Builds server + tools + tests. Auto-triplet: x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT).", - "inherits": "vcpkg-common", - "binaryDir": "${sourceDir}/build/dev", - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Debug", - "VOICECAT_BUILD_TOOLS": "ON", - "VOICECAT_BUILD_TESTS": "ON" - } - }, - { - "name": "release", - "displayName": "Release (optimized, tests on, symbols kept)", - "description": "Optimized build with the full test suite enabled. Use to run tests against optimized code, profile, or catch optimizer-sensitive bugs. Symbols are kept (not stripped) so stack traces and profiling remain useful. Auto-triplet. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT).", - "inherits": "vcpkg-common", - "binaryDir": "${sourceDir}/build/release", - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Release", - "VOICECAT_BUILD_TOOLS": "ON", - "VOICECAT_BUILD_TESTS": "ON" - } - }, - { - "name": "server-release", - "displayName": "Server Release (optimized, stripped, no tests)", - "description": "Production-shaped build for deployment. Optimized (Release) with stripped binaries (-s linker flag), no tests. This is what you'd ship/run — see docs/deployment.md. Auto-triplet. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT).", - "inherits": "vcpkg-common", - "binaryDir": "${sourceDir}/build/server-release", - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Release", - "VOICECAT_BUILD_TOOLS": "ON", - "VOICECAT_BUILD_TESTS": "OFF", - "CMAKE_EXE_LINKER_FLAGS": "-s", - "CMAKE_SHARED_LINKER_FLAGS": "-s" - } - }, - { - "name": "windows-client", - "displayName": "Windows client (voicecat.dll for C# WinForms)", - "description": "Produces a redistributable Release voicecat.dll with no MinGW runtime DLL dependencies (see core/CMakeLists.txt's static-runtime link flags and clients/windows/README.md). Server/tools/tests are off — this preset exists only to build the DLL. Windows only.", - "inherits": "vcpkg-common", - "binaryDir": "${sourceDir}/build/windows-client", - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Release", - "VOICECAT_BUILD_SHARED": "ON", - "VOICECAT_BUILD_SERVER": "OFF", - "VOICECAT_BUILD_TOOLS": "OFF", - "VOICECAT_BUILD_TESTS": "OFF" - } - }, - { - "name": "apple-dev", - "displayName": "Apple macOS (libvoicecat.a for Swift Package, scaffolding)", - "description": "SCAFFOLDING — not yet CI-validated; build on macOS to verify. Produces a static libvoicecat.a for macOS (arm64-osx on Apple Silicon, x64-osx on Intel) for consumption by the Swift Package / XCFramework. Server/tools/tests off. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT).", - "inherits": "vcpkg-common", - "binaryDir": "${sourceDir}/build/apple-dev", - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Release", - "VOICECAT_BUILD_SERVER": "OFF", - "VOICECAT_BUILD_TOOLS": "OFF", - "VOICECAT_BUILD_TESTS": "OFF" - } - }, - { - "name": "apple-ios", - "displayName": "Apple iOS device (XCFramework slice)", - "description": "Cross-compiles a static libvoicecat.a for iOS device (arm64). One slice of the XCFramework. Server/tools/tests off. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT) and a macOS host with iOS SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios.cmake (release-only, correct autoconf host triple).", - "inherits": "vcpkg-common", - "binaryDir": "${sourceDir}/build/apple-ios", - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Release", - "CMAKE_SYSTEM_NAME": "iOS", - "CMAKE_SYSTEM_PROCESSOR": "arm64", - "CMAKE_OSX_ARCHITECTURES": "arm64", - "CMAKE_OSX_SYSROOT": "iphoneos", - "CMAKE_OSX_DEPLOYMENT_TARGET": "17.0", - "VCPKG_TARGET_TRIPLET": "arm64-ios", - "VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets", - "VOICECAT_BUILD_SERVER": "OFF", - "VOICECAT_BUILD_TOOLS": "OFF", - "VOICECAT_BUILD_TESTS": "OFF" - } - }, - { - "name": "apple-ios-sim", - "displayName": "Apple iOS simulator (XCFramework slice)", - "description": "Cross-compiles a static libvoicecat.a for iOS simulator (arm64-ios-simulator). One slice of the XCFramework. Server/tools/tests off. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT) and a macOS host with iOS simulator SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake (release-only, correct autoconf host triple).", - "inherits": "vcpkg-common", - "binaryDir": "${sourceDir}/build/apple-ios-sim", - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Release", - "CMAKE_SYSTEM_NAME": "iOS", - "CMAKE_SYSTEM_PROCESSOR": "arm64", - "CMAKE_OSX_ARCHITECTURES": "arm64", - "CMAKE_OSX_SYSROOT": "iphonesimulator", - "CMAKE_OSX_DEPLOYMENT_TARGET": "17.0", - "VCPKG_TARGET_TRIPLET": "arm64-ios-simulator", - "VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets", - "VOICECAT_BUILD_SERVER": "OFF", - "VOICECAT_BUILD_TOOLS": "OFF", - "VOICECAT_BUILD_TESTS": "OFF" - } - } - ], - "buildPresets": [ - { "name": "dev", "configurePreset": "dev" }, - { "name": "release", "configurePreset": "release" }, - { "name": "server-release", "configurePreset": "server-release" }, - { "name": "windows-client", "configurePreset": "windows-client" }, - { "name": "apple-dev", "configurePreset": "apple-dev" }, - { "name": "apple-ios", "configurePreset": "apple-ios" }, - { "name": "apple-ios-sim", "configurePreset": "apple-ios-sim" } - ], - "testPresets": [ - { "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } }, - { "name": "release", "configurePreset": "release", "output": { "outputOnFailure": true } } - ] -} diff --git a/dotnet/Directory.Build.props b/Directory.Build.props similarity index 100% rename from dotnet/Directory.Build.props rename to Directory.Build.props diff --git a/dotnet/Directory.Build.targets b/Directory.Build.targets similarity index 100% rename from dotnet/Directory.Build.targets rename to Directory.Build.targets diff --git a/Dockerfile b/Dockerfile index 6e85b51..1c82d74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,9 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0.203-noble AS build WORKDIR /src COPY global.json Directory.Build.props Directory.Build.targets ./ COPY proto/voicecat.proto proto/voicecat.proto -COPY dotnet/ dotnet/ -RUN dotnet restore dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj -r linux-x64 --locked-mode -p:NuGetLockFilePath=packages.publish.linux-x64.lock.json -RUN dotnet publish dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj -c Release -r linux-x64 --self-contained true --no-restore \ +COPY src/ src/ +RUN dotnet restore src/VoiceCat.Server/VoiceCat.Server.csproj -r linux-x64 --locked-mode -p:NuGetLockFilePath=packages.publish.linux-x64.lock.json +RUN dotnet publish src/VoiceCat.Server/VoiceCat.Server.csproj -c Release -r linux-x64 --self-contained true --no-restore \ -p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=true -o /out \ && mkdir /empty-data diff --git a/PROGRESS.md b/PROGRESS.md index 66be97a..5fdc62e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,21 +1,22 @@ # VoiceCat status -Updated: 2026-09-19 +Updated: 2026-09-20 ## Current state -VoiceCat's supported implementation is .NET 10. The managed protocol, crypto, TLS, -server, CLI, client state, audio engine, Windows client, macOS client, and iOS client are -implemented. The previous C++ core/server/CLI and Swift applications are retired migration -sources and may be removed without preserving cross-generation interoperability. +VoiceCat's supported implementation is .NET 10. The managed protocol, crypto, TLS, server, +CLI, client state, audio engine, Windows client, macOS client, and iOS client are implemented. +Retired implementations and compatibility projects have been removed; this tree contains only +the supported product and its required native media boundaries. -The supported source-of-truth layout is: +The source-of-truth layout is: - `proto/voicecat.proto` — wire schema. -- `dotnet/src/` — protocol, crypto, codec/DSP bindings, server, client core, audio, and CLI. +- `src/` — protocol, crypto, codec/DSP bindings, server, client core, audio, and CLI. +- `tests/VoiceCat.Tests/` — managed behavior and integration tests. - `clients/windows/` — WinForms application over the managed core. -- `clients/apple/dotnet/` — AppKit and UIKit applications over the managed core. -- `native/media/` and `native/rnnoise/` — required Opus/RNNoise C shim and vendored RNNoise. +- `clients/apple/` — AppKit and UIKit applications over the managed core. +- `native/media/` and `native/rnnoise/` — required Opus/RNNoise shim and vendored RNNoise. - `native/apple/broadcast/` — required ReplayKit upload extension and shared ring producer. ## Release gates @@ -28,15 +29,7 @@ The supported source-of-truth layout is: - Complete Developer ID signing/notarization and iOS distribution signing. - Run the published Linux container and a 30-minute-or-longer server soak. -## Cleanup in progress - -- Delete the retired C++ implementation and old Swift applications after retained assets and - build inputs are detached from their trees. -- Move the Windows compatibility model types out of `VoiceCat.Interop`, then delete that old - P/Invoke project and its tests. -- Rewrite or remove historical design documents that still describe the retired architecture. - ## Working rule -Keep this file short. It records only current state, open release gates, and the immediate -cleanup queue. Git history is the implementation diary. +Keep this file short. It records only current state and open release gates. Git history is the +implementation diary. diff --git a/README.md b/README.md index 0e07ac3..99458ec 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ extension captures ReplayKit application audio. ## Build ```bash -./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 +./scripts/build-native.ps1 +dotnet restore VoiceCat.slnx --locked-mode +dotnet build VoiceCat.slnx -c Release --no-restore +dotnet test VoiceCat.slnx -c Release --no-build ``` See [CLAUDE.md](CLAUDE.md) for the developer map, [docs/README.md](docs/README.md) for current @@ -24,19 +24,16 @@ contracts, and [PROGRESS.md](PROGRESS.md) for the short release handoff. ```text proto/ protobuf wire schema -dotnet/src/ managed protocol, crypto, server, client, audio, and CLI -dotnet/tests/ managed behavior and integration tests +src/ managed protocol, crypto, server, client, audio, and CLI +tests/ managed behavior and integration tests clients/windows/ WinForms client -clients/apple/dotnet/ AppKit and UIKit clients +clients/apple/ AppKit and UIKit clients native/media/ narrow Opus/RNNoise C shim native/rnnoise/ vendored RNNoise source and model native/apple/broadcast/ ReplayKit broadcast extension docs/ current contracts and operating documentation ``` -The remaining C++ implementation and old Swift applications are unsupported retirement -sources. They are not compatibility targets or architectural authorities. - ## Non-negotiable constraints - Encryption is mandatory. diff --git a/dotnet/VoiceCat.slnx b/VoiceCat.slnx similarity index 89% rename from dotnet/VoiceCat.slnx rename to VoiceCat.slnx index 62456af..04b1c0f 100644 --- a/dotnet/VoiceCat.slnx +++ b/VoiceCat.slnx @@ -13,6 +13,6 @@ - + diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift deleted file mode 100644 index abf105f..0000000 --- a/clients/apple/Package.swift +++ /dev/null @@ -1,68 +0,0 @@ -// swift-tools-version: 6.0 -// -// VoiceCatCore — the shared Swift core for the VoiceCat macOS (AppKit) and iOS (SwiftUI) -// clients. It wraps libvoicecat's C ABI (core/include/voicecat.h) as imported through the -// VoiceCatCore.xcframework binary target's module map (`import VoiceCatC`), and exposes a -// Swift-idiomatic, @MainActor-safe surface. -// -// Architecture: docs/architecture.md §4 ("one core, many faces"). The Windows C# client -// (clients/windows/VoiceCat.Interop) is the proven mirror of this same layering — the Swift -// wrapper follows the same patterns (callback-lifetime, string-lifetime, event-delivery -// thread handoff, immediate vc_free_* on list reads) adapted to Swift's interop model. -// -// The XCFramework is a LOCAL BUILD ARTIFACT — run `scripts/build-xcframework.sh` before -// `swift build` / `swift test`. See clients/apple/README.md. -import PackageDescription - -let package = Package( - name: "VoiceCatCore", - // macOS 14 (Sonoma) is the AppKit client's deployment target. iOS 18 is the SwiftUI client - // target (clients/apple/iOS/) — 18.0 unlocks the newest AVAudioSession APIs (stereo capture, - // polar patterns, data sources). Run `scripts/build-xcframework.sh --all` to produce all - // three slices: macos-arm64, ios-arm64, ios-arm64-simulator. - // swift-tools-version 6.0 is required for .iOS(.v18); swiftLanguageVersions .v5 keeps the - // Swift 5 language mode (avoids Swift 6 strict concurrency checking on pre-existing code). - platforms: [ - .macOS(.v14), - .iOS(.v18), - ], - products: [ - .library(name: "VoiceCatCore", targets: ["VoiceCatCore"]), - ], - targets: [ - // Binary target — the prebuilt static lib + headers + module map. Produced by - // scripts/build-xcframework.sh from the `apple-dev` CMake preset. - .binaryTarget( - name: "VoiceCatCoreXCF", - path: "VoiceCatCore.xcframework" - ), - // The Swift wrapper library — what the macOS/iOS apps import as `import VoiceCatCore`. - .target( - name: "VoiceCatCore", - dependencies: ["VoiceCatCoreXCF"], - path: "Sources/VoiceCatCore", - // Event-cue WAVs (shared with the Windows client) bundled into the package's - // resource bundle; EventFeedback loads them via Bundle.module. Copied from - // assets/sounds/ into Sources/VoiceCatCore/Sounds/. - resources: [.process("Sounds")] - ), - // Smoke tests against a real voicecat-server — mirrors clients/windows/ - // VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. Requires the `dev` CMake preset - // to be built (build/dev/bin/voicecat-server + voicecat-admin). - // - // linkerSettings: libvoicecat.a is a static C++20 library (built by the apple-dev - // preset with vcpkg's clang), so the final executable must link libc++ (the LLVM C++ - // standard library on macOS). vcpkg's static deps (mbedtls/sodium/opus/protobuf/ - // sqlite3/spdlog/asio) are already compiled into the .a; macOS system frameworks - // (CoreAudio/CoreFoundation) are auto-discovered by the linker (PROGRESS.md). - .testTarget( - name: "VoiceCatCoreTests", - dependencies: ["VoiceCatCore"], - path: "Tests/VoiceCatCoreTests", - linkerSettings: [ - .linkedLibrary("c++"), - ] - ), - ], - swiftLanguageModes: [.v5] -) diff --git a/clients/apple/README.md b/clients/apple/README.md index 25ab867..929815b 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -1,160 +1,25 @@ -# Apple client (macOS + iOS) +# Apple clients -Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). One shared **Swift core** -(`VoiceCatCore` package) wrapping the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)), -with platform-specific UIs: **AppKit** for macOS (best VoiceOver accessibility), **SwiftUI** -for iOS. See [`docs/architecture.md`](../../docs/architecture.md) §4 and -[`docs/tech-stack.md`](../../docs/tech-stack.md) §2. +`VoiceCat.Mac` and `VoiceCat.iOS` are .NET 10 AppKit and UIKit clients over the shared managed +core. The ReplayKit upload extension under `native/apple/broadcast` remains Swift because it +runs under the extension memory limit and writes the versioned shared audio ring. -## What's here now - -### `VoiceCatCore` Swift Package — ✓ complete (2026-06-18) - -The shared Swift core that both the macOS AppKit app and the iOS SwiftUI app will consume. -Mirrors the Windows client's `VoiceCat.Interop` layer ([`clients/windows/`](../windows/)) -using Swift-native C interop instead of P/Invoke. - -``` -clients/apple/ -├── Package.swift # SPM: binary target (XCFramework) + VoiceCatCore library + tests -├── VoiceCatCore.xcframework/ # BUILT ARTIFACT — produced by scripts/build-xcframework.sh (gitignored) -├── scripts/ -│ └── build-xcframework.sh # builds libvoicecat + vcpkg deps → fat .a → XCFramework + module map -├── Sources/VoiceCatCore/ -│ ├── Enums.swift # Swift-idiomatic mirrors of the 9 voicecat.h C enums -│ ├── Config.swift # VoiceCatConfig (wraps vc_config) -│ ├── Event.swift # VoiceCatEvent — copies ev.text inside the callback (the #1 lifetime rule) -│ ├── Models.swift # Channel, User, Stream, Device, Permissions, Account, AudioConfig, … -│ ├── Marshaling.swift # C arrays → Swift arrays + immediate vc_free_* (callers never manage native lifetime) -│ ├── Callbacks.swift # @convention(c) on_event/on_level + Unmanaged.passUnretained context bridging -│ └── VoiceCatClient.swift # the public Swift surface — owns vc_client*, all 38 C functions, event delivery on @MainActor -└── Tests/VoiceCatCoreTests/ - └── VoiceCatClientSmokeTests.swift # 6 XCTest smoke tests against a real voicecat-server (6/6 green) -``` - -**Key patterns** (carried over from the proven C# `VoiceCat.Interop` — see -[`docs/architecture.md`](../../docs/architecture.md) §4 per-platform binding notes): - -- **C interop via module map:** `import VoiceCatC` — Swift sees all C enums/structs/functions - directly. No manual struct/function redeclaration (unlike C# P/Invoke). The module map - (`module VoiceCatC { header "voicecat.h" }`) is staged into the XCFramework headers by - `build-xcframework.sh`. -- **`@convention(c)` callbacks:** plain C function pointers (not ARC-managed closures) + - `Unmanaged.passUnretained(self)` as the `user` context — the Swift analog of C#'s - `[UnmanagedCallersOnly]` + `GCHandle`. `deinit` calls `vc_client_destroy` (joins all - threads) before the object's memory is freed, so no callback can fire with a dangling - pointer. -- **Config string lifetimes:** the core stores raw pointers from `vc_config` (doesn't copy). - Native CString storage (`strdup`) is held for the client's entire lifetime, freed in - `deinit` after `vc_client_destroy`. -- **Event delivery:** events buffered in a lock-protected array + coalesced - `DispatchQueue.main` drain (one async block at a time) — the Swift analog of C#'s - `Channel` + 30ms WinForms Timer pump. `ev.text` is copied to `String` - inside the callback before enqueueing (dangling-pointer rule). -- **Level meters:** coalesced to latest-per-stream-id (intermediate values are visually - irrelevant, same as C#'s `ConcurrentDictionary`). -- **Immediate `vc_free_*`** on list reads — callers never manage native list lifetime. - -### Tests — 6/6 green - -``` -swift test -# ✓ testVersionStringIsNonEmpty -# ✓ testResultStringRoundTrips -# ✓ testConnectTofuAuthListChannelsRoundTrips (connect → TOFU → confirm → guest auth → channels → permissions → guest ListAccounts rejected) -# ✓ testAdminChannelCrudAccountCrudRoundTrips (admin auth → channel create/edit/delete → account create/list/reset/delete) -# ✓ testScreenAudioStreamStartsAndStops (screen-audio stream start/stop through Swift interop) -# ✓ testPerStreamRecvControlsRoundTrip (two clients, per-stream gain/mute/NR round-trip) -``` - -Prerequisites for tests: `cmake --preset dev && cmake --build --preset dev` (builds -`voicecat-server` + `voicecat-admin` into `build/dev/bin/`). - -## What's NOT here yet (next steps) - -- **macOS AppKit app** (`clients/apple/macOS/`) — the M4 UI: connect dialog, saved-server - list (Keychain for passwords), TOFU identity dialog, main window (NSOutlineView channel - tree, NSTableView user list, NSTextView chat, activity log), voice controls, per-user - tuning, full VoiceOver accessibility. Mirrors the Windows `VoiceCat.App` feature set. -- **iOS SwiftUI app** — AVAudioSession, mic permission, foreground voice. -- **`vc_audio_suspend`/`vc_audio_resume` ABI hooks** — deferred until the iOS client - milestone (keep ABI stable). -- **ReplayKit Broadcast Upload Extension** for iOS `SCREEN_AUDIO` ([`docs/voice.md`](../../docs/voice.md) §9). -- **macOS `SCREEN_AUDIO`** via ScreenCaptureKit (currently stub returns `false`). -- **iOS XCFramework slices** — `apple-ios` / `apple-ios-sim` presets are scaffolding; run - `scripts/build-xcframework.sh --all` once the iOS vcpkg triplets are validated. - -## Building the XCFramework - -The XCFramework is a **local build artifact** (gitignored, like the Windows client's -`build/windows-client/bin/voicecat.dll`). Run the build script before `swift build` / -`swift test`: +Build on macOS with Xcode and the pinned .NET workloads: ```bash -# Prerequisites: VCPKG_ROOT set, Xcode installed -export VCPKG_ROOT=/path/to/vcpkg - -# Build the macOS slice + fat static lib + XCFramework (validated) -scripts/build-xcframework.sh -# → clients/apple/VoiceCatCore.xcframework/ (macOS-arm64 slice) - -# Build all 3 slices (macOS + iOS device + iOS sim) — iOS still scaffolding -scripts/build-xcframework.sh --all +./scripts/build-native.ps1 +./scripts/build-native-ios.sh +dotnet restore clients/apple/VoiceCat.Apple.slnx +dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore ``` -### Fat static library +Use `publish-macos.sh --dry-run` to validate an ad-hoc macOS bundle. For distribution, set +`VOICECAT_CODESIGN_IDENTITY`; optional notarization uses `APPLE_ID`, `APPLE_TEAM_ID`, and +`APPLE_APP_PASSWORD`. -The `apple-dev` CMake preset produces a 1.9 MB `libvoicecat.a` containing only voicecat's -own object files — vcpkg's static dependencies (protobuf, mbedtls, libsodium, opus, sqlite3, -spdlog, asio, abseil, …) are 107 separate `.a` files under `vcpkg_installed/arm64-osx/lib/`, -and the vendored RNNoise noise-suppression lib (`native/rnnoise/`, built as a CMake -target → `build//lib/librnnoise.a`) is another. A Swift Package binary target can -only link ONE `.a` per XCFramework slice, so `build-xcframework.sh` merges them all — vcpkg -deps plus the locally-built vendored libs — into a single self-contained `libvoicecat-fat.a` -(~33 MB) using `libtool -static`. This is the Apple equivalent of how the Windows client -ships a single `voicecat.dll` with all deps statically linked (via MinGW's `-static` flags -in [`core/CMakeLists.txt`](../../core/CMakeLists.txt)). If you add another vendored (non-vcpkg) -static-lib target to the core, it's picked up automatically as long as it lands in -`build//lib/` and isn't named `libvoicecat*`. +For a physical iOS device, use `build-ios-device.sh` and `deploy-ios-device.sh`. The host and +ReplayKit extension require signing profiles with App Group `group.me.iamtalon.voicecat`. +Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route +changes, Bluetooth, ReplayKit, and iOS 27 ScreenCaptureKit audio. -### Swift Package - -```bash -swift build # builds VoiceCatCore library -swift test # runs 6 smoke tests against a real voicecat-server -``` - -The `Package.swift` declares: -- A **binary target** (`VoiceCatCoreXCF`) pointing at the local `VoiceCatCore.xcframework`. -- A **library target** (`VoiceCatCore`) that depends on the binary target and provides the - Swift wrapper. -- A **test target** (`VoiceCatCoreTests`) with `linkerSettings: [.linkedLibrary("c++")]` — - the fat static lib is C++20, so the final executable must link libc++ (the LLVM C++ standard - library on macOS). vcpkg's static deps are already in the `.a`; macOS system frameworks - (CoreAudio/CoreFoundation) are auto-discovered by the linker. - -## Ad-hoc distribution (iOS, pre-TestFlight) - -To hand the iOS app to a handful of friends before TestFlight, use -[`scripts/dist-ios-adhoc.sh`](../../scripts/dist-ios-adhoc.sh). It registers each device's -UDID, builds an ad-hoc-signed `VoiceCatiOS.ipa`, and generates the `manifest.plist` + -`index.html` for an over-the-air (`itms-services://`) web install. Ad-hoc builds only run on -devices whose UDID is registered *before* signing, and stock iOS won't install a bare `.ipa` -without a sideloading tool — so the web-install page is the friend-friendly path. - -```bash -# One-time: create an App Store Connect API "Team Key" (.p8, Admin/App Manager access) at -# App Store Connect → Users and Access → Integrations → App Store Connect API -export ASC_KEY_ID=ABC123 ASC_ISSUER_ID=1111-... ASC_KEY_PATH=~/.appstoreconnect/AuthKey_ABC123.p8 - -# Register a device + build + stage everything into dist/ios-adhoc/ -scripts/dist-ios-adhoc.sh --udid --name "Friend iPhone" \ - --base-url https://example.com/voicecat -``` - -Then upload the three staged files (`VoiceCatiOS.ipa`, `manifest.plist`, `index.html`) to -that **HTTPS** folder and open `index.html` in Safari on a registered iPhone (iOS 18+). -Device UDID registration is automated via [`scripts/asc_api.py`](../../scripts/asc_api.py) -(`asc_api.py list` shows the registered devices against the 100-iOS-devices/year cap). -Requires a paid Apple Developer Program membership. Run `scripts/dist-ios-adhoc.sh --help` -for all flags. +The shared ring contract is documented in `docs/broadcast-ring-format.md`. diff --git a/clients/apple/Sources/VoiceCatCore/Callbacks.swift b/clients/apple/Sources/VoiceCatCore/Callbacks.swift deleted file mode 100644 index b4cb6e6..0000000 --- a/clients/apple/Sources/VoiceCatCore/Callbacks.swift +++ /dev/null @@ -1,37 +0,0 @@ -// C callbacks use an unretained `user` context. Client destruction joins callback threads, -// and transient event pointers are copied before the callback returns. - -import VoiceCatC -import Foundation - -/// Internal: builds the `vc_callbacks` struct wired to VoiceCatClient's C function pointers. -/// The `user` context is an Unmanaged-passUnretained pointer to the client — resolved back -/// to the client inside `onEvent`/`onLevel` below. -internal enum Callbacks { - /// The `on_event` C function pointer. Non-capturing @convention(c) closure — resolves - /// the VoiceCatClient from `user` and enqueues a safe copy of the event. - static let onEvent: @convention(c) ( - UnsafeMutableRawPointer?, UnsafePointer? - ) -> Void = { user, ev in - guard let user, let ev else { return } - let client = Unmanaged.fromOpaque(user).takeUnretainedValue() - // Copy the event (including text) to a Swift value NOW — the raw vc_event is - // invalid after this callback returns. - client.enqueueEvent(VoiceCatEvent.from(ev.pointee)) - } - - /// The `on_level` C function pointer. Coalesces to "latest sample per stream_id" - /// (intermediate values are visually irrelevant — same as C#'s ConcurrentDictionary). - static let onLevel: @convention(c) ( - UnsafeMutableRawPointer?, UInt32, Float - ) -> Void = { user, streamId, rms in - guard let user else { return } - let client = Unmanaged.fromOpaque(user).takeUnretainedValue() - client.enqueueLevel(streamId, rms) - } - - /// Construct the vc_callbacks struct for a given client. - static func make(user: UnsafeMutableRawPointer) -> vc_callbacks { - vc_callbacks(on_event: onEvent, on_level: onLevel, user: user) - } -} diff --git a/clients/apple/Sources/VoiceCatCore/Config.swift b/clients/apple/Sources/VoiceCatCore/Config.swift deleted file mode 100644 index ffd97d7..0000000 --- a/clients/apple/Sources/VoiceCatCore/Config.swift +++ /dev/null @@ -1,30 +0,0 @@ -// VoiceCatConfig — Swift-idiomatic mirror of `vc_config` (voicecat.h). Passed to -// VoiceCatClient.init. The native CString storage for the string fields is held for the -// client's entire lifetime inside VoiceCatClient — see VoiceCatClient.swift's doc comment -// on why (the core stores raw pointers from vc_config by value, it does not copy the data). - -import VoiceCatC - -/// Configuration for a `VoiceCatClient`. Mirrors `vc_config`. -public struct VoiceCatConfig: Sendable { - /// E.g. "VoiceCat-macOS". Forwarded in `ClientHello.client_name`. - public let clientName: String - /// E.g. "0.0.1". Forwarded in `ClientHello.client_version`. - public let clientVersion: String - public let logLevel: VoiceCatLogLevel - /// Path to the TOFU pin file (see `confirmServerIdentity` / docs/security.md §1.1). - /// nil = built-in relative default (only suitable for tests). - public let tofuStorePath: String? - - public init( - clientName: String, - clientVersion: String, - logLevel: VoiceCatLogLevel = .info, - tofuStorePath: String? = nil - ) { - self.clientName = clientName - self.clientVersion = clientVersion - self.logLevel = logLevel - self.tofuStorePath = tofuStorePath - } -} diff --git a/clients/apple/Sources/VoiceCatCore/Enums.swift b/clients/apple/Sources/VoiceCatCore/Enums.swift deleted file mode 100644 index 91975fb..0000000 --- a/clients/apple/Sources/VoiceCatCore/Enums.swift +++ /dev/null @@ -1,157 +0,0 @@ -// Swift-idiomatic mirrors of the voicecat.h C enums. Keep these in lockstep with -// core/include/voicecat.h — values are append-only per the C ABI's house rule, so it's -// safe to add new cases at the end here too, but never renumber/remove existing ones. -// -// Swift imports the C enums directly via `import VoiceCatC` (e.g. VoiceCatC.VC_OK), but -// those case names are C-style (VC_ERR_NOT_IMPLEMENTED, VC_EVENT_SERVER_IDENTITY) — these -// mirrors give the Swift UI and tests clean dot-syntax (VoiceCatResult.notImplemented, -// VoiceCatEventType.serverIdentity) and a typed bridge to/from the C values. -// -// NOTE: Swift's Clang importer brings C `typedef enum` types in as UInt32-backed enums -// (all our C enum values are non-negative), so these mirrors use UInt32 raw values too. -// The one signed field in the ABI — `vc_event.result` is `int32_t` (not `vc_result`) — is -// bridged via `UInt32(bitPattern:)` in Event.swift. - -import VoiceCatC - -/// Result codes — mirrors `vc_result` (voicecat.h). Additive-only: new values go at the end. -public enum VoiceCatResult: UInt32, Sendable, Equatable { - case ok = 0 - case notImplemented = 1 - case invalidArg = 2 - case notConnected = 3 - case already = 4 - case authFailed = 5 - case permissionDenied = 6 - case timeout = 7 - case io = 8 - case protocolError = 9 - case crypto = 10 - case audio = 11 - case internalError = 12 - - /// Human-readable description from the core (vc_result_string returns a static literal). - public var description: String { - String(cString: vc_result_string(vc_result(rawValue))) - } - - /// Bridge from the C enum. - public init(_ cValue: vc_result) { self = VoiceCatResult(rawValue: cValue.rawValue) ?? .internalError } - /// Bridge to the C enum. - public var cValue: vc_result { vc_result(rawValue) } -} - -/// Log level — mirrors `vc_log_level`. -public enum VoiceCatLogLevel: UInt32, Sendable, Equatable { - case trace = 0 - case debug = 1 - case info = 2 - case warn = 3 - case error = 4 - case off = 5 - - public init(_ cValue: vc_log_level) { self = VoiceCatLogLevel(rawValue: cValue.rawValue) ?? .info } - public var cValue: vc_log_level { vc_log_level(rawValue) } -} - -/// Connection state — mirrors `vc_connection_state`. -public enum VoiceCatConnectionState: UInt32, Sendable, Equatable { - case disconnected = 0 - case connecting = 1 - case tlsHandshake = 2 - case authenticating = 3 - case connected = 4 - /// Handshake succeeded, waiting on `confirmServerIdentity()`. - case verifyingIdentity = 5 - - public init(_ cValue: vc_connection_state) { - self = VoiceCatConnectionState(rawValue: cValue.rawValue) ?? .disconnected - } - public var cValue: vc_connection_state { vc_connection_state(rawValue) } -} - -/// Text message scope — mirrors `vc_text_scope`. -public enum VoiceCatTextScope: UInt32, Sendable, Equatable { - case channel = 0 - case `private` = 1 - case server = 2 - - public init(_ cValue: vc_text_scope) { self = VoiceCatTextScope(rawValue: cValue.rawValue) ?? .channel } - public var cValue: vc_text_scope { vc_text_scope(rawValue) } -} - -/// Audio device kind — mirrors `vc_device_kind`. -public enum VoiceCatDeviceKind: UInt32, Sendable, Equatable { - case input = 0 - case output = 1 - - public init(_ cValue: vc_device_kind) { self = VoiceCatDeviceKind(rawValue: cValue.rawValue) ?? .input } - public var cValue: vc_device_kind { vc_device_kind(rawValue) } -} - -/// Stream kind — mirrors `vc_stream_kind`. -public enum VoiceCatStreamKind: UInt32, Sendable, Equatable { - case mic = 0 - /// System/desktop audio (docs/voice.md §9). - case screenAudio = 1 - case auxDevice = 2 - - public init(_ cValue: vc_stream_kind) { self = VoiceCatStreamKind(rawValue: cValue.rawValue) ?? .mic } - public var cValue: vc_stream_kind { vc_stream_kind(rawValue) } -} - -/// Send-side input gate mode (docs/voice.md §11) — mirrors `vc_input_mode`. -public enum VoiceCatInputMode: UInt32, Sendable, Equatable { - case voiceActivation = 0 - case pushToTalk = 1 - /// Transmit unconditionally, no VAD gate. - case alwaysOn = 2 - - public init(_ cValue: vc_input_mode) { self = VoiceCatInputMode(rawValue: cValue.rawValue) ?? .voiceActivation } - public var cValue: vc_input_mode { vc_input_mode(rawValue) } -} - -/// Event type — mirrors `vc_event_type`. Additive-only. -public enum VoiceCatEventType: UInt32, Sendable, Equatable { - case connectionState = 0 - case authResult = 1 - case channelList = 2 - case userJoined = 3 - case userLeft = 4 - case userUpdated = 5 - case textMessage = 6 - case streamStarted = 7 - case streamStopped = 8 - case talkState = 9 - case error = 10 - case disconnected = 11 - /// Reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`. - case joinResult = 12 - /// The TOFU server-identity gate — see `VoiceCatEvent.tofuStatus` / `.text`. - case serverIdentity = 13 - /// Async result for moderation/admin/channel operations. - case genericResult = 14 - /// Reply to `requestAccountList()` — call `listAccounts()` to read. - case accountList = 15 - /// Voice-plane subscription state. `u32a` = 1 (subscribed) or 0 (unsubscribed). - case voiceState = 16 - - public init(_ cValue: vc_event_type) { - self = VoiceCatEventType(rawValue: cValue.rawValue) ?? .error - } - public var cValue: vc_event_type { vc_event_type(rawValue) } -} - -/// TOFU server-identity classification — mirrors `vc_tofu_status`. Pins the TLS leaf -/// certificate's SHA-256 fingerprint (NOT the display-only Ed25519 value — see -/// docs/security.md §1.1 and `VoiceCatServerIdentity`). -public enum VoiceCatTofuStatus: UInt32, Sendable, Equatable { - case firstConnect = 0 - case matched = 1 - case mismatch = 2 - - public init(_ cValue: vc_tofu_status) { - self = VoiceCatTofuStatus(rawValue: cValue.rawValue) ?? .firstConnect - } - public var cValue: vc_tofu_status { vc_tofu_status(rawValue) } -} diff --git a/clients/apple/Sources/VoiceCatCore/Event.swift b/clients/apple/Sources/VoiceCatCore/Event.swift deleted file mode 100644 index 01859a6..0000000 --- a/clients/apple/Sources/VoiceCatCore/Event.swift +++ /dev/null @@ -1,61 +0,0 @@ -// VoiceCatEvent — a Swift value type that is safe to hold/queue past the native callback's -// return. This is the Swift analog of the C# client's `VoiceCatEvent` record. -// -// CRITICAL (voicecat.h's vc_event doc comment): the native `vc_event.text` pointer is owned -// by the core and valid ONLY for the duration of the `on_event` callback. `from(_:)` copies -// it to a Swift `String` immediately — never hold the raw `vc_event` across the callback -// boundary, or `text` will be a dangling pointer by the time it's read. This is the #1 -// lifetime rule carried over from the Windows client (NativeCallbacks.cs / VoiceCatEvent.cs). - -import VoiceCatC - -/// A Swift-safe copy of a `vc_event`. Produced inside the `on_event` callback (see -/// Callbacks.swift) — all pointer fields are converted to value types before the callback -/// returns. -public struct VoiceCatEvent: Sendable, Equatable { - public let type: VoiceCatEventType - public let connectionState: VoiceCatConnectionState - public let result: VoiceCatResult - public let userId: UInt32 - public let channelId: UInt32 - public let streamId: UInt32 - public let textScope: VoiceCatTextScope - /// Generic small payload, meaning per event type. For `.serverIdentity` this is the - /// `VoiceCatTofuStatus`; for `.genericResult` the server error code; for `.talkState` - /// talking(0/1). - public let u32a: UInt32 - /// Copied from the core's `vc_event.text` inside the callback. nil if the core passed NULL. - public let text: String? - public let timestampUnixMs: UInt64 - - /// Convenience: the TOFU status, valid when `type == .serverIdentity` (maps `u32a`). - public var tofuStatus: VoiceCatTofuStatus? { - type == .serverIdentity ? VoiceCatTofuStatus(rawValue: u32a) : nil - } - - /// Copy a native `vc_event` into a safe Swift value. MUST be called inside the callback - /// while `ev.text` is still valid — `String(cString:)` copies the bytes here. - @inline(__always) - public static func from(_ ev: vc_event) -> VoiceCatEvent { - let text: String? - if let raw = ev.text { - text = String(cString: raw) // copies — safe to hold past callback return - } else { - text = nil - } - // ev.result is int32_t (not vc_result) per voicecat.h — bridge via bitPattern. - // ev.u32a is uint32_t — matches VoiceCatTofuStatus's UInt32 raw value directly. - return VoiceCatEvent( - type: VoiceCatEventType(ev.type), - connectionState: VoiceCatConnectionState(ev.connection_state), - result: VoiceCatResult(rawValue: UInt32(bitPattern: ev.result)) ?? .internalError, - userId: ev.user_id, - channelId: ev.channel_id, - streamId: ev.stream_id, - textScope: VoiceCatTextScope(ev.text_scope), - u32a: ev.u32a, - text: text, - timestampUnixMs: ev.timestamp_unix_ms - ) - } -} diff --git a/clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift b/clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift deleted file mode 100644 index dc25dba..0000000 --- a/clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift +++ /dev/null @@ -1,105 +0,0 @@ -// EventFeedback — shared audible + spoken feedback for session events, used by both the macOS -// (AppKit) and iOS (SwiftUI) clients. Mirrors the Windows client's EventFeedback policy -// (clients/windows/.../Notifications/EventFeedback.cs): the platform event handlers decide WHAT -// to play (they own the model/nickname/channel context); this type owns the "should I, and how" -// policy plus the AVFoundation playback/synthesis. -// -// Sound effects use AVAudioPlayer; spoken announcements use the OS-native AVSpeechSynthesizer. -// -// NOTE (iOS): on the voice path the app runs a play-and-record AVAudioSession (VPIO). Playing -// these cues / speech over that session can interact with the live call (ducking, route, or the -// mute switch). The session category should allow mixing — verify on device. This is the most -// likely place for platform bugs to surface. - -import Foundation -import AVFoundation - -/// User preferences for event sounds and spoken feedback, backed by UserDefaults so the macOS -/// and iOS settings screens and this player share one source of truth. -public struct FeedbackSettings: Sendable { - public var sounds: Bool - public var speech: Bool - public var volume: Float - public var selfTalkSounds: Bool - public var pttSound: Bool - - static let keySounds = "feedback.sounds" - static let keySpeech = "feedback.speech" - static let keyVolume = "feedback.volume" - static let keySelfTalk = "feedback.selfTalk" - static let keyPtt = "feedback.ptt" - - /// Default values registered with UserDefaults (so "unset" reads as the intended default - /// rather than false/0). - static let defaults: [String: Any] = [ - keySounds: true, - keySpeech: false, - keyVolume: 1.0, - keySelfTalk: false, - keyPtt: false, - ] - - /// The current settings, read live from UserDefaults. - public static var current: FeedbackSettings { - let d = UserDefaults.standard - d.register(defaults: defaults) // idempotent — ensures "unset" reads as the intended default - return FeedbackSettings( - sounds: d.bool(forKey: keySounds), - speech: d.bool(forKey: keySpeech), - volume: d.float(forKey: keyVolume), - selfTalkSounds: d.bool(forKey: keySelfTalk), - pttSound: d.bool(forKey: keyPtt)) - } -} - -@MainActor -public final class EventFeedback { - public static let shared = EventFeedback() - - private var players: [SoundEvent: AVAudioPlayer] = [:] - private let synthesizer = AVSpeechSynthesizer() - - private init() { - UserDefaults.standard.register(defaults: FeedbackSettings.defaults) - } - - // MARK: - Sounds - - /// Play an event cue, honouring the user's settings. The two opt-in categories (your own - /// voice-activity, and the PTT cue) are gated by their own flags. - public func play(_ event: SoundEvent) { - let s = FeedbackSettings.current - guard s.sounds, s.volume > 0 else { return } - if (event == .vaStart || event == .vaStop), !s.selfTalkSounds { return } - if event == .ptt, !s.pttSound { return } - - guard let player = player(for: event) else { return } - player.volume = s.volume - player.currentTime = 0 - player.play() - } - - /// Lazily load and cache an AVAudioPlayer for the event's bundled WAV. Returns nil (silent) - /// if the resource is missing or fails to load. - private func player(for event: SoundEvent) -> AVAudioPlayer? { - if let cached = players[event] { return cached } - guard let url = Bundle.module.url(forResource: event.resourceName, withExtension: "wav"), - let player = try? AVAudioPlayer(contentsOf: url) else { - return nil - } - player.prepareToPlay() - players[event] = player - return player - } - - // MARK: - Speech - - /// Speak `text` when spoken feedback is enabled. Utterances queue (do not interrupt prior - /// speech) so a burst of events is read in order. - public func speak(_ text: String) { - guard FeedbackSettings.current.speech else { return } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - synthesizer.speak(AVSpeechUtterance(string: trimmed)) - } -} diff --git a/clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift b/clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift deleted file mode 100644 index 2b9cb32..0000000 --- a/clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift +++ /dev/null @@ -1,43 +0,0 @@ -// SoundEvent — the cross-platform set of audible event cues. Each maps to a WAV bundled as an -// SPM resource (Sources/VoiceCatCore/Sounds/, copied from assets/sounds/). The same logical set -// is mirrored in the Windows client (clients/windows/.../Notifications/SoundEvent.cs) so feedback -// stays consistent across platforms. - -import Foundation - -public enum SoundEvent: CaseIterable, Sendable { - case channelJoin // another user joined my channel - case channelLeave // another user left my channel - case channelRecv // channel text message from someone else - case channelSent // channel text message I sent - case pmRecv // private message received - case pmSent // private message I sent - case login // connected / authenticated - case logout // clean disconnect - case connectionLost // unexpected disconnect - case voiceOn // my microphone stream started - case voiceOff // my microphone stream stopped - case vaStart // my voice-activity began (off by default) - case vaStop // my voice-activity ended (off by default) - case ptt // push-to-talk engaged (off by default) - - /// Resource name (without extension) as bundled in Sources/VoiceCatCore/Sounds/. - var resourceName: String { - switch self { - case .channelJoin: return "channel_join" - case .channelLeave: return "channel_leave" - case .channelRecv: return "channel_recv" - case .channelSent: return "channel_sent" - case .pmRecv: return "pm_recv" - case .pmSent: return "pm_sent" - case .login: return "login" - case .logout: return "logout" - case .connectionLost: return "connection_lost" - case .voiceOn: return "voice_on" - case .voiceOff: return "voice_off" - case .vaStart: return "va_start" - case .vaStop: return "va_stop" - case .ptt: return "ptt" - } - } -} diff --git a/clients/apple/Sources/VoiceCatCore/Marshaling.swift b/clients/apple/Sources/VoiceCatCore/Marshaling.swift deleted file mode 100644 index e651d64..0000000 --- a/clients/apple/Sources/VoiceCatCore/Marshaling.swift +++ /dev/null @@ -1,109 +0,0 @@ -// Marshaling — shared "walk a native array of owned-struct entries, convert to Swift value -// types, free the native list" pattern. Identical shape for vc_device_list / vc_channel_list -// / vc_user_list / vc_stream_summary_list / vc_account_list (all core-allocated, caller-freed -// per voicecat.h). The matching vc_free_*_list call happens INSIDE each function here, -// immediately after the conversion, so callers never need to remember to free anything -// themselves. This is the Swift analog of the C# client's Marshaling.cs. - -import VoiceCatC -import Foundation - -/// Internal marshaling helpers — convert core-allocated C arrays to Swift arrays and -/// immediately free the native list. Not part of the public API. -internal enum Marshaling { - /// Convert a nullable `const char*` to a Swift `String` (empty if NULL). - @inline(__always) - static func string(_ ptr: UnsafePointer?) -> String { - guard let ptr else { return "" } - return String(cString: ptr) - } - - static func devices(_ list: inout vc_device_list) -> [Device] { - guard let items = list.items else { vc_free_device_list(&list); return [] } - var result: [Device] = [] - result.reserveCapacity(list.count) - for i in 0.. [Channel] { - guard let items = list.items else { vc_free_channel_list(&list); return [] } - var result: [Channel] = [] - result.reserveCapacity(list.count) - for i in 0.. [User] { - guard let items = list.items else { vc_free_user_list(&list); return [] } - var result: [User] = [] - result.reserveCapacity(list.count) - for i in 0.. [StreamSummary] { - guard let items = list.items else { vc_free_stream_summary_list(&list); return [] } - var result: [StreamSummary] = [] - result.reserveCapacity(list.count) - for i in 0.. [Account] { - guard let items = list.items else { vc_free_account_list(&list); return [] } - var result: [Account] = [] - result.reserveCapacity(list.count) - for i in 0.. RemoteStreamState { - RemoteStreamState(gain: s.gain, muted: s.muted != 0, noiseReduction: s.noise_reduction != 0) - } - - static func audioConfig(_ c: vc_audio_config) -> AudioConfig { - AudioConfig(codec: c.codec, stereo: c.mode != 0, sampleRate: c.sample_rate, - bitrateBps: c.bitrate_bps, frameMs: c.frame_ms, application: c.application, - fec: c.fec != 0, expectedPacketLoss: c.expected_packet_loss, - dtx: c.dtx != 0, complexity: c.complexity, dred: c.dred != 0) - } - - static func permissions(_ p: vc_permissions) -> Permissions { - Permissions(canCreateTempChannel: p.can_create_temp_channel != 0, - canKick: p.can_kick != 0, canBan: p.can_ban != 0, - canMoveUsers: p.can_move_users != 0, - canAdminAccounts: p.can_admin_accounts != 0, - isAdmin: p.is_admin != 0) - } -} diff --git a/clients/apple/Sources/VoiceCatCore/Models.swift b/clients/apple/Sources/VoiceCatCore/Models.swift deleted file mode 100644 index 853bc25..0000000 --- a/clients/apple/Sources/VoiceCatCore/Models.swift +++ /dev/null @@ -1,250 +0,0 @@ -// Plain Swift value types — what survives past the native struct/free-list lifetime -// (Marshaling.swift converts the C structs into these and immediately frees the native -// list). Nothing here holds a raw pointer. This is the Swift analog of the C# client's -// Models.cs. Field naming follows Swift camelCase (the C structs use snake_case). - -import VoiceCatC - -/// Channel snapshot — mirrors `vc_channel` (the pull-based view; re-call `listChannels()` -/// after `.channelList` / `.userJoined` / `.userLeft` / `.userUpdated` events). -public struct Channel: Sendable, Equatable, Identifiable { - public let id: UInt32 - /// 0 = root. - public let parentId: UInt32 - public let name: String - public let topic: String - public let passwordProtected: Bool - /// 0 = unlimited. - public let maxUsers: UInt32 - public let sortOrder: UInt32 - /// Authoritative channel Opus params (docs/voice.md §3). Populated from the Channel proto - /// so the edit dialog can read back the current config. - public let audio: AudioConfig - - public init(id: UInt32, parentId: UInt32, name: String, topic: String, - passwordProtected: Bool, maxUsers: UInt32, sortOrder: UInt32, - audio: AudioConfig) { - self.id = id; self.parentId = parentId; self.name = name; self.topic = topic - self.passwordProtected = passwordProtected; self.maxUsers = maxUsers - self.sortOrder = sortOrder; self.audio = audio - } -} - -/// Channel creation/edition descriptor — mirrors `vc_channel_info`. Used by -/// `createChannel(_:)` and `editChannel(_:)`. `id == 0` means new channel (for create). -public struct ChannelEdit: Sendable, Equatable { - public let id: UInt32 // 0 = new channel for create - public let parentId: UInt32 // 0 = root - public let name: String - public let topic: String - public let passwordProtected: Bool - public let password: String? // nil/empty ignored if passwordProtected == false - public let maxUsers: UInt32 // 0 = unlimited - public let sortOrder: UInt32 - /// 0/nil fields use server defaults. - public let audio: AudioConfig - - public init(id: UInt32, parentId: UInt32, name: String, topic: String, - passwordProtected: Bool, password: String?, maxUsers: UInt32, - sortOrder: UInt32, audio: AudioConfig) { - self.id = id; self.parentId = parentId; self.name = name; self.topic = topic - self.passwordProtected = passwordProtected; self.password = password - self.maxUsers = maxUsers; self.sortOrder = sortOrder; self.audio = audio - } -} - -/// User snapshot — mirrors `vc_user`. -public struct User: Sendable, Equatable, Identifiable { - public let id: UInt32 - public let nickname: String - public let isGuest: Bool - public let channelId: UInt32 - public let selfMicMuted: Bool - public let selfDeafened: Bool - public let serverMuted: Bool - public let serverDeafened: Bool - public let voiceSubscribed: Bool - - public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32, - selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool, - serverDeafened: Bool, voiceSubscribed: Bool) { - self.id = id; self.nickname = nickname; self.isGuest = isGuest; self.channelId = channelId - self.selfMicMuted = selfMicMuted; self.selfDeafened = selfDeafened - self.serverMuted = serverMuted; self.serverDeafened = serverDeafened - self.voiceSubscribed = voiceSubscribed - } -} - -/// Permission bitset — mirrors `vc_permissions`. -public struct Permissions: Sendable, Equatable { - public let canCreateTempChannel: Bool - public let canKick: Bool - public let canBan: Bool - public let canMoveUsers: Bool - public let canAdminAccounts: Bool - public let isAdmin: Bool - - public init(canCreateTempChannel: Bool, canKick: Bool, canBan: Bool, - canMoveUsers: Bool, canAdminAccounts: Bool, isAdmin: Bool) { - self.canCreateTempChannel = canCreateTempChannel; self.canKick = canKick; self.canBan = canBan - self.canMoveUsers = canMoveUsers; self.canAdminAccounts = canAdminAccounts; self.isAdmin = isAdmin - } -} - -/// Account entry — mirrors `vc_account` (reply to `listAccounts()`). -public struct Account: Sendable, Equatable { - public let username: String - public let isAdmin: Bool - public let createdAtUnixMs: UInt64 - public let lastLoginUnixMs: UInt64 - - public init(username: String, isAdmin: Bool, createdAtUnixMs: UInt64, - lastLoginUnixMs: UInt64) { - self.username = username; self.isAdmin = isAdmin - self.createdAtUnixMs = createdAtUnixMs; self.lastLoginUnixMs = lastLoginUnixMs - } -} - -/// Per-user stream summary — mirrors `vc_stream_summary`. For the full effective Opus -/// config of a specific (user_id, stream_id), use `VoiceCatClient.getStreamAudioConfig`. -public struct StreamSummary: Sendable, Equatable, Identifiable { - public let id: UInt32 // stream_id - public let kind: VoiceCatStreamKind - public let label: String - - public init(streamId: UInt32, kind: VoiceCatStreamKind, label: String) { - self.id = streamId; self.kind = kind; self.label = label - } -} - -/// Receive-side state the local listener chose for a specific remote stream — mirrors -/// `vc_remote_stream_state`. All LOCAL (no protocol traffic) — docs/voice.md §10. -/// Defaults (if `setRemoteStream` was never called): gain 1.0, unmuted, NR off. -public struct RemoteStreamState: Sendable, Equatable { - public let gain: Float // 0.0–… ; default 1.0 - public let muted: Bool - public let noiseReduction: Bool - - public init(gain: Float, muted: Bool, noiseReduction: Bool) { - self.gain = gain; self.muted = muted; self.noiseReduction = noiseReduction - } -} - -/// Audio device — mirrors `vc_device`. `id` is an opaque, internally-encoded handle -/// (currently hex-encoded `ma_device_id`) — always round-trip an id from `listDevices`; -/// never construct one by hand (docs/architecture.md §4). -public struct Device: Sendable, Equatable, Identifiable { - public let id: String - public let name: String - public let isDefault: Bool - - public init(id: String, name: String, isDefault: Bool) { - self.id = id; self.name = name; self.isDefault = isDefault - } -} - -/// iOS audio input port — derived from `AVAudioSession.availableInputs`. Unlike the -/// miniaudio-based `Device` (which returns ~2 entries on iOS), this exposes the real -/// AVAudioSession input ports (builtInMic, bluetoothHFP, headsetMic, usbAudio, airPlay) -/// with their data sources (orientation: front/back/top/bottom) and polar patterns -/// (omni/cardioid/subcardioid/bidirectional). Used by `IOSAudioRouter` + `SettingsView`. -public struct IOSAudioInputPort: Identifiable, Hashable { - public let id: String // port UID (stable across route changes) - public let name: String // human-readable port name - public let portType: String // AVAudioSession.Port raw value as string - public let dataSources: [IOSAudioDataSource]? - public let isSelected: Bool // true if this is the current preferredInput - - public init(id: String, name: String, portType: String, - dataSources: [IOSAudioDataSource]?, isSelected: Bool) { - self.id = id; self.name = name; self.portType = portType - self.dataSources = dataSources; self.isSelected = isSelected - } -} - -/// iOS audio data source — a sub-selection of an input port (e.g. built-in mic -/// orientation: front/back/top/bottom). May have polar pattern options. -public struct IOSAudioDataSource: Identifiable, Hashable { - public let id: String // dataSource UID - public let name: String // "Front", "Back", "Top", "Bottom" - public let polarPatterns: [String]? // AVAudioSession.PolarPattern raw values - public let isSelected: Bool // true if this is the current preferredDataSource - public let selectedPolarPattern: String? - - public init(id: String, name: String, polarPatterns: [String]?, - isSelected: Bool, selectedPolarPattern: String?) { - self.id = id; self.name = name; self.polarPatterns = polarPatterns - self.isSelected = isSelected; self.selectedPolarPattern = selectedPolarPattern - } -} - -/// iOS audio output route — read-only display of `AVAudioSession.currentRoute.outputs`. -public struct IOSAudioOutputRoute: Identifiable, Hashable { - public let id: String // port UID - public let name: String // human-readable route name - public let portType: String // AVAudioSession.Port raw value as string - - public init(id: String, name: String, portType: String) { - self.id = id; self.name = name; self.portType = portType - } -} - -/// Effective Opus configuration — mirrors `vc_audio_config`. -public struct AudioConfig: Sendable, Equatable { - public let codec: UInt32 // 0 = OPUS - public let stereo: Bool // mode: 0 = mono, 1 = stereo - public let sampleRate: UInt32 - public let bitrateBps: UInt32 - public let frameMs: UInt32 - public let application: UInt32 // 0 = VOIP, 1 = AUDIO, 2 = LOWDELAY - public let fec: Bool - public let expectedPacketLoss: UInt32 // % 0..100 - public let dtx: Bool - public let complexity: UInt32 // 0..10 - public let dred: Bool // Deep REDundancy (Opus 1.6), off by default - - public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000, - bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0, - fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false, - complexity: UInt32 = 10, dred: Bool = false) { - self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate - self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application - self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx - self.complexity = complexity; self.dred = dred - } -} - -/// Stream descriptor — mirrors `vc_stream_desc`. Used by `startStream(kind:deviceId:label:)`. -public struct StreamDescriptor: Sendable, Equatable { - public let kind: VoiceCatStreamKind - /// nil = default device for this kind. - public let deviceId: String? - public let label: String - /// When true the caller feeds PCM via `feedPcm` (e.g. the iOS VPIO mic path) and the core - /// skips opening a hardware capture device for this stream. Mirrors `vc_stream_desc.external_feed`. - public let externalFeed: Bool - - public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String, - externalFeed: Bool = false) { - self.kind = kind; self.deviceId = deviceId; self.label = label - self.externalFeed = externalFeed - } -} - -/// Server identity info — parsed from a `.serverIdentity` event + `getServerIdentityDisplay()`. -/// The `tlsCertFingerprint` (SHA-256 hex of the TLS leaf cert) is the value the TOFU gate -/// actually pins on; `ed25519Fingerprint` is display-only (docs/security.md §1.1). -public struct ServerIdentity: Sendable, Equatable { - public let tofuStatus: VoiceCatTofuStatus - /// SHA-256 hex of the TLS leaf certificate — the pinned value. No separators (64 chars). - public let tlsCertFingerprint: String - /// Ed25519 identity fingerprint from ServerHello, hex-formatted — display only. - /// Empty if not yet available. - public let ed25519Fingerprint: String - - public init(tofuStatus: VoiceCatTofuStatus, tlsCertFingerprint: String, - ed25519Fingerprint: String) { - self.tofuStatus = tofuStatus; self.tlsCertFingerprint = tlsCertFingerprint - self.ed25519Fingerprint = ed25519Fingerprint - } -} diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav b/clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav deleted file mode 100644 index 01e4a82..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav b/clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav deleted file mode 100644 index 18f921f..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav b/clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav deleted file mode 100644 index be9eb28..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav b/clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav deleted file mode 100644 index 4c23cc7..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav b/clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav deleted file mode 100644 index 1748fc4..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/login.wav b/clients/apple/Sources/VoiceCatCore/Sounds/login.wav deleted file mode 100644 index f2f45d9..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/login.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/logout.wav b/clients/apple/Sources/VoiceCatCore/Sounds/logout.wav deleted file mode 100644 index 4e49272..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/logout.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav b/clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav deleted file mode 100644 index 7a52d19..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav b/clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav deleted file mode 100644 index 615d884..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav b/clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav deleted file mode 100644 index 1cf4d0d..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav b/clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav deleted file mode 100644 index b64872a..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav b/clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav deleted file mode 100644 index 76481cd..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav b/clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav deleted file mode 100644 index a2063bd..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav b/clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav deleted file mode 100644 index 87fb04d..0000000 Binary files a/clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav and /dev/null differ diff --git a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift deleted file mode 100644 index d286e58..0000000 --- a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift +++ /dev/null @@ -1,594 +0,0 @@ -// Swift binding invariants: native config strings outlive the handle, destroy joins callback -// threads before deallocation, and callback payloads are copied before main-queue delivery. -// See docs/architecture.md §4 for the complete binding contract. - -import VoiceCatC -import Foundation - -/// Swift-idiomatic alias for the C `vc_pcm_sink_cb` function-pointer type from -/// `voicecat.h`. Exposed publicly so consumers (`VoiceCatMac`, tests) can declare a sink -/// callback without directly importing the `VoiceCatC` C module. Mirrors the C# wrapper's -/// `VcPcmSinkCallback` delegate. -public typealias VoiceCatPcmSinkCallback = vc_pcm_sink_cb - -/// Swift-idiomatic alias for the C `vc_mixed_output_cb` function-pointer type from `voicecat.h` -/// — the external mixed-output sink used by the iOS VPIO path (see `setMixedOutputSink`). -public typealias VoiceCatMixedOutputCallback = vc_mixed_output_cb - -/// The Swift wrapper around `vc_client*`. Owns the native handle for its entire lifetime; -/// `deinit` destroys it. Events and level meters are delivered on the main queue via the -/// `onEvent` / `onLevel` closures. -/// -/// Thread-safety: the public methods are not thread-safe — call them from the main thread -/// (the standard AppKit/SwiftUI pattern). The internal event/level buffers are thread-safe -/// (lock-protected) because they're written from the core's event thread. -public final class VoiceCatClient { - - // MARK: - Stored properties - - private var handle: OpaquePointer? - - /// Unretained callback context; destroying the handle joins callback threads first. - private var selfPointer: UnsafeMutableRawPointer { - Unmanaged.passUnretained(self).toOpaque() - } - - /// The core retains these pointers for the handle's lifetime. - private var clientNamePtr: UnsafeMutablePointer? - private var clientVersionPtr: UnsafeMutablePointer? - private var tofuStorePathPtr: UnsafeMutablePointer? - - // MARK: - Event / level delivery (main-queue) - - /// Called on the main queue for every event, in order, never coalesced. Set this from - /// the main thread (AppKit/SwiftUI) to drive your UI. - public var onEvent: ((VoiceCatEvent) -> Void)? - - /// Called on the main queue with the latest RMS level per stream_id since the last drain. - /// Intermediate values are coalesced (only the latest per stream_id is delivered). - public var onLevel: ((UInt32, Float) -> Void)? - - private let bufferLock = NSLock() - private var eventBuffer: [VoiceCatEvent] = [] - private var levelSamples: [UInt32: Float] = [:] - private var drainScheduled = false - - // MARK: - Init / deinit - - public init(config: VoiceCatConfig) { - self.clientNamePtr = strdup(config.clientName) - self.clientVersionPtr = strdup(config.clientVersion) - self.tofuStorePathPtr = config.tofuStorePath.flatMap { strdup($0) } - self.handle = nil // placeholder — set below after callbacks are wired - - var nativeConfig = vc_config() - nativeConfig.client_name = UnsafePointer(clientNamePtr) - nativeConfig.client_version = UnsafePointer(clientVersionPtr) - nativeConfig.log_level = config.logLevel.cValue - nativeConfig.tofu_store_path = UnsafePointer(tofuStorePathPtr) - - let callbacks = Callbacks.make(user: selfPointer) - self.handle = vc_client_create(&nativeConfig, callbacks) - - if handle == nil { - free(clientNamePtr); clientNamePtr = nil - free(clientVersionPtr); clientVersionPtr = nil - if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil } - fatalError("vc_client_create returned nil") - } - } - - deinit { - if let handle { - // Joins every internal thread synchronously — no callbacks can fire after this - // returns, so the selfPointer and config-string pointers are safe to free. - vc_client_destroy(handle) - self.handle = nil - } - // Free config strings AFTER destroy (the core may have been reading them up until - // destroy joined the io thread). - free(clientNamePtr); clientNamePtr = nil - free(clientVersionPtr); clientVersionPtr = nil - if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil } - } - - // MARK: - Internal: event/level enqueue (called from the core's event thread) - - /// Called by Callbacks.onEvent on the core's event thread. Buffers the event and - /// schedules a coalesced main-queue drain. - internal func enqueueEvent(_ event: VoiceCatEvent) { - bufferLock.lock() - eventBuffer.append(event) - let shouldSchedule = !drainScheduled - drainScheduled = true - bufferLock.unlock() - if shouldSchedule { - DispatchQueue.main.async { [weak self] in self?.drain() } - } - } - - /// Called by Callbacks.onLevel on the core's event thread. Coalesces to latest-per-stream - /// and schedules a coalesced main-queue drain. - internal func enqueueLevel(_ streamId: UInt32, _ rms: Float) { - bufferLock.lock() - levelSamples[streamId] = rms - let shouldSchedule = !drainScheduled - drainScheduled = true - bufferLock.unlock() - if shouldSchedule { - DispatchQueue.main.async { [weak self] in self?.drain() } - } - } - - /// Drains buffered events + coalesced levels on the main queue. Only one drain is - /// scheduled at a time (debounced via `drainScheduled`). - private func drain() { - bufferLock.lock() - let events = eventBuffer - eventBuffer.removeAll() - let levels = levelSamples - levelSamples.removeAll() - drainScheduled = false - bufferLock.unlock() - - for event in events { onEvent?(event) } - for (streamId, rms) in levels { onLevel?(streamId, rms) } - } - - // MARK: - Lifecycle (statics) - - /// The core's version string (e.g. "VoiceCat 0.0.1 (protocol v1)"). Static literal — never freed. - public static var versionString: String { - String(cString: vc_version_string()) - } - - /// Human-readable description of a result code. Static literal — never freed. - public static func resultString(_ code: VoiceCatResult) -> String { - String(cString: vc_result_string(code.cValue)) - } - - // MARK: - Connection & auth (async; results via onEvent) - - @discardableResult - public func connect(host: String, port: UInt16) -> VoiceCatResult { - VoiceCatResult(vc_connect(handle, host, port)) - } - - @discardableResult - public func disconnect() -> VoiceCatResult { - VoiceCatResult(vc_disconnect(handle)) - } - - @discardableResult - public func authenticateGuest(_ nickname: String) -> VoiceCatResult { - VoiceCatResult(vc_authenticate_guest(handle, nickname)) - } - - @discardableResult - public func authenticateUser(_ username: String, password: String) -> VoiceCatResult { - VoiceCatResult(vc_authenticate_user(handle, username, password)) - } - - // MARK: - TOFU server-identity gate - - /// Accept or reject the pending server-identity check. Call after a `.serverIdentity` - /// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds; - /// `accept=false` aborts (emits `.disconnected` with `.crypto`). See docs/security.md §1.1. - @discardableResult - public func confirmServerIdentity(accept: Bool) -> VoiceCatResult { - VoiceCatResult(vc_confirm_server_identity(handle, accept ? 1 : 0)) - } - - /// The Ed25519 identity fingerprint from ServerHello, hex-formatted — DISPLAY ONLY, not - /// the value the TOFU gate pins on (see docs/security.md §1.1). Empty if not yet available. - /// Uses the two-call idiom: query size with nil buffer, then allocate + fetch. - public func getServerIdentityDisplay() -> String { - var len: Int = 0 - _ = vc_get_server_identity_display(handle, nil, 0, &len) - if len == 0 { return "" } - let buf = UnsafeMutablePointer.allocate(capacity: len + 1) - defer { buf.deallocate() } - _ = vc_get_server_identity_display(handle, buf, len + 1, &len) - return String(cString: buf) - } - - // MARK: - Channels - - /// Join a channel. Result arrives as a `.joinResult` event (not via the return value, - /// which only reflects "request queued"). `password` is for password-protected channels. - @discardableResult - public func joinChannel(_ channelId: UInt32, password: String? = nil) -> VoiceCatResult { - VoiceCatResult(vc_join_channel(handle, channelId, password)) - } - - @discardableResult - public func leaveChannel() -> VoiceCatResult { - VoiceCatResult(vc_leave_channel(handle)) - } - - @discardableResult - public func joinVoice() -> VoiceCatResult { - VoiceCatResult(vc_join_voice(handle)) - } - - @discardableResult - public func leaveVoice() -> VoiceCatResult { - VoiceCatResult(vc_leave_voice(handle)) - } - - /// Pull the current channel tree. Re-call after `.channelList`/`.userJoined`/`.userLeft`/ - /// `.userUpdated` events. The native list is freed inside this call — callers never - /// manage native lifetime. - public func listChannels() -> [Channel] { - var native = vc_channel_list() - _ = vc_list_channels(handle, &native) - return Marshaling.channels(&native) - } - - public func listUsers() -> [User] { - var native = vc_user_list() - _ = vc_list_users(handle, &native) - return Marshaling.users(&native) - } - - public func listUserStreams(_ userId: UInt32) -> [StreamSummary] { - var native = vc_stream_summary_list() - let r = vc_list_user_streams(handle, userId, &native) - guard r == VC_OK else { return [] } - return Marshaling.streamSummaries(&native) - } - - // MARK: - Local media streams - - /// Start a mic / screen-audio / aux stream. Returns `(result, streamId)` — `streamId` - /// is non-zero on success. The `label` and `deviceId` C strings are only needed for the - /// duration of the call (the core copies what it needs), so we use temporary strdup'd - /// buffers freed via `defer`. - @discardableResult - public func startStream(_ descriptor: StreamDescriptor) -> (VoiceCatResult, UInt32) { - var streamId: UInt32 = 0 - let labelPtr = strdup(descriptor.label) - defer { free(labelPtr) } - let deviceIdPtr = descriptor.deviceId.flatMap { strdup($0) } - defer { if let deviceIdPtr { free(deviceIdPtr) } } - - var desc = vc_stream_desc() - desc.kind = descriptor.kind.cValue - desc.device_id = deviceIdPtr.map { UnsafePointer($0) } - desc.label = UnsafePointer(labelPtr) - desc.external_feed = descriptor.externalFeed ? 1 : 0 - - let r = vc_stream_start(handle, &desc, &streamId) - return (VoiceCatResult(r), streamId) - } - - @discardableResult - public func stopStream(_ streamId: UInt32) -> VoiceCatResult { - VoiceCatResult(vc_stream_stop(handle, streamId)) - } - - @discardableResult - public func setInputDevice(streamId: UInt32, deviceId: String?) -> VoiceCatResult { - VoiceCatResult(vc_set_input_device(handle, streamId, deviceId)) - } - - /// Set the capture channel count for a local MIC stream (1 = mono, 2 = stereo interleaved). - /// Takes effect on the next AudioEngine restart (immediately if already running). Used by - /// the iOS `IOSAudioRouter` when the user picks stereo built-in mic capture. - @discardableResult - public func setCaptureChannels(streamId: UInt32, channels: UInt32) -> VoiceCatResult { - VoiceCatResult(vc_set_capture_channels(handle, streamId, channels)) - } - - // MARK: - External PCM feed / tap - - /// External PCM feed — drives a local stream's encode pipeline with caller-supplied PCM - /// instead of (or in addition to) a hardware capture device. Intended for ReplayKit - /// Broadcast Extension (iOS), ScreenCaptureKit (macOS), bots, and soundboard use cases. - /// - /// - Parameters: - /// - streamId: The stream returned by `startStream`. Must be active. - /// - pcm: Raw int16 PCM pointer. Caller must keep the buffer alive for the duration of the call. - /// - samplesPerChannel: Samples per channel (e.g. 960 for 20 ms @ 48 kHz). - /// - channels: 1 (mono) or 2 (stereo interleaved L/R). - @discardableResult - public func feedPcm(streamId: UInt32, pcm: UnsafePointer, - samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult { - VoiceCatResult(vc_stream_feed_pcm(handle, streamId, pcm, - samplesPerChannel, channels)) - } - - /// Convenience overload for feeding from a Swift `[Int16]` array. - @discardableResult - public func feedPcm(streamId: UInt32, pcm: [Int16], - samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult { - pcm.withUnsafeBufferPointer { - feedPcm(streamId: streamId, pcm: $0.baseAddress!, - samplesPerChannel: samplesPerChannel, channels: channels) - } - } - - /// External PCM tap — receive decoded per-stream audio as raw int16 PCM before it - /// reaches the hardware mix. Fires once per decoded Opus frame per remote stream. - /// - /// The callback is a C function pointer (`@convention(c)`) receiving: - /// `(user, userId, streamId, pcm, samplesPerChannel, channels, sampleRate)` - /// - /// Pass `nil` to disable (default). The callback MUST NOT block or allocate. - @discardableResult - public func setPcmSink(_ cb: VoiceCatPcmSinkCallback?, user: UnsafeMutableRawPointer?) -> VoiceCatResult { - VoiceCatResult(vc_set_pcm_sink(handle, cb, user)) - } - - /// External mixed-output sink (iOS VPIO) — receives the FINAL mixed remote audio as int16 - /// PCM on the core's mixer-timer thread when external playback is enabled. The Swift VPIO - /// renderer copies this into its ring and plays it through the voice-processing output so - /// echo cancellation has its reference signal. Pass `nil` to disable. Mirrors - /// `vc_set_mixed_output_sink`. The callback MUST NOT block or allocate. - @discardableResult - public func setMixedOutputSink(_ cb: VoiceCatMixedOutputCallback?, - user: UnsafeMutableRawPointer?) -> VoiceCatResult { - VoiceCatResult(vc_set_mixed_output_sink(handle, cb, user)) - } - - /// Enable/disable external-playback mode (iOS VPIO). When enabled, the core opens NO hardware - /// playback device; it drives decode+mix on a timer and delivers the final mix via - /// `setMixedOutputSink`. Apply before the engine starts, or follow with `audioRestart()` to - /// apply to a running engine. Mirrors `vc_set_external_playback`. - @discardableResult - public func setExternalPlayback(_ enabled: Bool) -> VoiceCatResult { - VoiceCatResult(vc_set_external_playback(handle, enabled ? 1 : 0)) - } - - @discardableResult - public func setInputMode(_ mode: VoiceCatInputMode) -> VoiceCatResult { - VoiceCatResult(vc_set_input_mode(handle, mode.cValue)) - } - - /// VAD threshold: normalized RMS 0.0–1.0 (default ~0.025). Takes effect immediately. - @discardableResult - public func setVadThreshold(_ threshold: Float) -> VoiceCatResult { - VoiceCatResult(vc_set_vad_threshold(handle, threshold)) - } - - @discardableResult - public func setPushToTalk(_ active: Bool) -> VoiceCatResult { - VoiceCatResult(vc_set_push_to_talk(handle, active ? 1 : 0)) - } - - @discardableResult - public func setSelfMute(micMuted: Bool, deafened: Bool) -> VoiceCatResult { - VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0)) - } - - /// Global playback volume applied after mixing all remote streams. gain 0.0 = silent, - /// 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. Mirrors the - /// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume`. - @discardableResult - public func setOutputVolume(_ gain: Float) -> VoiceCatResult { - VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain)) - } - - /// Send-side microphone input gain. Applied to captured MIC PCM before the VAD/PTT gate and - /// Opus encode (so boosting a quiet mic also helps it cross the VAD threshold). gain 0.0 = - /// silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). Always LOCAL. - @discardableResult - public func setInputGain(_ gain: Float) -> VoiceCatResult { - VoiceCatResult(vc_set_input_gain(handle, gain < 0 ? 0 : gain)) - } - - /// Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before the - /// input gain and VAD/PTT gate, so everyone hears the cleaned signal (one pass for all - /// listeners). MIC stream only, mono only; always LOCAL — no protocol traffic. Independent - /// of the per-listener receive-side NR in `setRemoteStream` (docs/voice.md §10). - @discardableResult - public func setInputNoiseReduction(_ enable: Bool) -> VoiceCatResult { - VoiceCatResult(vc_set_input_noise_reduction(handle, enable ? 1 : 0)) - } - - // MARK: - AVAudioSession interruption hooks (iOS) - - /// Pause miniaudio device I/O. Call when AVAudioSession interruption begins. - @discardableResult - public func audioSuspend() -> VoiceCatResult { - VoiceCatResult(vc_audio_suspend(handle)) - } - - /// Resume miniaudio device I/O. Call after re-activating AVAudioSession. - @discardableResult - public func audioResume() -> VoiceCatResult { - VoiceCatResult(vc_audio_resume(handle)) - } - - /// Full audio engine restart — uninitialize and re-initialize the capture and playback - /// devices so they pick up a new AVAudioSession route. Call this AFTER reconfiguring - /// AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.) so the - /// core's devices reopen against the new route. Unlike `audioSuspend()`/`audioResume()` - /// (which only stop/start the existing devices, leaving them bound to the route that was - /// active when they were opened), this fully re-initializes them. Safe to call when the - /// engine is not running (it will just start it). - @discardableResult - public func audioRestart() -> VoiceCatResult { - VoiceCatResult(vc_audio_restart(handle)) - } - - // MARK: - Receive-side, per remote stream (LOCAL — no protocol traffic; docs/voice.md §10) - - @discardableResult - public func setRemoteStream(userId: UInt32, streamId: UInt32, gain: Float, - muted: Bool, noiseReduction: Bool) -> VoiceCatResult { - VoiceCatResult(vc_set_remote_stream(handle, userId, streamId, gain, - muted ? 1 : 0, noiseReduction ? 1 : 0)) - } - - public func getRemoteStream(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, RemoteStreamState?) { - var state = vc_remote_stream_state() - let r = vc_get_remote_stream(handle, userId, streamId, &state) - guard r == VC_OK else { return (VoiceCatResult(r), nil) } - return (VoiceCatResult(r), Marshaling.remoteStreamState(state)) - } - - public func getStreamAudioConfig(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, AudioConfig?) { - var cfg = vc_audio_config() - let r = vc_get_stream_audio_config(handle, userId, streamId, &cfg) - guard r == VC_OK else { return (VoiceCatResult(r), nil) } - return (VoiceCatResult(r), Marshaling.audioConfig(cfg)) - } - - // MARK: - Text - - @discardableResult - public func sendText(scope: VoiceCatTextScope, targetId: UInt32, text: String) -> VoiceCatResult { - VoiceCatResult(vc_send_text(handle, scope.cValue, targetId, text)) - } - - // MARK: - Device enumeration (works pre-connect) - - public func listDevices(_ kind: VoiceCatDeviceKind) -> [Device] { - var native = vc_device_list() - _ = vc_list_devices(handle, kind.cValue, &native) - return Marshaling.devices(&native) - } - - // MARK: - Moderation - - @discardableResult - public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult { - VoiceCatResult(vc_kick_user(handle, userId, reason)) - } - - @discardableResult - public func banUser(_ userId: UInt32, reason: String? = nil, - expiresUnixMs: UInt64 = 0) -> VoiceCatResult { - VoiceCatResult(vc_ban_user(handle, userId, reason, expiresUnixMs)) - } - - @discardableResult - public func setPermission(_ userId: UInt32, perms: Permissions) -> VoiceCatResult { - var native = vc_permissions() - native.can_create_temp_channel = perms.canCreateTempChannel ? 1 : 0 - native.can_kick = perms.canKick ? 1 : 0 - native.can_ban = perms.canBan ? 1 : 0 - native.can_move_users = perms.canMoveUsers ? 1 : 0 - native.can_admin_accounts = perms.canAdminAccounts ? 1 : 0 - native.is_admin = perms.isAdmin ? 1 : 0 - return VoiceCatResult(vc_set_permission(handle, userId, &native)) - } - - @discardableResult - public func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) -> VoiceCatResult { - VoiceCatResult(vc_set_server_mute(handle, userId, muted ? 1 : 0, deafened ? 1 : 0)) - } - - @discardableResult - public func moveUser(_ userId: UInt32, toChannel channelId: UInt32) -> VoiceCatResult { - VoiceCatResult(vc_move_user(handle, userId, channelId)) - } - - // MARK: - Channel admin - - @discardableResult - public func createChannel(_ info: ChannelEdit) -> VoiceCatResult { - var native = vc_channel_info() - Self.populateChannelInfo(&native, from: info) - defer { Self.freeChannelInfoStrings(&native) } - return VoiceCatResult(vc_create_channel(handle, &native)) - } - - @discardableResult - public func editChannel(_ info: ChannelEdit) -> VoiceCatResult { - var native = vc_channel_info() - Self.populateChannelInfo(&native, from: info) - defer { Self.freeChannelInfoStrings(&native) } - return VoiceCatResult(vc_edit_channel(handle, &native)) - } - - @discardableResult - public func deleteChannel(_ channelId: UInt32) -> VoiceCatResult { - VoiceCatResult(vc_delete_channel(handle, channelId)) - } - - // MARK: - Account admin - - @discardableResult - public func createAccount(_ username: String, password: String) -> VoiceCatResult { - VoiceCatResult(vc_create_account(handle, username, password)) - } - - @discardableResult - public func resetPassword(_ username: String, newPassword: String) -> VoiceCatResult { - VoiceCatResult(vc_reset_password(handle, username, newPassword)) - } - - @discardableResult - public func deleteAccount(_ username: String) -> VoiceCatResult { - VoiceCatResult(vc_delete_account(handle, username)) - } - - /// Request the account list — result arrives as a `.accountList` event, then call - /// `listAccounts()` to pull the cached list. - @discardableResult - public func requestAccountList() -> VoiceCatResult { - VoiceCatResult(vc_list_accounts(handle)) - } - - public func listAccounts() -> [Account] { - var native = vc_account_list() - _ = vc_get_account_list(handle, &native) - return Marshaling.accounts(&native) - } - - public func getPermissions() -> Permissions { - var native = vc_permissions() - _ = vc_get_permissions(handle, &native) - return Marshaling.permissions(native) - } -} - -// MARK: - Helpers for vc_channel_info / vc_audio_config construction - -extension VoiceCatClient { - /// Populate a `vc_channel_info` from a Swift `ChannelEdit`. The string fields - /// (`name`/`topic`/`password`) are strdup'd — the caller MUST call - /// `freeChannelInfoStrings(_:)` after the C call returns (the core copies what it needs - /// during the call, so the temporary buffers can be freed via `defer`). - internal static func populateChannelInfo(_ native: inout vc_channel_info, from info: ChannelEdit) { - native.id = info.id - native.parent_id = info.parentId - native.name = UnsafePointer(strdup(info.name)) - native.topic = UnsafePointer(strdup(info.topic)) - native.password_protected = info.passwordProtected ? 1 : 0 - native.password = (info.passwordProtected && !(info.password?.isEmpty ?? true)) - ? UnsafePointer(strdup(info.password!)) : nil - native.max_users = info.maxUsers - native.sort_order = info.sortOrder - native.audio = info.audio.toNative() - } - - /// Free the strdup'd string fields of a `vc_channel_info` populated by - /// `populateChannelInfo`. Call this in a `defer` after the C call. - internal static func freeChannelInfoStrings(_ native: inout vc_channel_info) { - if let p = native.name { free(UnsafeMutablePointer(mutating: p)); native.name = nil } - if let p = native.topic { free(UnsafeMutablePointer(mutating: p)); native.topic = nil } - if let p = native.password { free(UnsafeMutablePointer(mutating: p)); native.password = nil } - } -} - -extension AudioConfig { - /// Convert to a native `vc_audio_config`. - internal func toNative() -> vc_audio_config { - var n = vc_audio_config() - n.codec = codec - n.mode = stereo ? 1 : 0 - n.sample_rate = sampleRate - n.bitrate_bps = bitrateBps - n.frame_ms = frameMs - n.application = application - n.fec = fec ? 1 : 0 - n.expected_packet_loss = expectedPacketLoss - n.dtx = dtx ? 1 : 0 - n.complexity = complexity - n.dred = dred ? 1 : 0 - return n - } -} diff --git a/clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift b/clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift deleted file mode 100644 index 760c208..0000000 --- a/clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -// ExternalPcmTests — Swift wrapper smoke tests for vc_stream_feed_pcm / vc_set_pcm_sink. -// -// These tests verify that the Swift API surface compiles, is callable, and returns expected -// results at the C-ABI boundary — without requiring a live server or audio hardware. -// Full end-to-end relay / decode verification is covered by tests/test_external_pcm.cpp -// (C++ ctest), which runs headlessly on all platforms. - -import XCTest -@testable import VoiceCatCore - -final class ExternalPcmTests: XCTestCase { - - // MARK: - feedPcm: API surface smoke - - /// Calling feedPcm without a connected client or active stream must return .invalidArg - /// (not crash). Proves the Swift→C bridge compiles and handles the error path. - func testFeedPcm_noActiveStream_returnsInvalidArg() { - let client = VoiceCatClient(config: VoiceCatConfig( - clientName: "ext-pcm-test", - clientVersion: "0.1", - logLevel: .off - )) - let sine = [Int16](repeating: 0, count: 960) - // Stream 0 doesn't exist — the core must return invalidArg, not crash. - let result = client.feedPcm(streamId: 0, pcm: sine, samplesPerChannel: 960, channels: 1) - XCTAssertEqual(result, .invalidArg) - } - - /// Calling feedPcm with channels=3 (invalid) must return .invalidArg. - func testFeedPcm_invalidChannels_returnsInvalidArg() { - let client = VoiceCatClient(config: VoiceCatConfig( - clientName: "ext-pcm-test", - clientVersion: "0.1", - logLevel: .off - )) - let pcm = [Int16](repeating: 0, count: 960 * 3) - let result = client.feedPcm(streamId: 0, pcm: pcm, samplesPerChannel: 960, channels: 3) - XCTAssertEqual(result, .invalidArg) - } - - // MARK: - setPcmSink: API surface smoke - - /// setPcmSink(nil) on a freshly-created client must succeed (nil = disable, which is the - /// default state — a no-op that must still return .ok). - func testSetPcmSink_nil_returnsOk() { - let client = VoiceCatClient(config: VoiceCatConfig( - clientName: "ext-pcm-test", - clientVersion: "0.1", - logLevel: .off - )) - let result = client.setPcmSink(nil, user: nil) - XCTAssertEqual(result, .ok) - } - - /// Calling setPcmSink with a @convention(c) function and then immediately disabling it - /// with nil must both succeed. Verifies the C-ABI function-pointer round-trip. - func testSetPcmSink_enableThenDisable_bothSucceed() { - let client = VoiceCatClient(config: VoiceCatConfig( - clientName: "ext-pcm-test", - clientVersion: "0.1", - logLevel: .off - )) - - let mySink: VoiceCatPcmSinkCallback = { _, _, _, _, _, _, _ in } - XCTAssertEqual(client.setPcmSink(mySink, user: nil), .ok) - XCTAssertEqual(client.setPcmSink(nil, user: nil), .ok) - } -} diff --git a/clients/apple/Tests/VoiceCatCoreTests/VoiceCatClientSmokeTests.swift b/clients/apple/Tests/VoiceCatCoreTests/VoiceCatClientSmokeTests.swift deleted file mode 100644 index 5a67530..0000000 --- a/clients/apple/Tests/VoiceCatCoreTests/VoiceCatClientSmokeTests.swift +++ /dev/null @@ -1,505 +0,0 @@ -// VoiceCatClientSmokeTests — exercises the full connect → TOFU → auth → channels → -// moderation flow purely through the Swift wrapper layer (VoiceCatClient), against a real -// `voicecat-server` (the same binary the C++ ctest suite uses, built by `cmake --preset dev`). -// This is the Swift analog of clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. -// -// Why this exists (same rationale as the C# tests): the C++ ctest suite proves the protocol -// works at the C++ level, but Swift-specific interop bugs — @convention(c) callback lifetime, -// Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct -// field layout — can only be caught by exercising the exact Swift→C boundary. These tests -// catch the same class of bugs the C# P/Invoke tests catch, for Swift. -// -// Prerequisites: `cmake --preset dev && cmake --build --preset dev` (builds voicecat-server -// and voicecat-admin into build/dev/bin/), AND `scripts/build-xcframework.sh` (builds the -// VoiceCatCore.xcframework that the Swift Package links). - -import XCTest -import Foundation -@testable import VoiceCatCore - -/// Manages a real `voicecat-server` process for the test suite's lifetime. Starts the server -/// on an ephemeral port (--port 0), parses the bound port from stdout, and provisions a known -/// admin account via `voicecat-admin`. Killed + cleaned up in deinit. -private final class ServerHarness { - let port: UInt16 - private let process: Process - let tempDir: String - - init() throws { - let repoRoot = Self.findRepoRoot() - let serverURL = URL(fileURLWithPath: repoRoot) - .appendingPathComponent("build/dev/bin/voicecat-server") - - guard FileManager.default.isExecutableFile(atPath: serverURL.path) else { - throw NSError(domain: "VoiceCatTest", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "voicecat-server not found at \(serverURL.path) — " - + "build the dev preset first: cmake --preset dev && cmake --build --preset dev", - ]) - } - - let tempDir = NSTemporaryDirectory() + "vc_swift_smoke_" + UUID().uuidString - try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true) - self.tempDir = tempDir - - let p = Process() - p.executableURL = serverURL - p.arguments = ["--port", "0", "--data-dir", tempDir, "--name", "SwiftSmokeTest"] - - // Pipe stdout to read the bound port; stderr to /dev/null. - let stdoutPipe = Pipe() - p.standardOutput = stdoutPipe - p.standardError = FileHandle(forWritingAtPath: "/dev/null") - try p.run() - self.process = p - - // Parse "[voicecat-server] ... — TCP : UDP :" from stdout. The server - // prints several lines before the port line (version, first-run admin box, etc.), so - // we keep reading until we find a line matching "TCP :". Read with a 10s timeout - // so a crashed/hung server can't hang the test forever. - guard let port = Self.readPortWithTimeout(stdoutPipe, timeout: 10) else { - p.terminate() - throw NSError(domain: "VoiceCatTest", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "voicecat-server did not report a bound TCP port within 10s", - ]) - } - self.port = port - - // Provision a known admin account for moderation/admin tests. - let adminURL = URL(fileURLWithPath: repoRoot) - .appendingPathComponent("build/dev/bin/voicecat-admin") - guard FileManager.default.isExecutableFile(atPath: adminURL.path) else { - throw NSError(domain: "VoiceCatTest", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "voicecat-admin not found at \(adminURL.path)", - ]) - } - let adminProc = Process() - adminProc.executableURL = adminURL - adminProc.arguments = ["--data-dir", tempDir, "account", "add", "admin2", - "--admin", "--password", "testpassword123"] - adminProc.standardOutput = FileHandle(forWritingAtPath: "/dev/null") - adminProc.standardError = FileHandle(forWritingAtPath: "/dev/null") - try adminProc.run() - adminProc.waitUntilExit() - guard adminProc.terminationStatus == 0 else { - throw NSError(domain: "VoiceCatTest", code: 4, userInfo: [ - NSLocalizedDescriptionKey: "voicecat-admin failed to provision admin2 (exit \(adminProc.terminationStatus))", - ]) - } - } - - deinit { - if process.isRunning { process.terminate() } - try? FileManager.default.removeItem(atPath: tempDir) - } - - private static func findRepoRoot() -> String { - var url = URL(fileURLWithPath: #file) - while url.path != "/" && !FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) { - url = url.deletingLastPathComponent() - } - guard FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) else { - fatalError("Could not find repo root (CMakePresets.json) above \(#file)") - } - return url.path - } - - /// Read from the server's stdout until a line matching "TCP :" is found, or the - /// timeout expires. The server prints several lines (version banner, first-run admin box, - /// etc.) before the port line — see server/src/server.cpp. - private static func readPortWithTimeout(_ pipe: Pipe, timeout: TimeInterval) -> UInt16? { - let handle = pipe.fileHandleForReading - let deadline = Date().addingTimeInterval(timeout) - var buffer = Data() - while Date() < deadline { - let data = handle.availableData - if !data.isEmpty { - buffer.append(data) - // Check each complete line in the buffer for "TCP :". - while let newlineIdx = buffer.firstIndex(of: 0x0A) { - let lineData = buffer.prefix(newlineIdx) - buffer = buffer.suffix(from: buffer.index(after: newlineIdx)) - if let line = String(data: lineData, encoding: .utf8), - let port = parsePort(from: line) { - return port - } - } - } - Thread.sleep(forTimeInterval: 0.05) - } - return nil - } - - private static func parsePort(from line: String) -> UInt16? { - // Match "TCP :" — see server/src/server.cpp. - guard let range = line.range(of: #"TCP :(\d+)"#, options: .regularExpression) else { return nil } - let digits = line[range].split(separator: ":").last ?? "" - return UInt16(digits.trimmingCharacters(in: .whitespaces)) - } -} - -/// XCTest smoke tests against a real voicecat-server, through the Swift VoiceCatClient wrapper. -final class VoiceCatClientSmokeTests: XCTestCase { - private static var harness: ServerHarness? - - override class func setUp() { - do { - harness = try ServerHarness() - } catch { - // Store the error so each test fails with a clear message rather than a crash. - NSLog("ServerHarness setup failed: \(error.localizedDescription)") - harness = nil - } - } - - override class func tearDown() { - harness = nil - } - - private var port: UInt16 { - guard let p = Self.harness?.port else { - XCTFail("ServerHarness not started — see setUp error in log") - return 0 - } - return p - } - - private var tempDir: String { - Self.harness?.tempDir ?? NSTemporaryDirectory() - } - - /// Helper: wait until the predicate is satisfied, running the main runloop to process - /// dispatched events. The Swift analog of the C# `PumpUntil` helper. Our events are - /// delivered via DispatchQueue.main.async, which the main runloop processes during - /// `RunLoop.current.run(until:)`. - /// - /// Uses RunLoop polling (not XCTestExpectation) so that the "assert something does NOT - /// happen within N seconds" pattern works without generating spurious "Asynchronous wait - /// failed" errors — `wait(for:timeout:)` logs an error when an expectation isn't - /// fulfilled, which is wrong for negative checks. - private func waitFor(timeout: TimeInterval = 5, _ predicate: @escaping () -> Bool) -> Bool { - if predicate() { return true } - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - // Run the main runloop for ~20ms — processes DispatchQueue.main.async blocks - // (where our events/levels are drained) and timer sources. - RunLoop.current.run(until: Date().addingTimeInterval(0.02)) - if predicate() { return true } - } - return predicate() - } - - private func requireHarness() -> Bool { - guard Self.harness != nil else { - XCTFail("ServerHarness not started — see setUp error in log") - return false - } - return true - } - - // MARK: - Tests - - func testVersionStringIsNonEmpty() { - XCTAssertFalse(VoiceCatClient.versionString.isEmpty) - } - - func testResultStringRoundTrips() { - XCTAssertFalse(VoiceCatClient.resultString(.ok).isEmpty) - XCTAssertFalse(VoiceCatClient.resultString(.permissionDenied).isEmpty) - } - - /// Full connect → TOFU → confirm → guest auth → list channels → permissions → guest - /// ListAccounts rejected. Mirrors the C# `Connect_Tofu_Auth_ListChannels_RoundTrips`. - func testConnectTofuAuthListChannelsRoundTrips() throws { - guard requireHarness() else { return } - - var events: [VoiceCatEvent] = [] - let client = VoiceCatClient(config: VoiceCatConfig( - clientName: "vc-swift-smoke", - clientVersion: "0.1", - logLevel: .off, - tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins.txt") - )) - client.onEvent = { events.append($0) } - - XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok) - XCTAssertEqual(client.authenticateGuest("SwiftSmoke"), .ok) - - // Wait for VC_EVENT_SERVER_IDENTITY. - XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } }, - "did not receive .serverIdentity") - let identityEvent = try XCTUnwrap(events.first { $0.type == .serverIdentity }) - XCTAssertEqual(identityEvent.tofuStatus, .firstConnect) - XCTAssertNotNil(identityEvent.text) - XCTAssertEqual(identityEvent.text?.count, 64, "SHA-256 hex, no separators") - - // Auth must NOT complete before identity is confirmed (800ms, like the C# test). - XCTAssertFalse(waitFor(timeout: 0.8) { events.contains { $0.type == .authResult } }, - "auth completed before identity confirmation (should be held open)") - - XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok) - - // Wait for VC_EVENT_AUTH_RESULT. - XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } }, - "did not receive .authResult after confirming identity") - let authEvent = try XCTUnwrap(events.first { $0.type == .authResult }) - XCTAssertEqual(authEvent.result, .ok) - - // Wait for VC_EVENT_CHANNEL_LIST. - XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } }, - "did not receive .channelList") - - let channels = client.listChannels() - XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" }, - "expected Lobby (channel 1) in \(channels.map { $0.name })") - - // Permissions getter round-trip. - let perms = client.getPermissions() - XCTAssertFalse(perms.isAdmin) - XCTAssertFalse(perms.canKick) - - // Guest ListAccounts is rejected by the server with a GenericResult — proves the - // moderation wrapper path works end-to-end through the Swift interop layer. - events.removeAll() - XCTAssertEqual(client.requestAccountList(), .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult } }, - "did not receive .genericResult for guest ListAccounts") - let generic = try XCTUnwrap(events.first { $0.type == .genericResult }) - XCTAssertEqual(generic.result, .permissionDenied) - - client.disconnect() - } - - /// Admin auth → channel CRUD → account CRUD. Mirrors C# `Admin_ChannelCrud_AccountCrud_RoundTrips`. - func testAdminChannelCrudAccountCrudRoundTrips() throws { - guard requireHarness() else { return } - - var events: [VoiceCatEvent] = [] - let client = VoiceCatClient(config: VoiceCatConfig( - clientName: "vc-swift-admin", - clientVersion: "0.1", - logLevel: .off, - tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_admin.txt") - )) - client.onEvent = { events.append($0) } - - XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok) - XCTAssertEqual(client.authenticateUser("admin2", password: "testpassword123"), .ok) - - XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } }) - XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } }) - XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } }) - - let perms = client.getPermissions() - XCTAssertTrue(perms.isAdmin || perms.canAdminAccounts) - - // Channel CRUD — create. - let audioConfig = AudioConfig(stereo: true, bitrateBps: 64000, frameMs: 20, - application: 1, fec: true, expectedPacketLoss: 5, complexity: 10) - XCTAssertEqual(client.createChannel(ChannelEdit( - id: 0, parentId: 0, name: "Swift Test Channel", topic: "Created by Swift smoke test", - passwordProtected: false, password: nil, maxUsers: 42, sortOrder: 0, audio: audioConfig - )), .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, - "CreateChannel did not succeed") - - var channels = client.listChannels() - let created = try XCTUnwrap(channels.first { $0.name == "Swift Test Channel" }) - XCTAssertEqual(created.topic, "Created by Swift smoke test") - XCTAssertFalse(created.passwordProtected) - - // Channel CRUD — edit. - events.removeAll() - XCTAssertEqual(client.editChannel(ChannelEdit( - id: created.id, parentId: created.parentId, name: created.name, - topic: "Updated topic", passwordProtected: false, password: nil, - maxUsers: 100, sortOrder: 0, audio: audioConfig - )), .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, - "EditChannel did not succeed") - - // Channel CRUD — delete. - events.removeAll() - XCTAssertEqual(client.deleteChannel(created.id), .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, - "DeleteChannel did not succeed") - - // Account CRUD — create. - events.removeAll() - XCTAssertEqual(client.createAccount("swift_smoke_user", password: "initialpw"), .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, - "CreateAccount did not succeed") - - // Account CRUD — list. - events.removeAll() - XCTAssertEqual(client.requestAccountList(), .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .accountList } }, - "did not receive .accountList") - let accounts = client.listAccounts() - XCTAssertTrue(accounts.contains { $0.username == "swift_smoke_user" }) - - // Account CRUD — reset password. - events.removeAll() - XCTAssertEqual(client.resetPassword("swift_smoke_user", newPassword: "newpw123"), .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, - "ResetPassword did not succeed") - - // Account CRUD — delete. - events.removeAll() - XCTAssertEqual(client.deleteAccount("swift_smoke_user"), .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, - "DeleteAccount did not succeed") - - client.disconnect() - } - - /// Screen-audio (SCREEN_AUDIO) stream start/stop through the Swift wrapper. The core's - /// macOS CoreAudio path starts the StreamAnnounce; this exercises the full - /// startStream → .streamStarted → stopStream → .streamStopped path through Swift interop. - /// Mirrors C# `ScreenAudioStream_Starts_And_Stops`. - func testScreenAudioStreamStartsAndStops() throws { - guard requireHarness() else { return } - - var events: [VoiceCatEvent] = [] - let client = VoiceCatClient(config: VoiceCatConfig( - clientName: "vc-swift-screen", - clientVersion: "0.1", - logLevel: .off, - tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_screen.txt") - )) - client.onEvent = { events.append($0) } - - XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok) - XCTAssertEqual(client.authenticateGuest("SwiftScreen"), .ok) - - XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } }) - XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } }) - XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok) - XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } }) - - // Give the async UDP binding handshake a moment to land (mirrors vccli's 500ms sleep). - Thread.sleep(forTimeInterval: 0.5) - - let (startResult, streamId) = client.startStream( - StreamDescriptor(kind: .screenAudio, label: "Desktop audio") - ) - XCTAssertEqual(startResult, .ok) - XCTAssertNotEqual(streamId, 0, "streamId should be non-zero on success") - - // The core emits .streamStarted for the local client too. - XCTAssertTrue(waitFor(timeout: 5) { - events.contains { $0.type == .streamStarted && $0.streamId == streamId } - }, "did not receive .streamStarted for screen-audio stream") - - XCTAssertEqual(client.stopStream(streamId), .ok) - XCTAssertTrue(waitFor(timeout: 5) { - events.contains { $0.type == .streamStopped && $0.streamId == streamId } - }, "did not receive .streamStopped for screen-audio stream") - - client.disconnect() - } - - /// Per-stream receive-side controls (gain/mute/NR) round-trip through Swift: two clients - /// in a channel, one publishes a MIC stream, the other setRemoteStream's it then - /// getRemoteStream's it back. Catches Swift-specific marshaling bugs (field order, - /// bool-from-int, float precision) that the C++ ctest can't. Mirrors C# - /// `PerStream_RecvControls_Round_Trip_Through_PInvoke`. - func testPerStreamRecvControlsRoundTrip() throws { - guard requireHarness() else { return } - - var eventsA: [VoiceCatEvent] = [] - var eventsB: [VoiceCatEvent] = [] - - let a = VoiceCatClient(config: VoiceCatConfig( - clientName: "vc-swift-mix-a", clientVersion: "0.1", logLevel: .off, - tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_a.txt") - )) - let b = VoiceCatClient(config: VoiceCatConfig( - clientName: "vc-swift-mix-b", clientVersion: "0.1", logLevel: .off, - tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_b.txt") - )) - a.onEvent = { eventsA.append($0) } - b.onEvent = { eventsB.append($0) } - - // Connect + auth A first, then B (staggering avoids concurrent TLS handshakes). - XCTAssertEqual(a.connect(host: "127.0.0.1", port: port), .ok) - XCTAssertEqual(a.authenticateGuest("SwiftMixA"), .ok) - XCTAssertTrue(waitFor { eventsA.contains { $0.type == .serverIdentity } }) - XCTAssertEqual(a.confirmServerIdentity(accept: true), .ok) - XCTAssertTrue(waitFor { eventsA.contains { $0.type == .authResult } }) - XCTAssertEqual(try XCTUnwrap(eventsA.first { $0.type == .authResult }).result, .ok) - XCTAssertTrue(waitFor { eventsA.contains { $0.type == .channelList } }) - - XCTAssertEqual(b.connect(host: "127.0.0.1", port: port), .ok) - XCTAssertEqual(b.authenticateGuest("SwiftMixB"), .ok) - XCTAssertTrue(waitFor { eventsB.contains { $0.type == .serverIdentity } }) - XCTAssertEqual(b.confirmServerIdentity(accept: true), .ok) - XCTAssertTrue(waitFor { eventsB.contains { $0.type == .authResult } }) - XCTAssertEqual(try XCTUnwrap(eventsB.first { $0.type == .authResult }).result, .ok) - XCTAssertTrue(waitFor { eventsB.contains { $0.type == .channelList } }) - - // Both join Lobby (channel 1) so voice relays between them. - XCTAssertEqual(a.joinChannel(1), .ok) - XCTAssertTrue(waitFor { eventsA.contains { $0.type == .joinResult } }, - "A did not receive .joinResult") - XCTAssertEqual(b.joinChannel(1), .ok) - XCTAssertTrue(waitFor { eventsB.contains { $0.type == .joinResult } }, - "B did not receive .joinResult") - - // UDP binding handshake is async; give it a moment. - Thread.sleep(forTimeInterval: 0.5) - - // A publishes a MIC stream. - let (startResult, streamId) = a.startStream(StreamDescriptor(kind: .mic, label: "mix-test-mic")) - XCTAssertEqual(startResult, .ok) - XCTAssertNotEqual(streamId, 0) - - // B sees A's stream. - XCTAssertTrue(waitFor(timeout: 5) { - eventsB.contains { $0.type == .streamStarted && $0.streamId == streamId } - }, "B did not see A's .streamStarted") - - // Resolve A's user id from B's user list. - var aUid: UInt32 = 0 - XCTAssertTrue(waitFor(timeout: 3) { - aUid = b.listUsers().first { $0.nickname == "SwiftMixA" }?.id ?? 0 - return aUid != 0 - }, "could not resolve A's user id on B") - XCTAssertNotEqual(aUid, 0) - - // B can enumerate A's stream. - XCTAssertTrue(waitFor(timeout: 3) { - b.listUserStreams(aUid).contains { $0.id == streamId } - }, "B could not enumerate A's stream") - let bStreams = b.listUserStreams(aUid) - XCTAssertTrue(bStreams.contains { $0.id == streamId && $0.kind == .mic }) - - // Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off). - let (r0, st0) = b.getRemoteStream(userId: aUid, streamId: streamId) - XCTAssertEqual(r0, .ok) - XCTAssertNotNil(st0) - XCTAssertEqual(st0?.gain, 1.0) - XCTAssertFalse(st0?.muted ?? true) - XCTAssertFalse(st0?.noiseReduction ?? true) - - // B turns A down to 0.5×, mutes, enables NR — then reads it back. - XCTAssertEqual(b.setRemoteStream(userId: aUid, streamId: streamId, - gain: 0.5, muted: true, noiseReduction: true), .ok) - let (r1, st1) = b.getRemoteStream(userId: aUid, streamId: streamId) - XCTAssertEqual(r1, .ok) - XCTAssertNotNil(st1) - XCTAssertEqual(st1?.gain, 0.5) - XCTAssertTrue(st1?.muted ?? false) - XCTAssertTrue(st1?.noiseReduction ?? false) - - // Unknown stream id on a known user → .invalidArg. - let (rBad, stBad) = b.getRemoteStream(userId: aUid, streamId: 0xDEADBEEF) - XCTAssertEqual(rBad, .invalidArg) - XCTAssertNil(stBad) - - a.disconnect() - b.disconnect() - } -} diff --git a/clients/apple/VoiceCat.Apple.slnx b/clients/apple/VoiceCat.Apple.slnx new file mode 100644 index 0000000..07bf431 --- /dev/null +++ b/clients/apple/VoiceCat.Apple.slnx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/clients/apple/dotnet/VoiceCat.Mac/AdministrationWindowController.cs b/clients/apple/VoiceCat.Mac/AdministrationWindowController.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/AdministrationWindowController.cs rename to clients/apple/VoiceCat.Mac/AdministrationWindowController.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/AppDelegate.cs b/clients/apple/VoiceCat.Mac/AppDelegate.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/AppDelegate.cs rename to clients/apple/VoiceCat.Mac/AppDelegate.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/ChannelEditor.cs b/clients/apple/VoiceCat.Mac/ChannelEditor.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/ChannelEditor.cs rename to clients/apple/VoiceCat.Mac/ChannelEditor.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs b/clients/apple/VoiceCat.Mac/ConnectWindowController.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs rename to clients/apple/VoiceCat.Mac/ConnectWindowController.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/CoreAudioDevices.cs b/clients/apple/VoiceCat.Mac/CoreAudioDevices.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/CoreAudioDevices.cs rename to clients/apple/VoiceCat.Mac/CoreAudioDevices.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/EventFeedback.cs b/clients/apple/VoiceCat.Mac/EventFeedback.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/EventFeedback.cs rename to clients/apple/VoiceCat.Mac/EventFeedback.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/Info.plist b/clients/apple/VoiceCat.Mac/Info.plist similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/Info.plist rename to clients/apple/VoiceCat.Mac/Info.plist diff --git a/clients/apple/dotnet/VoiceCat.Mac/MacAudioBackend.cs b/clients/apple/VoiceCat.Mac/MacAudioBackend.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/MacAudioBackend.cs rename to clients/apple/VoiceCat.Mac/MacAudioBackend.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/MacKeychainPasswordStore.cs b/clients/apple/VoiceCat.Mac/MacKeychainPasswordStore.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/MacKeychainPasswordStore.cs rename to clients/apple/VoiceCat.Mac/MacKeychainPasswordStore.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/MacSettings.cs b/clients/apple/VoiceCat.Mac/MacSettings.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/MacSettings.cs rename to clients/apple/VoiceCat.Mac/MacSettings.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs b/clients/apple/VoiceCat.Mac/MainWindowController.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs rename to clients/apple/VoiceCat.Mac/MainWindowController.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/PrivateMessageWindowController.cs b/clients/apple/VoiceCat.Mac/PrivateMessageWindowController.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/PrivateMessageWindowController.cs rename to clients/apple/VoiceCat.Mac/PrivateMessageWindowController.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/Program.cs b/clients/apple/VoiceCat.Mac/Program.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/Program.cs rename to clients/apple/VoiceCat.Mac/Program.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/ScreenAudioCapture.cs b/clients/apple/VoiceCat.Mac/ScreenAudioCapture.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/ScreenAudioCapture.cs rename to clients/apple/VoiceCat.Mac/ScreenAudioCapture.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/ScreenAudioPicker.cs b/clients/apple/VoiceCat.Mac/ScreenAudioPicker.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/ScreenAudioPicker.cs rename to clients/apple/VoiceCat.Mac/ScreenAudioPicker.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/SettingsWindowController.cs b/clients/apple/VoiceCat.Mac/SettingsWindowController.cs similarity index 100% rename from clients/apple/dotnet/VoiceCat.Mac/SettingsWindowController.cs rename to clients/apple/VoiceCat.Mac/SettingsWindowController.cs diff --git a/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj b/clients/apple/VoiceCat.Mac/VoiceCat.Mac.csproj similarity index 85% rename from clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj rename to clients/apple/VoiceCat.Mac/VoiceCat.Mac.csproj index 122afb1..34fe9ac 100644 --- a/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj +++ b/clients/apple/VoiceCat.Mac/VoiceCat.Mac.csproj @@ -14,13 +14,13 @@ Info.plist VoiceCat.Mac.entitlements $(NoWarn);XCODE_27_0_PREVIEW - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../../dotnet/artifacts/native/runtimes/osx-arm64/native/libvoicecat_media.dylib')) - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../../dotnet/artifacts/native/licenses')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../artifacts/native/runtimes/osx-arm64/native/libvoicecat_media.dylib')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../artifacts/native/licenses')) <_ComputePublishLocationDependsOn>VoiceCatPrepareNativeAssets;$(_ComputePublishLocationDependsOn) - - + + - $(MSBuildThisFileDirectory)..\..\build\windows-client\bin - - diff --git a/clients/windows/README.md b/clients/windows/README.md index e33dab1..24b898d 100644 --- a/clients/windows/README.md +++ b/clients/windows/README.md @@ -1,43 +1,21 @@ -# VoiceCat Windows client +# Windows client -The WinForms .NET 10 client uses `VoiceCat.Core` for TLS, TOFU, protocol state and encrypted UDP, and `VoiceCat.Audio` for streams, jitter, Opus and mixing. Its only native runtime component is `voicecat_media.dll`, the narrow Opus/RNNoise C shim. The old `voicecat.dll` core is no longer loaded or published. - -## Build - -Stage the pinned native media dependencies once, then build the solution: +The .NET 10 WinForms client uses `VoiceCat.Core` and `VoiceCat.Audio`. Its only native runtime +component is `voicecat_media.dll`, the narrow Opus/RNNoise shim. ```powershell -./dotnet/build-native.ps1 +./scripts/build-native.ps1 dotnet restore clients/windows/VoiceCat.slnx --locked-mode dotnet build clients/windows/VoiceCat.slnx -c Release --no-restore -``` - -The original `VoiceCat.Interop` project remains in the repository as a migration oracle. The app references `VoiceCat.Managed`, whose compatibility facade lets the existing accessible WinForms UI keep its event-pump shape while all networking and audio state live in the idiomatic managed libraries. - -## Publish - -```powershell ./clients/windows/publish-client.ps1 ``` -This produces a self-contained `win-x64` distribution in `dotnet/artifacts/client/win-x64`. The script requires `voicecat_media.dll` and fails if the legacy `voicecat.dll` appears. - -The noninteractive startup and real WASAPI device check is: +Publishing produces `artifacts/client/win-x64`. The noninteractive form and WASAPI device gate +is: ```powershell -./dotnet/artifacts/client/win-x64/VoiceCat.App.exe --smoke-test --audio +./artifacts/client/win-x64/VoiceCat.App.exe --smoke-test --audio ``` -`--smoke-test` constructs the real main form and pumps the managed client. `--audio` additionally opens the default WASAPI capture and render endpoints, moves PCM through both for three seconds, and fails if capture produces no samples. - -## Run manually - -```powershell -# Terminal 1 -./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe - -# Terminal 2 -dotnet run --project clients/windows/VoiceCat.App/VoiceCat.App.csproj -``` - -Manual release validation includes NVDA navigation and a sustained two-client listen test, as tracked in `docs/roadmap.md`. +Manual release validation includes keyboard operation, NVDA navigation, curated announcements, +and a sustained real call. diff --git a/clients/windows/VoiceCat.App/Audio/ProcessAudioMixer.cs b/clients/windows/VoiceCat.App/Audio/ProcessAudioMixer.cs index e801799..9a0a22c 100644 --- a/clients/windows/VoiceCat.App/Audio/ProcessAudioMixer.cs +++ b/clients/windows/VoiceCat.App/Audio/ProcessAudioMixer.cs @@ -1,5 +1,5 @@ using VoiceCat.Audio; -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Audio; diff --git a/clients/windows/VoiceCat.App/Forms/AccountsDialog.cs b/clients/windows/VoiceCat.App/Forms/AccountsDialog.cs index 90d181b..ab9619c 100644 --- a/clients/windows/VoiceCat.App/Forms/AccountsDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/AccountsDialog.cs @@ -1,4 +1,4 @@ -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Forms; diff --git a/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs b/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs index 9ad35e4..ad74b65 100644 --- a/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs +++ b/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using VoiceCat.App.Audio; using VoiceCat.App.Models; -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Forms; diff --git a/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs b/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs index ceda6ec..128b958 100644 --- a/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs @@ -1,4 +1,4 @@ -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Forms; diff --git a/clients/windows/VoiceCat.App/Forms/ConnectDialog.cs b/clients/windows/VoiceCat.App/Forms/ConnectDialog.cs index fd97455..c1a642c 100644 --- a/clients/windows/VoiceCat.App/Forms/ConnectDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/ConnectDialog.cs @@ -1,5 +1,5 @@ using VoiceCat.App.Models; -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Forms; diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs index 46d8480..65e72fe 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs @@ -2,7 +2,7 @@ using VoiceCat.App.Audio; using VoiceCat.App.Models; using VoiceCat.App.Native; using VoiceCat.App.Notifications; -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Forms; diff --git a/clients/windows/VoiceCat.App/Forms/MoveUserDialog.cs b/clients/windows/VoiceCat.App/Forms/MoveUserDialog.cs index 05a3a20..ae5e90b 100644 --- a/clients/windows/VoiceCat.App/Forms/MoveUserDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/MoveUserDialog.cs @@ -1,4 +1,4 @@ -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Forms; diff --git a/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs b/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs index 072925e..3e70c33 100644 --- a/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs @@ -1,4 +1,4 @@ -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Forms; diff --git a/clients/windows/VoiceCat.App/Forms/PermissionsDialog.cs b/clients/windows/VoiceCat.App/Forms/PermissionsDialog.cs index 3119fa6..62445a1 100644 --- a/clients/windows/VoiceCat.App/Forms/PermissionsDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/PermissionsDialog.cs @@ -1,4 +1,4 @@ -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Forms; diff --git a/clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs b/clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs index 8284594..8bf4701 100644 --- a/clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs +++ b/clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs @@ -1,4 +1,4 @@ -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Forms; diff --git a/clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.cs b/clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.cs index f135a07..4e0bb26 100644 --- a/clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.cs @@ -1,4 +1,4 @@ -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Forms; diff --git a/clients/windows/VoiceCat.App/Forms/UserPickerDialog.cs b/clients/windows/VoiceCat.App/Forms/UserPickerDialog.cs index a66e8a5..7763d13 100644 --- a/clients/windows/VoiceCat.App/Forms/UserPickerDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/UserPickerDialog.cs @@ -1,4 +1,4 @@ -using VoiceCat.Interop; +using VoiceCat.Windows; namespace VoiceCat.App.Forms; diff --git a/clients/windows/VoiceCat.App/Native/RawInput.cs b/clients/windows/VoiceCat.App/Native/RawInput.cs index e808b04..ca18874 100644 --- a/clients/windows/VoiceCat.App/Native/RawInput.cs +++ b/clients/windows/VoiceCat.App/Native/RawInput.cs @@ -62,7 +62,7 @@ internal static partial class RawInput public uint ExtraInformation; } - // ── P/Invoke (source-generated via LibraryImport, matching VoiceCat.Interop) ──────────── + // Raw Input P/Invoke (source-generated via LibraryImport). [LibraryImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static partial bool RegisterRawInputDevices( diff --git a/clients/windows/VoiceCat.App/Program.cs b/clients/windows/VoiceCat.App/Program.cs index 3e51f0f..750ddac 100644 --- a/clients/windows/VoiceCat.App/Program.cs +++ b/clients/windows/VoiceCat.App/Program.cs @@ -20,7 +20,7 @@ internal static class Program { try { - using var client = new VoiceCat.Interop.VoiceCatClient("Smoke", "test"); + using var client = new VoiceCat.Windows.VoiceCatClient("Smoke", "test"); using var form = new MainForm(client, 0, "Smoke", "Managed core"); form.CreateControl(); if (form.Controls.Count == 0 || string.IsNullOrEmpty(form.Text)) return 1; diff --git a/clients/windows/VoiceCat.App/VoiceCat.App.csproj b/clients/windows/VoiceCat.App/VoiceCat.App.csproj index 6741404..c6ae788 100644 --- a/clients/windows/VoiceCat.App/VoiceCat.App.csproj +++ b/clients/windows/VoiceCat.App/VoiceCat.App.csproj @@ -1,7 +1,7 @@  - + - - - voicecat.dll - PreserveNewest - - - - - - - - \ No newline at end of file diff --git a/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs b/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs deleted file mode 100644 index b87f912..0000000 --- a/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs +++ /dev/null @@ -1,401 +0,0 @@ -using System.Diagnostics; -using System.Text.RegularExpressions; -using VoiceCat.Interop; - -namespace VoiceCat.Interop.Tests; - -/// -/// Exercises the full connect -> TOFU event -> confirm -> guest auth -> list channels flow -/// purely through the P/Invoke layer (NativeMethods/VoiceCatClient), against a real -/// `voicecat-server.exe` (the same binary the C++ ctest suite uses) — this is the same flow -/// tests/test_tofu_flow.cpp already proves at the C++ level, now proven reachable through -/// P/Invoke specifically: marshaling bugs, callback-lifetime bugs, and calling-convention -/// mistakes are all P/Invoke-specific failure modes ctest alone cannot catch. -/// -public sealed class VoiceCatClientSmokeTests : IDisposable -{ - private readonly string _tempDir; - private readonly Process _server; - private readonly ushort _port; - - public VoiceCatClientSmokeTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - - string serverExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-server.exe"); - Assert.True(File.Exists(serverExe), - $"voicecat-server.exe not found at '{serverExe}' — build the dev preset first " + - "(cmake --preset dev && cmake --build --preset dev)."); - - var psi = new ProcessStartInfo(serverExe) - { - Arguments = $"--port 0 --data-dir \"{_tempDir}\" --name CSharpSmokeTest", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - _server = Process.Start(psi) ?? throw new InvalidOperationException("failed to start voicecat-server.exe"); - - // "[voicecat-server] — TCP : UDP :" — see server/src/server.cpp. - // ReadLineAsync()+timeout (not a bare blocking ReadLine() in a deadline loop) so a - // server that never prints anything (crash, hang) can't hang this constructor forever - // — the deadline check must apply to the read itself, not just the loop around it. - ushort? port = null; - var deadline = DateTime.UtcNow.AddSeconds(10); - while (port is null && DateTime.UtcNow < deadline) - { - var readTask = _server.StandardOutput.ReadLineAsync(); - var remaining = deadline - DateTime.UtcNow; - if (remaining <= TimeSpan.Zero || !readTask.Wait(remaining)) break; - string? line = readTask.Result; - if (line is null) break; - var m = Regex.Match(line, @"TCP :(\d+)"); - if (m.Success) port = ushort.Parse(m.Groups[1].Value); - } - Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s."); - _port = port!.Value; - - // Provision a known admin account so we can exercise moderation wrappers end-to-end. - string adminExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-admin.exe"); - Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the dev preset."); - var adminPsi = new ProcessStartInfo(adminExe) - { - Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - using (var adminProc = Process.Start(adminPsi) ?? throw new InvalidOperationException("failed to start voicecat-admin.exe")) - { - Assert.True(adminProc.WaitForExit(10000), "voicecat-admin.exe did not exit within 10s."); - Assert.Equal(0, adminProc.ExitCode); - } - } - - public void Dispose() - { - try { if (!_server.HasExited) _server.Kill(entireProcessTree: true); } catch { /* best effort */ } - try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ } - } - - private static string FindRepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "CMakePresets.json"))) - dir = dir.Parent; - return dir?.FullName ?? throw new InvalidOperationException("Could not find repo root (CMakePresets.json) above " + AppContext.BaseDirectory); - } - - private static bool PumpUntil(VoiceCatClient client, Func predicate, int timeoutMs) - { - var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); - while (DateTime.UtcNow < deadline) - { - client.PumpEvents(); - if (predicate()) return true; - Thread.Sleep(20); - } - client.PumpEvents(); - return predicate(); - } - - [Fact] - public void VersionString_IsNonEmpty() - { - Assert.False(string.IsNullOrEmpty(VoiceCatClient.VersionString)); - } - - [Fact] - public void Connect_Tofu_Auth_ListChannels_RoundTrips() - { - var events = new List(); - using var client = new VoiceCatClient("vc-csharp-smoke", "0.1", VcLogLevel.Off, - tofuStorePath: Path.Combine(_tempDir, "tofu_pins.txt")); - client.EventReceived += events.Add; - - Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port)); - Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpSmoke")); - - Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000), - "did not receive VC_EVENT_SERVER_IDENTITY"); - var identityEvent = events.First(e => e.Type == VcEventType.ServerIdentity); - Assert.Equal((uint)VcTofuStatus.FirstConnect, identityEvent.U32a); - Assert.NotNull(identityEvent.Text); - Assert.Equal(64, identityEvent.Text!.Length); // SHA-256 hex, no separators - - // Auth must NOT complete before the identity is confirmed. - Assert.False(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 800)); - - Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true)); - - Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000), - "did not receive VC_EVENT_AUTH_RESULT after confirming identity"); - var authEvent = events.First(e => e.Type == VcEventType.AuthResult); - Assert.Equal(VcResult.Ok, authEvent.Result); - - Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000), - "did not receive VC_EVENT_CHANNEL_LIST"); - - var channels = client.ListChannels(); - Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby"); - - // Permissions getter round-trip. - var perms = client.GetPermissions(); - Assert.False(perms.IsAdmin); - Assert.False(perms.CanKick); - - // Moderation request wrappers queue without error. As a guest, account listing - // is rejected by the server with a GenericResult, which proves the wrapper path works - // end-to-end and that the event type is delivered through P/Invoke. - Assert.Equal(VcResult.Ok, client.RequestAccountList()); - Assert.True(PumpUntil(client, - () => events.Any(e => e.Type == VcEventType.GenericResult), 3000), - "did not receive VC_EVENT_GENERIC_RESULT for guest ListAccounts"); - var generic = events.First(e => e.Type == VcEventType.GenericResult); - Assert.Equal(VcResult.PermissionDenied, generic.Result); - - client.Disconnect(); - } - - [Fact] - public void Admin_ChannelCrud_AccountCrud_RoundTrips() - { - var events = new List(); - using var client = new VoiceCatClient("vc-csharp-admin", "0.1", VcLogLevel.Off, - tofuStorePath: Path.Combine(_tempDir, "tofu_pins_admin.txt")); - client.EventReceived += events.Add; - - Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port)); - Assert.Equal(VcResult.Ok, client.AuthenticateUser("admin2", "testpassword123")); - - Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000)); - Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true)); - Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000)); - Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result); - Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000)); - - var perms = client.GetPermissions(); - Assert.True(perms.IsAdmin || perms.CanAdminAccounts); - - // Channel CRUD - Assert.Equal(VcResult.Ok, client.CreateChannel(new ChannelEditInfo( - Id: 0, - ParentId: 0, - Name: "CSharp Test Channel", - Topic: "Created by C# smoke test", - PasswordProtected: false, - Password: null, - MaxUsers: 42, - SortOrder: 0, - Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false)))); - Assert.True(PumpUntil(client, - () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), - "CreateChannel did not succeed"); - - var channels = client.ListChannels(); - var created = channels.FirstOrDefault(c => c.Name == "CSharp Test Channel"); - Assert.NotNull(created); - Assert.Equal("Created by C# smoke test", created.Topic); - Assert.True(created.PasswordProtected == false); - - Assert.Equal(VcResult.Ok, client.EditChannel(new ChannelEditInfo( - created.Id, - created.ParentId, - created.Name, - "Updated topic", - created.PasswordProtected, - null, - 100, - 0, - new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false)))); - events.Clear(); - Assert.True(PumpUntil(client, - () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), - "EditChannel did not succeed"); - - Assert.Equal(VcResult.Ok, client.DeleteChannel(created.Id)); - events.Clear(); - Assert.True(PumpUntil(client, - () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), - "DeleteChannel did not succeed"); - - // Account CRUD - Assert.Equal(VcResult.Ok, client.CreateAccount("csharp_smoke_user", "initialpw")); - events.Clear(); - Assert.True(PumpUntil(client, - () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), - "CreateAccount did not succeed"); - - Assert.Equal(VcResult.Ok, client.RequestAccountList()); - Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AccountList), 3000)); - var accounts = client.ListAccounts(); - Assert.Contains(accounts, a => a.Username == "csharp_smoke_user"); - - Assert.Equal(VcResult.Ok, client.ResetPassword("csharp_smoke_user", "newpw123")); - events.Clear(); - Assert.True(PumpUntil(client, - () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), - "ResetPassword did not succeed"); - - Assert.Equal(VcResult.Ok, client.DeleteAccount("csharp_smoke_user")); - events.Clear(); - Assert.True(PumpUntil(client, - () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), - "DeleteAccount did not succeed"); - - client.Disconnect(); - } - - /// - /// Screen-audio (SCREEN_AUDIO) stream start/stop through the P/Invoke layer. The core's - /// WASAPI loopback path (VOICECAT_HAS_LOOPBACK) captures the default render endpoint; the - /// StreamAnnounce succeeds regardless of whether the loopback device actually initializes - /// on a headless box, so this test passes in CI while still exercising the full - /// StartStream -> StreamStarted -> StopStream -> StreamStopped path through P/Invoke. - /// See docs/voice.md §9 and MainForm's BtnScreenShareToggle_Click. - /// - [Fact] - public void ScreenAudioStream_Starts_And_Stops() - { - var events = new List(); - using var client = new VoiceCatClient("vc-csharp-screen", "0.1", VcLogLevel.Off, - tofuStorePath: Path.Combine(_tempDir, "tofu_pins_screen.txt")); - client.EventReceived += events.Add; - - Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port)); - Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpScreen")); - - Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000)); - Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true)); - Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000)); - Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result); - Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000)); - - Assert.Equal(VcResult.Ok, client.JoinVoice()); - Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000)); - // Give the async UDP binding handshake a moment to land before announcing a stream - // (mirrors vccli's 500ms sleep after auth). - Thread.Sleep(500); - - var (startResult, streamId) = client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio"); - Assert.Equal(VcResult.Ok, startResult); - Assert.True(streamId != 0, "StreamId should be non-zero on success"); - - // The core emits VC_EVENT_STREAM_STARTED for the local client too (client.cpp - // handle_stream_announce_result), so we see our own screen-audio stream start. - Assert.True(PumpUntil(client, - () => events.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId), 5000), - "did not receive VC_EVENT_STREAM_STARTED for screen-audio stream"); - - Assert.Equal(VcResult.Ok, client.StopStream(streamId)); - Assert.True(PumpUntil(client, - () => events.Any(e => e.Type == VcEventType.StreamStopped && e.StreamId == streamId), 5000), - "did not receive VC_EVENT_STREAM_STOPPED for screen-audio stream"); - - client.Disconnect(); - } - - /// - /// Per-stream receive-side controls (gain/mute/NR) round-trip through P/Invoke: two - /// clients in a channel, one publishes a MIC stream, the other SetRemoteStream's it then - /// GetRemoteStream's it back. Catches P/Invoke-specific marshaling bugs in - /// VcRemoteStreamStateNative (field order, bool-from-int, float precision) that the C++ - /// ctest (test_m3_multistream) cannot. See docs/voice.md §1, §10. - /// - [Fact] - public void PerStream_RecvControls_RoundTrip_Through_PInvoke() - { - var eventsA = new List(); - var eventsB = new List(); - using var a = new VoiceCatClient("vc-csharp-mix-a", "0.1", VcLogLevel.Off, - tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_a.txt")); - using var b = new VoiceCatClient("vc-csharp-mix-b", "0.1", VcLogLevel.Off, - tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_b.txt")); - a.EventReceived += eventsA.Add; - b.EventReceived += eventsB.Add; - - // Connect + auth A first, then B — staggering avoids concurrent TLS handshakes against - // the same server (mirrors the C++ test_m3_multistream harness, which connects A to - // completion before starting B). - Assert.Equal(VcResult.Ok, a.Connect("127.0.0.1", _port)); - Assert.Equal(VcResult.Ok, a.AuthenticateGuest("CSharpMixA")); - Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ServerIdentity), 5000)); - Assert.Equal(VcResult.Ok, a.ConfirmServerIdentity(accept: true)); - Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.AuthResult), 5000)); - Assert.Equal(VcResult.Ok, eventsA.First(e => e.Type == VcEventType.AuthResult).Result); - Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ChannelList), 3000)); - - Assert.Equal(VcResult.Ok, b.Connect("127.0.0.1", _port)); - Assert.Equal(VcResult.Ok, b.AuthenticateGuest("CSharpMixB")); - Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ServerIdentity), 5000)); - Assert.Equal(VcResult.Ok, b.ConfirmServerIdentity(accept: true)); - Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.AuthResult), 5000)); - Assert.Equal(VcResult.Ok, eventsB.First(e => e.Type == VcEventType.AuthResult).Result); - Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ChannelList), 3000)); - - // Both join Lobby (channel 1) so voice relays between them. - Assert.Equal(VcResult.Ok, a.JoinChannel(1, null)); - Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.JoinResult), 5000), - "A did not receive VC_EVENT_JOIN_RESULT"); - Assert.Equal(VcResult.Ok, b.JoinChannel(1, null)); - Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.JoinResult), 5000), - "B did not receive VC_EVENT_JOIN_RESULT"); - - Assert.Equal(VcResult.Ok, a.JoinVoice()); - Assert.Equal(VcResult.Ok, b.JoinVoice()); - Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000)); - Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000)); - // UDP binding handshake is async; give it a moment (mirrors ScreenAudio test). - Thread.Sleep(500); - - // A publishes a MIC stream. - var (startResult, streamId) = a.StartStream(VcStreamKind.Mic, "mix-test-mic"); - Assert.Equal(VcResult.Ok, startResult); - Assert.True(streamId != 0); - - // B sees A's stream and can enumerate it. - uint aUid = 0; - Assert.True(PumpUntil(b, () => - { - return eventsB.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId); - }, 5000), "B did not see A's STREAM_STARTED"); - // Resolve A's user id from B's user list. - Assert.True(PumpUntil(b, () => - { - aUid = b.ListUsers().FirstOrDefault(u => u.Nickname == "CSharpMixA")?.Id ?? 0; - return aUid != 0; - }, 3000), "could not resolve A's user id on B"); - Assert.True(aUid != 0); - - Assert.True(PumpUntil(b, () => b.ListUserStreams(aUid).Any(s => s.StreamId == streamId), 3000), - "B could not enumerate A's stream"); - var bStreams = b.ListUserStreams(aUid); - Assert.Contains(bStreams, s => s.StreamId == streamId && s.Kind == VcStreamKind.Mic); - - // Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off). - var (r0, st0) = b.GetRemoteStream(aUid, streamId); - Assert.Equal(VcResult.Ok, r0); - Assert.NotNull(st0); - Assert.Equal(1.0f, st0!.Gain); - Assert.False(st0.Muted); - Assert.False(st0.NoiseReduction); - - // B turns A down to 0.4×, mutes, and enables NR — then reads it back. - Assert.Equal(VcResult.Ok, b.SetRemoteStream(aUid, streamId, 0.4f, muted: true, noiseReduction: true)); - var (r1, st1) = b.GetRemoteStream(aUid, streamId); - Assert.Equal(VcResult.Ok, r1); - Assert.NotNull(st1); - Assert.Equal(0.4f, st1!.Gain); - Assert.True(st1.Muted); - Assert.True(st1.NoiseReduction); - - // Unknown stream id on a known user -> INVALID_ARG. - var (rBad, stBad) = b.GetRemoteStream(aUid, 0xDEADBEEF); - Assert.Equal(VcResult.InvalidArg, rBad); - Assert.Null(stBad); - - a.Disconnect(); - b.Disconnect(); - } -} diff --git a/clients/windows/VoiceCat.Interop/Marshaling.cs b/clients/windows/VoiceCat.Interop/Marshaling.cs deleted file mode 100644 index 8e937e3..0000000 --- a/clients/windows/VoiceCat.Interop/Marshaling.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Runtime.InteropServices; - -// Shared "walk a native array of owned-struct entries, convert to managed records, free the -// native list" pattern — identical shape for vc_device_list/vc_channel_list/vc_user_list/ -// vc_stream_summary_list (all core-allocated, caller-freed; see voicecat.h's doc comments on -// each). The matching vc_free_*_list call happens INSIDE each ToManaged here, immediately -// after the conversion, so callers never need to remember to free anything themselves. -namespace VoiceCat.Interop; - -internal static class Marshaling -{ - public static List ToManaged(ref VcDeviceListNative native) - { - var result = new List((int)native.Count); - int size = Marshal.SizeOf(); - for (nuint i = 0; i < native.Count; i++) - { - var raw = Marshal.PtrToStructure(native.Items + (int)i * size); - result.Add(new DeviceInfo( - Marshal.PtrToStringUTF8(raw.Id) ?? string.Empty, - Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty, - raw.IsDefault != 0)); - } - NativeMethods.vc_free_device_list(ref native); - return result; - } - - public static List ToManaged(ref VcChannelListNative native) - { - var result = new List((int)native.Count); - int size = Marshal.SizeOf(); - for (nuint i = 0; i < native.Count; i++) - { - var raw = Marshal.PtrToStructure(native.Items + (int)i * size); - result.Add(new ChannelInfo( - raw.Id, - raw.ParentId, - Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty, - Marshal.PtrToStringUTF8(raw.Topic) ?? string.Empty, - raw.PasswordProtected != 0, - raw.MaxUsers, - raw.SortOrder, - ToManaged(in raw.Audio))); - } - NativeMethods.vc_free_channel_list(ref native); - return result; - } - - public static List ToManaged(ref VcUserListNative native) - { - var result = new List((int)native.Count); - int size = Marshal.SizeOf(); - for (nuint i = 0; i < native.Count; i++) - { - var raw = Marshal.PtrToStructure(native.Items + (int)i * size); - result.Add(new UserInfo( - raw.Id, - Marshal.PtrToStringUTF8(raw.Nickname) ?? string.Empty, - raw.IsGuest != 0, - raw.ChannelId, - raw.SelfMicMuted != 0, - raw.SelfDeafened != 0, - raw.ServerMuted != 0, - raw.ServerDeafened != 0, - raw.VoiceSubscribed != 0)); - } - NativeMethods.vc_free_user_list(ref native); - return result; - } - - public static List ToManaged(ref VcStreamSummaryListNative native) - { - var result = new List((int)native.Count); - int size = Marshal.SizeOf(); - for (nuint i = 0; i < native.Count; i++) - { - var raw = Marshal.PtrToStructure(native.Items + (int)i * size); - result.Add(new StreamSummary( - raw.StreamId, - raw.Kind, - Marshal.PtrToStringUTF8(raw.Label) ?? string.Empty)); - } - NativeMethods.vc_free_stream_summary_list(ref native); - return result; - } - - public static RemoteStreamState ToManaged(in VcRemoteStreamStateNative native) => new( - native.Gain, - native.Muted != 0, - native.NoiseReduction != 0); - - public static AudioConfigInfo ToManaged(in VcAudioConfigNative native) => new( - native.Codec, - native.Mode != 0, - native.SampleRate, - native.BitrateBps, - native.FrameMs, - native.Application, - native.Fec != 0, - native.ExpectedPacketLoss, - native.Dtx != 0, - native.Complexity, - native.Dred != 0); - - public static PermissionsInfo ToManaged(in VcPermissionsNative native) => new( - native.CanCreateTempChannel != 0, - native.CanKick != 0, - native.CanBan != 0, - native.CanMoveUsers != 0, - native.CanAdminAccounts != 0, - native.IsAdmin != 0); - - public static List ToManaged(ref VcAccountListNative native) - { - var result = new List((int)native.Count); - int size = Marshal.SizeOf(); - for (nuint i = 0; i < native.Count; i++) - { - var raw = Marshal.PtrToStructure(native.Items + (int)i * size); - result.Add(new AccountInfo( - Marshal.PtrToStringUTF8(raw.Username) ?? string.Empty, - raw.IsAdmin != 0, - raw.CreatedAtUnixMs, - raw.LastLoginUnixMs)); - } - NativeMethods.vc_free_account_list(ref native); - return result; - } -} diff --git a/clients/windows/VoiceCat.Interop/NativeCallbacks.cs b/clients/windows/VoiceCat.Interop/NativeCallbacks.cs deleted file mode 100644 index 00b1a86..0000000 --- a/clients/windows/VoiceCat.Interop/NativeCallbacks.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Runtime.InteropServices; - -// [UnmanagedCallersOnly] static methods for vc_callbacks.on_event/on_level — true native -// function pointers, not GC-tracked delegates (avoids the classic P/Invoke pitfall where a -// delegate is collected by the GC sometime after the call that registered it returns; see -// docs/tech-stack.md §2). vc_callbacks.user is a GCHandle-wrapped VoiceCatClient (allocated in -// VoiceCatClient's constructor, freed in Dispose) — these methods must be static, so they -// resolve back to the right client instance via that handle rather than closing over state. -namespace VoiceCat.Interop; - -internal static unsafe class NativeCallbacks -{ - [UnmanagedCallersOnly] - internal static void OnEvent(IntPtr userContext, VcEventNative* ev) - { - if (userContext == IntPtr.Zero) return; - if (GCHandle.FromIntPtr(userContext).Target is not VoiceCatClient client) return; - - // CRITICAL (voicecat.h's vc_event doc comment): ev->Text is owned by the core and - // valid ONLY for the duration of this callback. Convert to a managed string NOW, - // before returning — never store/queue the raw VcEventNative across the callback - // boundary, or Text will be a dangling pointer by the time it's read. - client.EnqueueEvent(VoiceCatEvent.FromNative(*ev)); - } - - [UnmanagedCallersOnly] - internal static void OnLevel(IntPtr userContext, uint streamId, float rms) - { - if (userContext == IntPtr.Zero) return; - if (GCHandle.FromIntPtr(userContext).Target is not VoiceCatClient client) return; - client.EnqueueLevel(streamId, rms); - } -} diff --git a/clients/windows/VoiceCat.Interop/NativeMethods.cs b/clients/windows/VoiceCat.Interop/NativeMethods.cs deleted file mode 100644 index 96438bf..0000000 --- a/clients/windows/VoiceCat.Interop/NativeMethods.cs +++ /dev/null @@ -1,228 +0,0 @@ -using System.Runtime.InteropServices; - -// Raw P/Invoke surface over core/include/voicecat.h, via LibraryImport (source-generated — -// no runtime reflection marshaling stub; see docs/tech-stack.md §2). One entry per voicecat.h -// function. `vc_client*` is represented as a raw `nint` here — VoiceCatClientHandle (a -// SafeHandle) owns the create/destroy lifetime one level up; these declarations never see a -// SafeHandle directly, per .NET's own SafeHandle convention. -// -// "voicecat" resolves to voicecat.dll via the OS's standard DLL search order (same directory -// as the .exe first) — see clients/windows/README.md for how it gets there at build time. -namespace VoiceCat.Interop; - -internal static partial class NativeMethods -{ - private const string LibName = "voicecat"; - - // ── Lifecycle ──────────────────────────────────────────────────────────────────────── - // NOTE: these two return `const char*` pointing at STATIC string literals the core never - // expects the caller to free. Declaring them as `string` with StringMarshalling.Utf8 - // would be wrong: the built-in Utf8StringMarshaller's return-value convention assumes the - // native callee allocated the string FOR this call and that the marshaller should free it - // afterward — calling that on a static literal corrupts the heap (confirmed: it crashes - // with STATUS_HEAP_CORRUPTION / 0xC0000374). Return the raw pointer instead and convert - // with Marshal.PtrToStringUTF8 ourselves, without ever freeing it — see VoiceCatClient.cs. - [LibraryImport(LibName)] - internal static partial nint vc_version_string(); - - [LibraryImport(LibName)] - internal static partial nint vc_result_string(VcResult code); - - [LibraryImport(LibName)] - internal static partial nint vc_client_create(in VcConfigNative cfg, VcCallbacksNative cb); - - [LibraryImport(LibName)] - internal static partial void vc_client_destroy(nint c); - - // ── Connection & auth (async; results via on_event) ──────────────────────────────────── - [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] - internal static partial VcResult vc_connect(nint c, string host, ushort port); - - [LibraryImport(LibName)] - internal static partial VcResult vc_disconnect(nint c); - - [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] - internal static partial VcResult vc_authenticate_guest(nint c, string nickname); - - [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] - internal static partial VcResult vc_authenticate_user(nint c, string username, string password); - - // ── Channels ───────────────────────────────────────────────────────────────────────── - [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] - internal static partial VcResult vc_join_channel(nint c, uint channelId, string? password); - - [LibraryImport(LibName)] - internal static partial VcResult vc_leave_channel(nint c); - - [LibraryImport(LibName)] - internal static partial VcResult vc_join_voice(nint c); - - [LibraryImport(LibName)] - internal static partial VcResult vc_leave_voice(nint c); - - // ── Local media streams ───────────────────────────────────────────────────────────────── - [LibraryImport(LibName)] - internal static partial VcResult vc_stream_start(nint c, in VcStreamDescNative desc, - out uint outStreamId); - - [LibraryImport(LibName)] - internal static partial VcResult vc_stream_stop(nint c, uint streamId); - - [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] - internal static partial VcResult vc_set_input_device(nint c, uint streamId, string? deviceId); - - [LibraryImport(LibName)] - internal static partial VcResult vc_set_capture_channels(nint c, uint streamId, uint channels); - - [LibraryImport(LibName)] - internal static partial VcResult vc_audio_restart(nint c); - - [LibraryImport(LibName)] - internal static partial VcResult vc_set_input_mode(nint c, VcInputMode mode); - - [LibraryImport(LibName)] - internal static partial VcResult vc_set_vad_threshold(nint c, float threshold); - - [LibraryImport(LibName)] - internal static partial VcResult vc_set_push_to_talk(nint c, int active); - - [LibraryImport(LibName)] - internal static partial VcResult vc_set_self_mute(nint c, int micMuted, int deafened); - - [LibraryImport(LibName)] - internal static partial VcResult vc_set_output_volume(nint c, float gain); - - [LibraryImport(LibName)] - internal static partial VcResult vc_set_input_gain(nint c, float gain); - - [LibraryImport(LibName)] - internal static partial VcResult vc_set_input_noise_reduction(nint c, int enable); - - [LibraryImport(LibName)] - internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId, - float gain, int muted, int noiseReduction); - - [LibraryImport(LibName)] - internal static partial VcResult vc_get_remote_stream(nint c, uint userId, uint streamId, - out VcRemoteStreamStateNative outState); - - [LibraryImport(LibName)] - internal static partial VcResult vc_get_stream_audio_config(nint c, uint userId, - uint streamId, out VcAudioConfigNative outCfg); - - // TEST-ONLY in the core (see voicecat.h) — declared for ABI parity; the real app never - // calls this (no microphone-bypass path in production UI). - [LibraryImport(LibName)] - internal static unsafe partial VcResult vc_test_inject_capture(nint c, uint streamId, - short* pcm, nuint samples); - - // ── External PCM feed / tap ────────────────────────────────────────────────────────── - - [LibraryImport(LibName)] - internal static unsafe partial VcResult vc_stream_feed_pcm(nint c, uint streamId, - short* pcm, nuint samplesPerChannel, uint channels); - - // Delegate type for the PCM sink callback — callers convert to a native function - // pointer via Marshal.GetFunctionPointerForDelegate (for instance members) or by casting - // a static lambda to delegate* unmanaged<> (for [UnmanagedCallersOnly] statics). - // Keep the delegate alive for the lifetime of the sink registration. - [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - public delegate void VcPcmSinkCallback(IntPtr user, uint userId, uint streamId, - IntPtr pcm, nuint samplesPerChannel, uint channels, uint sampleRate); - - // cb is a raw function pointer (IntPtr.Zero = disable). Use - // Marshal.GetFunctionPointerForDelegate(sinkDelegate) to convert from VcPcmSinkCallback. - [LibraryImport(LibName)] - internal static partial VcResult vc_set_pcm_sink(nint c, nint cb, IntPtr user); - - // ── Text ───────────────────────────────────────────────────────────────────────────── - [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] - internal static partial VcResult vc_send_text(nint c, VcTextScope scope, uint targetId, - string utf8); - - // ── Device enumeration ─────────────────────────────────────────────────────────────── - [LibraryImport(LibName)] - internal static partial VcResult vc_list_devices(nint c, VcDeviceKind kind, - out VcDeviceListNative outList); - - [LibraryImport(LibName)] - internal static partial void vc_free_device_list(ref VcDeviceListNative list); - - // ── Channel / user / stream snapshot getters ──────────────────────────────────────────── - [LibraryImport(LibName)] - internal static partial VcResult vc_list_channels(nint c, out VcChannelListNative outList); - - [LibraryImport(LibName)] - internal static partial void vc_free_channel_list(ref VcChannelListNative list); - - [LibraryImport(LibName)] - internal static partial VcResult vc_list_users(nint c, out VcUserListNative outList); - - [LibraryImport(LibName)] - internal static partial void vc_free_user_list(ref VcUserListNative list); - - [LibraryImport(LibName)] - internal static partial VcResult vc_list_user_streams(nint c, uint userId, - out VcStreamSummaryListNative outList); - - [LibraryImport(LibName)] - internal static partial void vc_free_stream_summary_list(ref VcStreamSummaryListNative list); - - // ── TOFU server-identity gate ─────────────────────────────────────────────────────────── - [LibraryImport(LibName)] - internal static partial VcResult vc_confirm_server_identity(nint c, int accept); - - [LibraryImport(LibName)] - internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf, - nuint bufCap, out nuint outLen); - - // ── Moderation & admin ─────────────────────────────────────────────────────────────────── - [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] - internal static partial VcResult vc_kick_user(nint c, uint userId, string? reason); - - [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] - internal static partial VcResult vc_ban_user(nint c, uint userId, string? reason, - ulong expiresUnixMs); - - [LibraryImport(LibName)] - internal static partial VcResult vc_set_permission(nint c, uint userId, - in VcPermissionsNative perms); - - [LibraryImport(LibName)] - internal static partial VcResult vc_set_server_mute(nint c, uint userId, int muted, - int deafened); - - [LibraryImport(LibName)] - internal static partial VcResult vc_move_user(nint c, uint userId, uint channelId); - - [LibraryImport(LibName)] - internal static partial VcResult vc_create_channel(nint c, in VcChannelInfoNative info); - - [LibraryImport(LibName)] - internal static partial VcResult vc_edit_channel(nint c, in VcChannelInfoNative info); - - [LibraryImport(LibName)] - internal static partial VcResult vc_delete_channel(nint c, uint channelId); - - [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] - internal static partial VcResult vc_create_account(nint c, string username, string password); - - [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] - internal static partial VcResult vc_reset_password(nint c, string username, - string newPassword); - - [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] - internal static partial VcResult vc_delete_account(nint c, string username); - - [LibraryImport(LibName)] - internal static partial VcResult vc_list_accounts(nint c); - - [LibraryImport(LibName)] - internal static partial VcResult vc_get_account_list(nint c, out VcAccountListNative outList); - - [LibraryImport(LibName)] - internal static partial void vc_free_account_list(ref VcAccountListNative list); - - [LibraryImport(LibName)] - internal static partial VcResult vc_get_permissions(nint c, out VcPermissionsNative outPerms); -} diff --git a/clients/windows/VoiceCat.Interop/Structs.cs b/clients/windows/VoiceCat.Interop/Structs.cs deleted file mode 100644 index 9269a95..0000000 --- a/clients/windows/VoiceCat.Interop/Structs.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.Runtime.InteropServices; - -// Native (blittable) struct layouts mirroring voicecat.h field-for-field. These are the raw -// P/Invoke shapes — NativeMethods.cs uses them directly; Marshaling.cs converts them to the -// managed record types in Models.cs. const char* fields stay IntPtr here (LibraryImport's -// StringMarshalling only auto-converts top-level string parameters/returns, not struct -// fields) and must be hand-marshaled — see Marshaling.cs and VoiceCatClient.cs. -namespace VoiceCat.Interop; - -[StructLayout(LayoutKind.Sequential)] -internal struct VcEventNative -{ - public VcEventType Type; - public VcConnectionState ConnectionState; - public int Result; // vc_result - public uint UserId; - public uint ChannelId; - public uint StreamId; - public VcTextScope TextScope; - public uint U32a; - public IntPtr Text; // owned by core, valid ONLY for the callback's duration - public ulong TimestampUnixMs; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcConfigNative -{ - public IntPtr ClientName; - public IntPtr ClientVersion; - public VcLogLevel LogLevel; - public IntPtr TofuStorePath; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcCallbacksNative -{ - public IntPtr OnEvent; // delegate* unmanaged - public IntPtr OnLevel; // delegate* unmanaged - public IntPtr User; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcStreamDescNative -{ - public VcStreamKind Kind; - // Device selection uses vc_set_input_device; stream start passes null. - public IntPtr DeviceId; - public IntPtr Label; - // Mirrors vc_stream_desc::external_feed. When 1, the core skips its own WASAPI loopback - // and the caller feeds PCM via StreamFeedPcm (per-app capture path on Windows). - public int ExternalFeed; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcAudioConfigNative -{ - public uint Codec; - public uint Mode; - public uint SampleRate; - public uint BitrateBps; - public uint FrameMs; - public uint Application; - public int Fec; - public uint ExpectedPacketLoss; - public int Dtx; - public uint Complexity; - public int Dred; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcPermissionsNative -{ - public int CanCreateTempChannel; - public int CanKick; - public int CanBan; - public int CanMoveUsers; - public int CanAdminAccounts; - public int IsAdmin; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcChannelInfoNative -{ - public uint Id; - public uint ParentId; - public IntPtr Name; - public IntPtr Topic; - public int PasswordProtected; - public IntPtr Password; - public uint MaxUsers; - public uint SortOrder; - public VcAudioConfigNative Audio; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcDeviceNative -{ - public IntPtr Id; - public IntPtr Name; - public int IsDefault; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcDeviceListNative -{ - public IntPtr Items; - public nuint Count; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcChannelNative -{ - public uint Id; - public uint ParentId; - public IntPtr Name; - public IntPtr Topic; - public int PasswordProtected; - public uint MaxUsers; - public uint SortOrder; - public VcAudioConfigNative Audio; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcChannelListNative -{ - public IntPtr Items; - public nuint Count; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcUserNative -{ - public uint Id; - public IntPtr Nickname; - public int IsGuest; - public uint ChannelId; - public int SelfMicMuted; - public int SelfDeafened; - public int ServerMuted; - public int ServerDeafened; - public int VoiceSubscribed; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcUserListNative -{ - public IntPtr Items; - public nuint Count; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcStreamSummaryNative -{ - public uint StreamId; - public VcStreamKind Kind; - public IntPtr Label; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcStreamSummaryListNative -{ - public IntPtr Items; - public nuint Count; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcRemoteStreamStateNative -{ - public float Gain; - public int Muted; - public int NoiseReduction; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcAccountNative -{ - public IntPtr Username; - public int IsAdmin; - public ulong CreatedAtUnixMs; - public ulong LastLoginUnixMs; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct VcAccountListNative -{ - public IntPtr Items; - public nuint Count; -} diff --git a/clients/windows/VoiceCat.Interop/VoiceCat.Interop.csproj b/clients/windows/VoiceCat.Interop/VoiceCat.Interop.csproj deleted file mode 100644 index b07e447..0000000 --- a/clients/windows/VoiceCat.Interop/VoiceCat.Interop.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net10.0 - enable - enable - - true - VoiceCat.Interop - - - - diff --git a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs deleted file mode 100644 index b9071d7..0000000 --- a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs +++ /dev/null @@ -1,441 +0,0 @@ -using System.Runtime.InteropServices; -using System.Threading.Channels; - -// The public, safe C# surface over libvoicecat. Everything below is a thin wrapper around -// NativeMethods — see docs/architecture.md §4 ("the core owns audio; C# only orchestrates"). -namespace VoiceCat.Interop; - -public sealed class VoiceCatClient : IDisposable -{ - private readonly VoiceCatClientHandle _handle = new(); - private readonly GCHandle _selfHandle; - - // Native string buffers backing vc_config — must outlive the WHOLE client lifetime, not - // just vc_client_create(): client_name/client_version are read later, whenever connect() - // actually runs on io_thread_ (vc_client just stores the raw pointers from vc_config by - // value, it does not copy the string data). Freed in Dispose(), after vc_client_destroy - // has returned (which synchronously joins every internal thread, so nothing can still be - // reading these pointers by then). - private nint _clientNamePtr; - private nint _clientVersionPtr; - private nint _tofuStorePathPtr; - - private readonly Channel _events = - Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = true, - }); - - // on_level fires far more often than on_event and intermediate values are visually - // irrelevant — coalesce to "latest sample per stream_id" instead of queuing every one. - private readonly System.Collections.Concurrent.ConcurrentDictionary _latestLevels = new(); - - /// Raised from PumpEvents() (i.e. on whatever thread calls it — see that method's - /// doc comment) for every event, in order, never coalesced. - public event Action? EventReceived; - - /// Raised from PumpEvents() with the latest RMS level per stream_id since the - /// last pump. - public event Action? LevelChanged; - - public unsafe VoiceCatClient(string clientName, string clientVersion, - VcLogLevel logLevel = VcLogLevel.Info, string? tofuStorePath = null) - { - _selfHandle = GCHandle.Alloc(this, GCHandleType.Normal); - - _clientNamePtr = Marshal.StringToCoTaskMemUTF8(clientName); - _clientVersionPtr = Marshal.StringToCoTaskMemUTF8(clientVersion); - _tofuStorePathPtr = tofuStorePath is null ? 0 : Marshal.StringToCoTaskMemUTF8(tofuStorePath); - - var cfg = new VcConfigNative - { - ClientName = _clientNamePtr, - ClientVersion = _clientVersionPtr, - LogLevel = logLevel, - TofuStorePath = _tofuStorePathPtr, - }; - - var cb = new VcCallbacksNative - { - OnEvent = (nint)(delegate* unmanaged)&NativeCallbacks.OnEvent, - OnLevel = (nint)(delegate* unmanaged)&NativeCallbacks.OnLevel, - User = GCHandle.ToIntPtr(_selfHandle), - }; - - nint native = NativeMethods.vc_client_create(in cfg, cb); - _handle.SetHandle(native); - if (_handle.IsInvalid) - { - FreeConfigStrings(); - _selfHandle.Free(); - throw new InvalidOperationException("vc_client_create failed."); - } - } - - /// - /// Drains every event/level sample queued since the last call. Call this from a - /// System.Windows.Forms.Timer.Tick on the UI thread (~30-50ms) — this is the boundary - /// where the core's own event-delivery thread hands off to the UI thread; see - /// docs/architecture.md §3 and this project's README for why a Timer + Channel was chosen - /// over a message-only window + PostMessage. - /// - public void PumpEvents() - { - while (_events.Reader.TryRead(out var ev)) - EventReceived?.Invoke(ev); - - if (!_latestLevels.IsEmpty) - { - foreach (var (streamId, rms) in _latestLevels) - LevelChanged?.Invoke(streamId, rms); - _latestLevels.Clear(); - } - } - - internal void EnqueueEvent(VoiceCatEvent ev) - { - _events.Writer.TryWrite(ev); - } - internal void EnqueueLevel(uint streamId, float rms) => _latestLevels[streamId] = rms; - - // ── Connection & auth ──────────────────────────────────────────────────────────────── - public VcResult Connect(string host, ushort port) => - NativeMethods.vc_connect(_handle.DangerousGetHandle(), host, port); - - public VcResult Disconnect() => - NativeMethods.vc_disconnect(_handle.DangerousGetHandle()); - - public VcResult AuthenticateGuest(string nickname) => - NativeMethods.vc_authenticate_guest(_handle.DangerousGetHandle(), nickname); - - public VcResult AuthenticateUser(string username, string password) => - NativeMethods.vc_authenticate_user(_handle.DangerousGetHandle(), username, password); - - // ── TOFU server-identity gate ─────────────────────────────────────────────────────────── - public VcResult ConfirmServerIdentity(bool accept) => - NativeMethods.vc_confirm_server_identity(_handle.DangerousGetHandle(), accept ? 1 : 0); - - /// The Ed25519 identity fingerprint from ServerHello, hex-formatted — display - /// only, NOT the value the TOFU gate pins on (see VcTofuStatus's doc comment). Empty - /// string if not yet available. - public string GetServerIdentityDisplay() - { - nint c = _handle.DangerousGetHandle(); - NativeMethods.vc_get_server_identity_display(c, 0, 0, out nuint len); - if (len == 0) return string.Empty; - - nint buf = Marshal.AllocHGlobal((int)len + 1); - try - { - NativeMethods.vc_get_server_identity_display(c, buf, len + (nuint)1, out _); - return Marshal.PtrToStringUTF8(buf) ?? string.Empty; - } - finally - { - Marshal.FreeHGlobal(buf); - } - } - - // ── Channels ───────────────────────────────────────────────────────────────────────── - /// Result arrives as a VcEventType.JoinResult event, not via this return value - /// (which only reflects "request queued" — see voicecat.h's vc_join_channel doc comment). - public VcResult JoinChannel(uint channelId, string? password = null) => - NativeMethods.vc_join_channel(_handle.DangerousGetHandle(), channelId, password); - - public VcResult LeaveChannel() => - NativeMethods.vc_leave_channel(_handle.DangerousGetHandle()); - - public VcResult JoinVoice() => - NativeMethods.vc_join_voice(_handle.DangerousGetHandle()); - - public VcResult LeaveVoice() => - NativeMethods.vc_leave_voice(_handle.DangerousGetHandle()); - - public List ListChannels() - { - NativeMethods.vc_list_channels(_handle.DangerousGetHandle(), out var native); - return Marshaling.ToManaged(ref native); - } - - public List ListUsers() - { - NativeMethods.vc_list_users(_handle.DangerousGetHandle(), out var native); - return Marshaling.ToManaged(ref native); - } - - public List ListUserStreams(uint userId) - { - var r = NativeMethods.vc_list_user_streams(_handle.DangerousGetHandle(), userId, out var native); - return r == VcResult.Ok ? Marshaling.ToManaged(ref native) : new List(); - } - - // ── Local media streams ───────────────────────────────────────────────────────────────── - public (VcResult Result, uint StreamId) StartStream(VcStreamKind kind, string label) - { - nint labelPtr = Marshal.StringToCoTaskMemUTF8(label); - try - { - var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr }; - var r = NativeMethods.vc_stream_start(_handle.DangerousGetHandle(), in desc, out uint streamId); - return (r, streamId); - } - finally - { - Marshal.FreeCoTaskMem(labelPtr); - } - } - - /// - /// Like but sets external_feed = 1 so the core skips its - /// own WASAPI loopback. The caller is responsible for feeding PCM via - /// . Used by the Windows per-app capture path. - /// - public (VcResult Result, uint StreamId) StartStreamExternalFeed(VcStreamKind kind, string label) - { - nint labelPtr = Marshal.StringToCoTaskMemUTF8(label); - try - { - var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr, ExternalFeed = 1 }; - var r = NativeMethods.vc_stream_start(_handle.DangerousGetHandle(), in desc, out uint streamId); - return (r, streamId); - } - finally - { - Marshal.FreeCoTaskMem(labelPtr); - } - } - - public VcResult StopStream(uint streamId) => - NativeMethods.vc_stream_stop(_handle.DangerousGetHandle(), streamId); - - public VcResult SetInputDevice(uint streamId, string? deviceId) => - NativeMethods.vc_set_input_device(_handle.DangerousGetHandle(), streamId, deviceId); - - /// Sets the mic capture channel count (1 = mono, 2 = stereo) for the given stream. - /// Applied when the capture device next (re)starts — call before Join Voice, or pair with an - /// audio restart to take effect live. Real stereo only reaches the wire on a stereo channel; - /// the core folds a stereo mic to mono on a mono channel. - public VcResult SetCaptureChannels(uint streamId, uint channels) => - NativeMethods.vc_set_capture_channels(_handle.DangerousGetHandle(), streamId, channels); - - /// Uninitializes and re-initializes the capture and playback devices on a running - /// engine, applying pending changes (e.g. capture channel count) that only take effect on a - /// device restart. No-op if audio isn't running. - public VcResult AudioRestart() => - NativeMethods.vc_audio_restart(_handle.DangerousGetHandle()); - - public VcResult SetInputMode(VcInputMode mode) => - NativeMethods.vc_set_input_mode(_handle.DangerousGetHandle(), mode); - - public VcResult SetVadThreshold(float threshold) => - NativeMethods.vc_set_vad_threshold(_handle.DangerousGetHandle(), threshold); - - public VcResult SetPushToTalk(bool active) => - NativeMethods.vc_set_push_to_talk(_handle.DangerousGetHandle(), active ? 1 : 0); - - public VcResult SetSelfMute(bool micMuted, bool deafened) => - NativeMethods.vc_set_self_mute(_handle.DangerousGetHandle(), micMuted ? 1 : 0, deafened ? 1 : 0); - - public VcResult SetOutputVolume(float gain) => - NativeMethods.vc_set_output_volume(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain); - - /// Send-side microphone input gain, applied to captured MIC PCM before VAD/encode. - /// 0.0 = silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). LOCAL only. - public VcResult SetInputGain(float gain) => - NativeMethods.vc_set_input_gain(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain); - - /// Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before - /// the input gain and VAD/PTT gate, so everyone hears the cleaned signal. MIC stream only, - /// mono only; always LOCAL — no protocol traffic. Independent of per-listener receive-side - /// NR in . - public VcResult SetInputNoiseReduction(bool enable) => - NativeMethods.vc_set_input_noise_reduction(_handle.DangerousGetHandle(), enable ? 1 : 0); - - public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) => - NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain, - muted ? 1 : 0, noiseReduction ? 1 : 0); - - public (VcResult Result, RemoteStreamState? State) GetRemoteStream(uint userId, uint streamId) - { - var r = NativeMethods.vc_get_remote_stream(_handle.DangerousGetHandle(), userId, - streamId, out var native); - return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null); - } - - public (VcResult Result, AudioConfigInfo? Config) GetStreamAudioConfig(uint userId, uint streamId) - { - var r = NativeMethods.vc_get_stream_audio_config(_handle.DangerousGetHandle(), userId, - streamId, out var native); - return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null); - } - - // ── External PCM feed / tap ───────────────────────────────────────────────────────── - public unsafe VcResult StreamFeedPcm(uint streamId, ReadOnlySpan pcm, - int samplesPerChannel, uint channels) - { - fixed (short* p = pcm) - return NativeMethods.vc_stream_feed_pcm(_handle.DangerousGetHandle(), - streamId, p, (nuint)samplesPerChannel, channels); - } - - // Pass Marshal.GetFunctionPointerForDelegate(cb) for a managed delegate, or - // IntPtr.Zero to disable. Keep the delegate alive for the lifetime of the registration. - public VcResult SetPcmSink(nint cb, IntPtr user) => - NativeMethods.vc_set_pcm_sink(_handle.DangerousGetHandle(), cb, user); - - // ── Moderation & admin ─────────────────────────────────────────────────────────────── - public VcResult KickUser(uint userId, string? reason = null) => - NativeMethods.vc_kick_user(_handle.DangerousGetHandle(), userId, reason); - - public VcResult BanUser(uint userId, string? reason = null, ulong expiresUnixMs = 0) => - NativeMethods.vc_ban_user(_handle.DangerousGetHandle(), userId, reason, expiresUnixMs); - - public VcResult SetPermission(uint userId, PermissionsInfo perms) - { - var native = new VcPermissionsNative - { - CanCreateTempChannel = perms.CanCreateTempChannel ? 1 : 0, - CanKick = perms.CanKick ? 1 : 0, - CanBan = perms.CanBan ? 1 : 0, - CanMoveUsers = perms.CanMoveUsers ? 1 : 0, - CanAdminAccounts = perms.CanAdminAccounts ? 1 : 0, - IsAdmin = perms.IsAdmin ? 1 : 0, - }; - return NativeMethods.vc_set_permission(_handle.DangerousGetHandle(), userId, in native); - } - - public VcResult SetServerMute(uint userId, bool muted, bool deafened) => - NativeMethods.vc_set_server_mute(_handle.DangerousGetHandle(), userId, - muted ? 1 : 0, deafened ? 1 : 0); - - public VcResult MoveUser(uint userId, uint channelId) => - NativeMethods.vc_move_user(_handle.DangerousGetHandle(), userId, channelId); - - public VcResult CreateChannel(ChannelEditInfo info) - { - var native = ToNativeChannelInfo(info); - try - { - return NativeMethods.vc_create_channel(_handle.DangerousGetHandle(), in native); - } - finally - { - FreeChannelInfoStrings(native); - } - } - - public VcResult EditChannel(ChannelEditInfo info) - { - var native = ToNativeChannelInfo(info); - try - { - return NativeMethods.vc_edit_channel(_handle.DangerousGetHandle(), in native); - } - finally - { - FreeChannelInfoStrings(native); - } - } - - public VcResult DeleteChannel(uint channelId) => - NativeMethods.vc_delete_channel(_handle.DangerousGetHandle(), channelId); - - public VcResult CreateAccount(string username, string password) => - NativeMethods.vc_create_account(_handle.DangerousGetHandle(), username, password); - - public VcResult ResetPassword(string username, string newPassword) => - NativeMethods.vc_reset_password(_handle.DangerousGetHandle(), username, newPassword); - - public VcResult DeleteAccount(string username) => - NativeMethods.vc_delete_account(_handle.DangerousGetHandle(), username); - - public VcResult RequestAccountList() => - NativeMethods.vc_list_accounts(_handle.DangerousGetHandle()); - - public List ListAccounts() - { - NativeMethods.vc_get_account_list(_handle.DangerousGetHandle(), out var native); - return Marshaling.ToManaged(ref native); - } - - public PermissionsInfo GetPermissions() - { - NativeMethods.vc_get_permissions(_handle.DangerousGetHandle(), out var native); - return Marshaling.ToManaged(in native); - } - - private static VcChannelInfoNative ToNativeChannelInfo(ChannelEditInfo info) - { - return new VcChannelInfoNative - { - Id = info.Id, - ParentId = info.ParentId, - Name = Marshal.StringToCoTaskMemUTF8(info.Name), - Topic = Marshal.StringToCoTaskMemUTF8(info.Topic), - PasswordProtected = info.PasswordProtected ? 1 : 0, - Password = string.IsNullOrEmpty(info.Password) - ? 0 - : Marshal.StringToCoTaskMemUTF8(info.Password), - MaxUsers = info.MaxUsers, - SortOrder = info.SortOrder, - Audio = new VcAudioConfigNative - { - Codec = info.Audio.Codec, - Mode = info.Audio.Stereo ? 1u : 0u, - SampleRate = info.Audio.SampleRate, - BitrateBps = info.Audio.BitrateBps, - FrameMs = info.Audio.FrameMs, - Application = info.Audio.Application, - Fec = info.Audio.Fec ? 1 : 0, - ExpectedPacketLoss = info.Audio.ExpectedPacketLoss, - Dtx = info.Audio.Dtx ? 1 : 0, - Complexity = info.Audio.Complexity, - Dred = info.Audio.Dred ? 1 : 0, - } - }; - } - - private static void FreeChannelInfoStrings(VcChannelInfoNative native) - { - if (native.Name != 0) Marshal.FreeCoTaskMem(native.Name); - if (native.Topic != 0) Marshal.FreeCoTaskMem(native.Topic); - if (native.Password != 0) Marshal.FreeCoTaskMem(native.Password); - } - - // ── Text ───────────────────────────────────────────────────────────────────────────── - public VcResult SendText(VcTextScope scope, uint targetId, string utf8) => - NativeMethods.vc_send_text(_handle.DangerousGetHandle(), scope, targetId, utf8); - - // ── Device enumeration (works pre-connect) ────────────────────────────────────────────── - public List ListDevices(VcDeviceKind kind) - { - NativeMethods.vc_list_devices(_handle.DangerousGetHandle(), kind, out var native); - return Marshaling.ToManaged(ref native); - } - - // ── Lifecycle ──────────────────────────────────────────────────────────────────────── - // NativeMethods.vc_version_string/vc_result_string return raw pointers to static, never- - // freed string literals — see NativeMethods.cs's comment for why we don't let LibraryImport - // auto-marshal these as `string` (it would try to free a static literal and corrupt the - // heap). Marshal.PtrToStringUTF8 just reads; it never frees. - public static string VersionString => - Marshal.PtrToStringUTF8(NativeMethods.vc_version_string()) ?? string.Empty; - - public static string ResultString(VcResult code) => - Marshal.PtrToStringUTF8(NativeMethods.vc_result_string(code)) ?? string.Empty; - - public void Dispose() - { - _handle.Dispose(); // runs vc_client_destroy (joins every internal thread) synchronously - FreeConfigStrings(); - if (_selfHandle.IsAllocated) _selfHandle.Free(); - } - - private void FreeConfigStrings() - { - if (_clientNamePtr != 0) { Marshal.FreeCoTaskMem(_clientNamePtr); _clientNamePtr = 0; } - if (_clientVersionPtr != 0) { Marshal.FreeCoTaskMem(_clientVersionPtr); _clientVersionPtr = 0; } - if (_tofuStorePathPtr != 0) { Marshal.FreeCoTaskMem(_tofuStorePathPtr); _tofuStorePathPtr = 0; } - } -} diff --git a/clients/windows/VoiceCat.Interop/VoiceCatClientHandle.cs b/clients/windows/VoiceCat.Interop/VoiceCatClientHandle.cs deleted file mode 100644 index e112345..0000000 --- a/clients/windows/VoiceCat.Interop/VoiceCatClientHandle.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.Win32.SafeHandles; - -// Standard SafeHandle pattern for vc_client* — guarantees vc_client_destroy runs even on an -// unhandled exception or a finalizer pass, which a bare `nint` field would not. -namespace VoiceCat.Interop; - -internal sealed class VoiceCatClientHandle : SafeHandleZeroOrMinusOneIsInvalid -{ - public VoiceCatClientHandle() : base(ownsHandle: true) { } - - public new void SetHandle(nint handle) => base.SetHandle(handle); - - protected override bool ReleaseHandle() - { - NativeMethods.vc_client_destroy(handle); - return true; - } -} diff --git a/clients/windows/VoiceCat.Interop/VoiceCatEvent.cs b/clients/windows/VoiceCat.Interop/VoiceCatEvent.cs deleted file mode 100644 index 93c4fab..0000000 --- a/clients/windows/VoiceCat.Interop/VoiceCatEvent.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Runtime.InteropServices; - -namespace VoiceCat.Interop; - -/// -/// Managed copy of a vc_event — safe to hold/queue past the native callback's return (unlike -/// VcEventNative, whose Text pointer is only valid during the callback). -/// -public sealed record VoiceCatEvent( - VcEventType Type, - VcConnectionState ConnectionState, - VcResult Result, - uint UserId, - uint ChannelId, - uint StreamId, - VcTextScope TextScope, - uint U32a, - string? Text, - ulong TimestampUnixMs) -{ - internal static VoiceCatEvent FromNative(in VcEventNative ev) => new( - ev.Type, - ev.ConnectionState, - (VcResult)ev.Result, - ev.UserId, - ev.ChannelId, - ev.StreamId, - ev.TextScope, - ev.U32a, - ev.Text == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ev.Text), - ev.TimestampUnixMs); -} diff --git a/clients/windows/VoiceCat.Managed/Administration.cs b/clients/windows/VoiceCat.Windows/Administration.cs similarity index 99% rename from clients/windows/VoiceCat.Managed/Administration.cs rename to clients/windows/VoiceCat.Windows/Administration.cs index 281e04e..95e0bd3 100644 --- a/clients/windows/VoiceCat.Managed/Administration.cs +++ b/clients/windows/VoiceCat.Windows/Administration.cs @@ -1,6 +1,6 @@ using Voicecat.V1; -namespace VoiceCat.Interop; +namespace VoiceCat.Windows; public sealed partial class VoiceCatClient { diff --git a/clients/windows/VoiceCat.Managed/AudioControls.cs b/clients/windows/VoiceCat.Windows/AudioControls.cs similarity index 99% rename from clients/windows/VoiceCat.Managed/AudioControls.cs rename to clients/windows/VoiceCat.Windows/AudioControls.cs index 6c107f5..9990c4f 100644 --- a/clients/windows/VoiceCat.Managed/AudioControls.cs +++ b/clients/windows/VoiceCat.Windows/AudioControls.cs @@ -1,7 +1,7 @@ using VoiceCat.Audio; using Voicecat.V1; -namespace VoiceCat.Interop; +namespace VoiceCat.Windows; public sealed partial class VoiceCatClient { diff --git a/clients/windows/VoiceCat.Interop/Enums.cs b/clients/windows/VoiceCat.Windows/Enums.cs similarity index 90% rename from clients/windows/VoiceCat.Interop/Enums.cs rename to clients/windows/VoiceCat.Windows/Enums.cs index e9817e2..f9745de 100644 --- a/clients/windows/VoiceCat.Interop/Enums.cs +++ b/clients/windows/VoiceCat.Windows/Enums.cs @@ -1,7 +1,5 @@ -// Mirrors of voicecat.h's enums. Keep these in lockstep with core/include/voicecat.h — -// values are append-only per the C ABI's house rule, so it's safe to add new members at the -// end here too, but never renumber/remove existing ones. -namespace VoiceCat.Interop; +// Windows-facing state and command values used by the WinForms application. +namespace VoiceCat.Windows; public enum VcResult { diff --git a/clients/windows/VoiceCat.Interop/Models.cs b/clients/windows/VoiceCat.Windows/Models.cs similarity index 85% rename from clients/windows/VoiceCat.Interop/Models.cs rename to clients/windows/VoiceCat.Windows/Models.cs index 39e2b08..9a5368d 100644 --- a/clients/windows/VoiceCat.Interop/Models.cs +++ b/clients/windows/VoiceCat.Windows/Models.cs @@ -1,7 +1,5 @@ -// Plain managed record types — what survives past the native struct/free-list lifetime -// (Marshaling.cs converts the native *Native structs into these and immediately frees the -// native list). Nothing here holds an IntPtr. -namespace VoiceCat.Interop; +// Windows-facing records used by the WinForms application. +namespace VoiceCat.Windows; public sealed record ChannelInfo( uint Id, diff --git a/clients/windows/VoiceCat.Managed/VoiceCat.Managed.csproj b/clients/windows/VoiceCat.Windows/VoiceCat.Windows.csproj similarity index 59% rename from clients/windows/VoiceCat.Managed/VoiceCat.Managed.csproj rename to clients/windows/VoiceCat.Windows/VoiceCat.Windows.csproj index 065ca25..21fe6c3 100644 --- a/clients/windows/VoiceCat.Managed/VoiceCat.Managed.csproj +++ b/clients/windows/VoiceCat.Windows/VoiceCat.Windows.csproj @@ -7,8 +7,6 @@ true - - - + diff --git a/clients/windows/VoiceCat.Managed/VoiceCatClient.cs b/clients/windows/VoiceCat.Windows/VoiceCatClient.cs similarity index 99% rename from clients/windows/VoiceCat.Managed/VoiceCatClient.cs rename to clients/windows/VoiceCat.Windows/VoiceCatClient.cs index c36f2e6..5881173 100644 --- a/clients/windows/VoiceCat.Managed/VoiceCatClient.cs +++ b/clients/windows/VoiceCat.Windows/VoiceCatClient.cs @@ -7,7 +7,7 @@ using VoiceCat.Crypto; using Voicecat.V1; using CoreClient = VoiceCat.Core.VoiceCatClient; -namespace VoiceCat.Interop; +namespace VoiceCat.Windows; // Preserves the shipped WinForms call/event surface while its implementation moves to // the async managed core. Events still reach controls only through PumpEvents on the UI thread. diff --git a/clients/windows/VoiceCat.Managed/VoiceCatEvent.cs b/clients/windows/VoiceCat.Windows/VoiceCatEvent.cs similarity index 94% rename from clients/windows/VoiceCat.Managed/VoiceCatEvent.cs rename to clients/windows/VoiceCat.Windows/VoiceCatEvent.cs index e6f04bf..99a12e7 100644 --- a/clients/windows/VoiceCat.Managed/VoiceCatEvent.cs +++ b/clients/windows/VoiceCat.Windows/VoiceCatEvent.cs @@ -1,4 +1,4 @@ -namespace VoiceCat.Interop; +namespace VoiceCat.Windows; // Compatibility event consumed by WinForms. The managed core owns protocol and audio; // this assembly contains no libvoicecat bindings or native handles. diff --git a/clients/windows/VoiceCat.Managed/packages.lock.json b/clients/windows/VoiceCat.Windows/packages.lock.json similarity index 100% rename from clients/windows/VoiceCat.Managed/packages.lock.json rename to clients/windows/VoiceCat.Windows/packages.lock.json diff --git a/clients/windows/VoiceCat.Managed/packages.publish.win-x64.lock.json b/clients/windows/VoiceCat.Windows/packages.publish.win-x64.lock.json similarity index 100% rename from clients/windows/VoiceCat.Managed/packages.publish.win-x64.lock.json rename to clients/windows/VoiceCat.Windows/packages.publish.win-x64.lock.json diff --git a/clients/windows/VoiceCat.slnx b/clients/windows/VoiceCat.slnx index 0c7c6d5..50bc3a9 100644 --- a/clients/windows/VoiceCat.slnx +++ b/clients/windows/VoiceCat.slnx @@ -1,12 +1,12 @@ - - - - - - + + + + + + - + diff --git a/clients/windows/publish-client.ps1 b/clients/windows/publish-client.ps1 index b85e38e..387cf0a 100644 --- a/clients/windows/publish-client.ps1 +++ b/clients/windows/publish-client.ps1 @@ -4,12 +4,11 @@ param( ) $ErrorActionPreference = "Stop" $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path -if ([string]::IsNullOrWhiteSpace($Output)) { $Output = "$repoRoot/dotnet/artifacts/client/$RuntimeIdentifier" } +if ([string]::IsNullOrWhiteSpace($Output)) { $Output = "$repoRoot/artifacts/client/$RuntimeIdentifier" } dotnet publish "$PSScriptRoot/VoiceCat.App/VoiceCat.App.csproj" -c Release -r $RuntimeIdentifier --self-contained true ` -p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=false ` -p:RestorePackagesWithLockFile=true -p:RestoreLockedMode=true ` "-p:NuGetLockFilePath=packages.publish.$RuntimeIdentifier.lock.json" -o $Output if ($LASTEXITCODE -ne 0) { throw "Managed Windows client publish failed." } if (-not (Test-Path "$Output/voicecat_media.dll")) { throw "Managed media shim was not published." } -if (Test-Path "$Output/voicecat.dll") { throw "Legacy VoiceCat native core must not be published." } Write-Host "Published managed Windows client to $Output" diff --git a/cmake/toolchains/ios-device.cmake b/cmake/toolchains/ios-device.cmake deleted file mode 100644 index 98e8218..0000000 --- a/cmake/toolchains/ios-device.cmake +++ /dev/null @@ -1,8 +0,0 @@ -# Chainload toolchain for iOS device cross-compilation (arm64-ios vcpkg triplet). -# Loaded by vcpkg when building all dependencies for the arm64-ios target, ensuring -# every dep is compiled against the iPhone OS SDK (not the macOS SDK). -set(CMAKE_SYSTEM_NAME iOS) -set(CMAKE_SYSTEM_PROCESSOR arm64) -set(CMAKE_OSX_ARCHITECTURES arm64) -set(CMAKE_OSX_SYSROOT iphoneos) -set(CMAKE_OSX_DEPLOYMENT_TARGET 17.0) diff --git a/cmake/toolchains/ios-simulator.cmake b/cmake/toolchains/ios-simulator.cmake deleted file mode 100644 index d4eda77..0000000 --- a/cmake/toolchains/ios-simulator.cmake +++ /dev/null @@ -1,8 +0,0 @@ -# Chainload toolchain for iOS simulator cross-compilation (arm64-ios-simulator vcpkg triplet). -# Loaded by vcpkg when building all dependencies for the arm64-ios-simulator target, ensuring -# every dep is compiled against the iphonesimulator SDK (platform tag IOSSIMULATOR, not IOS). -set(CMAKE_SYSTEM_NAME iOS) -set(CMAKE_SYSTEM_PROCESSOR arm64) -set(CMAKE_OSX_ARCHITECTURES arm64) -set(CMAKE_OSX_SYSROOT iphonesimulator) -set(CMAKE_OSX_DEPLOYMENT_TARGET 17.0) diff --git a/cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake b/cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake deleted file mode 100644 index c404304..0000000 --- a/cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake +++ /dev/null @@ -1,13 +0,0 @@ -set(VCPKG_TARGET_ARCHITECTURE arm64) -set(VCPKG_CRT_LINKAGE dynamic) -set(VCPKG_LIBRARY_LINKAGE static) -set(VCPKG_CMAKE_SYSTEM_NAME iOS) -# Release-only: debug iOS binaries are never shipped and skipping them halves build time. -set(VCPKG_BUILD_TYPE release) -# Override the autoconf --host triple (same rationale as arm64-ios.cmake — prevents autoconf from -# treating cross-compilation as a native build and trying to run arm64-ios binaries on macOS). -set(VCPKG_MAKE_BUILD_TRIPLET "--host=aarch64-apple-ios17.0-simulator") -# Chainload toolchain: forces every vcpkg dep to compile against the iphonesimulator SDK so all -# objects carry the platform IOSSIMULATOR tag (not IOS or MACOS). Critical for xcodebuild's -# -create-xcframework which rejects fat libs that mix platform tags. -set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE "${CMAKE_CURRENT_LIST_DIR}/../../toolchains/ios-simulator.cmake") diff --git a/cmake/vcpkg-overlays/triplets/arm64-ios.cmake b/cmake/vcpkg-overlays/triplets/arm64-ios.cmake deleted file mode 100644 index c4fa4b9..0000000 --- a/cmake/vcpkg-overlays/triplets/arm64-ios.cmake +++ /dev/null @@ -1,14 +0,0 @@ -set(VCPKG_TARGET_ARCHITECTURE arm64) -set(VCPKG_CRT_LINKAGE dynamic) -set(VCPKG_LIBRARY_LINKAGE static) -set(VCPKG_CMAKE_SYSTEM_NAME iOS) -# Release-only: debug iOS binaries are never shipped and skipping them halves build time. -set(VCPKG_BUILD_TYPE release) -# Override the autoconf --host triple. Without this, vcpkg uses arm64-apple-darwin (same as the -# macOS build machine), which makes autoconf think it's a native build and try to run compiled -# programs — those are iOS arm64 binaries and can't execute on macOS. An ios-suffixed triple -# signals cross-compilation and disables all run-time configure checks. -set(VCPKG_MAKE_BUILD_TRIPLET "--host=aarch64-apple-ios17.0") -# Chainload toolchain: forces every vcpkg dep to compile against the iphoneos SDK so all objects -# carry the platform IOS tag (not MACOS). Without this the fat library mixes platforms. -set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE "${CMAKE_CURRENT_LIST_DIR}/../../toolchains/ios-device.cmake") diff --git a/cmake/vcpkg-overlays/triplets/arm64-linux.cmake b/cmake/vcpkg-overlays/triplets/arm64-linux.cmake deleted file mode 100644 index a8f9228..0000000 --- a/cmake/vcpkg-overlays/triplets/arm64-linux.cmake +++ /dev/null @@ -1,7 +0,0 @@ -set(VCPKG_TARGET_ARCHITECTURE arm64) -set(VCPKG_CRT_LINKAGE dynamic) -set(VCPKG_LIBRARY_LINKAGE static) -set(VCPKG_CMAKE_SYSTEM_NAME Linux) -# Only build release configurations — halves buildtree disk usage. -# The server always ships a Release build; debug deps are never needed. -set(VCPKG_BUILD_TYPE release) diff --git a/cmake/vcpkg-overlays/triplets/x64-linux.cmake b/cmake/vcpkg-overlays/triplets/x64-linux.cmake deleted file mode 100644 index 9c25e7f..0000000 --- a/cmake/vcpkg-overlays/triplets/x64-linux.cmake +++ /dev/null @@ -1,7 +0,0 @@ -set(VCPKG_TARGET_ARCHITECTURE x64) -set(VCPKG_CRT_LINKAGE dynamic) -set(VCPKG_LIBRARY_LINKAGE static) -set(VCPKG_CMAKE_SYSTEM_NAME Linux) -# Only build release configurations — halves buildtree disk usage. -# The server always ships a Release build; debug deps are never needed. -set(VCPKG_BUILD_TYPE release) diff --git a/cmake/voicecat-toolchain.cmake b/cmake/voicecat-toolchain.cmake deleted file mode 100644 index afce5dd..0000000 --- a/cmake/voicecat-toolchain.cmake +++ /dev/null @@ -1,100 +0,0 @@ -# cmake/voicecat-toolchain.cmake — vcpkg toolchain wrapper with auto-triplet. -# -# Wraps vcpkg's toolchain to auto-resolve VCPKG_HOST_TRIPLET and VCPKG_TARGET_TRIPLET -# from the host platform, so the main presets (dev, release, server-release) build on -# Windows/MinGW, Linux, and macOS without per-OS preset variants. -# -# Triplet mapping (auto): -# Windows → x64-mingw-static (the project's toolchain is MSYS2/UCRT64 — see -# clients/windows/README.md; MSVC users must set -# VCPKG_TARGET_TRIPLET=x64-windows explicitly) -# Linux x64 → x64-linux -# Linux arm64 → arm64-linux -# macOS arm64 → arm64-osx (Apple Silicon) -# macOS x64 → x64-osx (Intel) -# -# Cross-compile presets (apple-ios, apple-ios-sim) set VCPKG_TARGET_TRIPLET explicitly -# in their cacheVariables; the DEFINED guards below preserve those. VCPKG_HOST_TRIPLET -# is always the host's (e.g. arm64-osx when cross-compiling iOS on Apple Silicon). -# -# Resolution order: an explicit VCPKG_ROOT env var always wins (for developers pointing at -# their own external vcpkg checkout); otherwise this falls back to the bundled submodule at -# /vcpkg (`git submodule update --init vcpkg`). - -# ── Host triplet (the platform running vcpkg / the build) ───────────────────── -if(NOT DEFINED VCPKG_HOST_TRIPLET) - if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") - set(_voicecat_host "x64-mingw-static") - elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") - if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") - set(_voicecat_host "arm64-linux") - else() - set(_voicecat_host "x64-linux") - endif() - elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") - if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") - set(_voicecat_host "arm64-osx") - else() - set(_voicecat_host "x64-osx") - endif() - endif() - if(_voicecat_host) - set(VCPKG_HOST_TRIPLET "${_voicecat_host}" CACHE STRING "vcpkg host triplet (auto-resolved)") - endif() - unset(_voicecat_host) -endif() - -# ── Target triplet (the platform being built for) ───────────────────────────── -# Auto-resolve only when not explicitly set. Cross-compile presets (apple-ios, -# apple-ios-sim) set VCPKG_TARGET_TRIPLET in their cacheVariables; the DEFINED -# guard preserves those so they win over the auto-detection. -if(NOT DEFINED VCPKG_TARGET_TRIPLET) - if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") - set(_voicecat_target "x64-mingw-static") - elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") - if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") - set(_voicecat_target "arm64-linux") - else() - set(_voicecat_target "x64-linux") - endif() - elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") - if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") - set(_voicecat_target "arm64-osx") - else() - set(_voicecat_target "x64-osx") - endif() - endif() - if(_voicecat_target) - set(VCPKG_TARGET_TRIPLET "${_voicecat_target}" CACHE STRING "vcpkg target triplet (auto-resolved)") - endif() - unset(_voicecat_target) -endif() - -# ── Overlay triplets — project-local overrides take precedence ──────────────── -# Enables custom triplets (e.g. release-only x64-linux/arm64-linux, iOS slices) -# without needing to fork vcpkg's built-in ones. Cross-compile presets that -# already set VCPKG_OVERLAY_TRIPLETS explicitly (apple-ios, apple-ios-sim) keep -# their own value via the DEFINED guard. -if(NOT DEFINED VCPKG_OVERLAY_TRIPLETS) - set(VCPKG_OVERLAY_TRIPLETS "${CMAKE_CURRENT_LIST_DIR}/vcpkg-overlays/triplets" - CACHE STRING "vcpkg overlay triplets directory") -endif() - -# ── Resolve vcpkg root ────────────────────────────────────────────────────────── -if(DEFINED ENV{VCPKG_ROOT} AND NOT "$ENV{VCPKG_ROOT}" STREQUAL "") - set(_voicecat_vcpkg_root "$ENV{VCPKG_ROOT}") -else() - set(_voicecat_vcpkg_root "${CMAKE_CURRENT_LIST_DIR}/../vcpkg") -endif() - -if(NOT EXISTS "${_voicecat_vcpkg_root}/scripts/buildsystems/vcpkg.cmake") - message(FATAL_ERROR - "vcpkg not found at '${_voicecat_vcpkg_root}'.\n" - "Either init the bundled submodule:\n" - " git submodule update --init vcpkg\n" - "or point VCPKG_ROOT at an external vcpkg checkout.") -endif() - -# ── Hand off to the real vcpkg toolchain ────────────────────────────────────── -include("${_voicecat_vcpkg_root}/scripts/buildsystems/vcpkg.cmake") -unset(_voicecat_vcpkg_root) diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt deleted file mode 100644 index 6af6f31..0000000 --- a/core/CMakeLists.txt +++ /dev/null @@ -1,126 +0,0 @@ -# libvoicecat — the shared C++ core -# Sources are globbed so adding a stub under src// needs no CMake edit. -file(GLOB_RECURSE VOICECAT_SOURCES CONFIGURE_DEPENDS - "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp") - -if(VOICECAT_BUILD_SHARED) - add_library(voicecat SHARED ${VOICECAT_SOURCES}) - if(WIN32 AND MINGW) - # The C# client only ships voicecat.dll itself, no MinGW runtime DLLs alongside - # it. x64-mingw-static only statically links vcpkg's OWN library deps (protobuf, - # sodium, mbedTLS, ...); the GCC/MinGW runtime stays dynamic by default - # (libgcc_s_seh-1.dll/libwinpthread-1.dll/libstdc++-6.dll — confirmed via `objdump -p` - # on the existing vccli.exe). These flags are the standard fully-static-MinGW - # recipe. Verify after building (see clients/windows/README.md): - # objdump -p build/windows-client/bin/voicecat.dll | grep "DLL Name" - # should show only Windows system DLLs. - target_link_options(voicecat PRIVATE - -static-libgcc -static-libstdc++ -static -lwinpthread) - endif() - if(WIN32) - # CMake's default SHARED naming on MinGW adds a "lib" prefix (libvoicecat.dll) — - # drop it so the output is exactly voicecat.dll, matching the C ABI/library name the - # C# [LibraryImport] surface and docs use everywhere else. - set_target_properties(voicecat PROPERTIES PREFIX "") - endif() -else() - add_library(voicecat STATIC ${VOICECAT_SOURCES}) - # Static consumers must see VC_API as empty (no dllimport). - target_compile_definitions(voicecat PUBLIC VOICECAT_STATIC) -endif() -add_library(voicecat::voicecat ALIAS voicecat) - -target_include_directories(voicecat - PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include - PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) - -target_compile_definitions(voicecat PRIVATE VOICECAT_BUILDING) -target_compile_features(voicecat PUBLIC cxx_std_20) - -set_target_properties(voicecat PROPERTIES - C_VISIBILITY_PRESET hidden - CXX_VISIBILITY_PRESET hidden - VISIBILITY_INLINES_HIDDEN ON) - -find_package(protobuf CONFIG REQUIRED) - find_package(unofficial-sodium CONFIG REQUIRED) - find_package(MbedTLS CONFIG REQUIRED) - find_package(asio CONFIG REQUIRED) - find_package(unofficial-sqlite3 CONFIG REQUIRED) - find_package(spdlog CONFIG REQUIRED) - find_package(Opus CONFIG REQUIRED) - # miniaudio is header-only; vcpkg does not install a CMake config for it. - find_path(MINIAUDIO_INCLUDE_DIR "miniaudio.h" REQUIRED) - - # Generate C++ from voicecat.proto into the build tree. - protobuf_generate( - TARGET voicecat - PROTOS ../proto/voicecat.proto - LANGUAGE cpp - IMPORT_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/../proto - PROTOC_OUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated/proto) - # Generated .pb.h files are included by protocol/envelope.h (consumed by tests and server), - # so the generated dir and protobuf itself must be PUBLIC. - target_include_directories(voicecat PUBLIC ${CMAKE_CURRENT_BINARY_DIR}/generated) - - target_link_libraries(voicecat - PUBLIC protobuf::libprotobuf - PRIVATE unofficial-sodium::sodium - MbedTLS::mbedtls MbedTLS::mbedcrypto MbedTLS::mbedx509 - asio::asio unofficial::sqlite3::sqlite3 spdlog::spdlog - Opus::opus) - - target_include_directories(voicecat PRIVATE ${MINIAUDIO_INCLUDE_DIR}) - - target_compile_definitions(voicecat PUBLIC VOICECAT_HAS_OPUS VOICECAT_HAS_AUDIO) - - # ── RNNoise vendored noise-suppression DSP (native/rnnoise, BSD-3 + CC0). ────────── - # The real backend behind ApmProcessor . Built as a standalone C - # static lib with NO run-time CPU dispatch (RTCD off): portable scalar path on x86, - # auto-NEON on arm64. -DDISABLE_DEBUG_FLOAT selects the int8-quantized weights that match our - # shrunk model (third_party/README.md). The vcpkg port is !windows !arm, so we vendor it. - set(RNNOISE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../native/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) - set_target_properties(rnnoise PROPERTIES - POSITION_INDEPENDENT_CODE ON # libvoicecat may be built SHARED (windows-client/apple) - C_VISIBILITY_PRESET hidden) - if(CMAKE_SYSTEM_NAME STREQUAL "iOS") - # rnnoise is plain C; keep it compiling as C even though audio_engine.cpp is OBJCXX. - set_source_files_properties(${RNNOISE_DIR}/src/rnnoise_data.c PROPERTIES LANGUAGE C) - endif() - - target_link_libraries(voicecat PRIVATE rnnoise) - target_compile_definitions(voicecat PRIVATE VOICECAT_HAS_NS) - - # On iOS, miniaudio's AVFoundation backend includes Objective-C headers (AVFoundation.h - # Foundation.h). Compiling those as plain C++ fails; setting LANGUAGE OBJCXX for - # audio_engine.cpp (the only file that includes miniaudio.h directly) fixes this. - if(CMAKE_SYSTEM_NAME STREQUAL "iOS") - set_source_files_properties( - ${CMAKE_CURRENT_SOURCE_DIR}/src/audio/audio_engine.cpp - PROPERTIES LANGUAGE OBJCXX - ) - endif() - - if(WIN32) - # AcceptEx / GetAcceptExSockaddrs live in mswsock; ws2_32 covers the base Winsock API. - target_link_libraries(voicecat PRIVATE ws2_32 mswsock) - - target_compile_definitions(voicecat PUBLIC VOICECAT_HAS_LOOPBACK) - endif() - - target_compile_definitions(voicecat PUBLIC VOICECAT_HAS_NET) diff --git a/core/include/voicecat.h b/core/include/voicecat.h deleted file mode 100644 index ee33f7f..0000000 --- a/core/include/voicecat.h +++ /dev/null @@ -1,586 +0,0 @@ -/* - * voicecat.h — the C ABI for libvoicecat. - * -*/ -#ifndef VOICECAT_H -#define VOICECAT_H - -#include -#include - -#if defined(__cplusplus) -extern "C" { -#endif - - -#if defined(VOICECAT_STATIC) -#define VC_API -#elif defined(_WIN32) -#if defined(VOICECAT_BUILDING) -#define VC_API __declspec(dllexport) -#else -#define VC_API __declspec(dllimport) -#endif -#else -#if defined(VOICECAT_BUILDING) -#define VC_API __attribute__((visibility("default"))) -#else -#define VC_API -#endif -#endif - - -#define VOICECAT_VERSION_MAJOR 0 -#define VOICECAT_VERSION_MINOR 0 -#define VOICECAT_VERSION_PATCH 2 - -#define VOICECAT_PROTOCOL_VERSION 2 - - -typedef enum vc_result { - VC_OK = 0, - VC_ERR_NOT_IMPLEMENTED = 1, - VC_ERR_INVALID_ARG = 2, - VC_ERR_NOT_CONNECTED = 3, - VC_ERR_ALREADY = 4, - VC_ERR_AUTH_FAILED = 5, - VC_ERR_PERMISSION_DENIED = 6, - VC_ERR_TIMEOUT = 7, - VC_ERR_IO = 8, - VC_ERR_PROTOCOL = 9, - VC_ERR_CRYPTO = 10, - VC_ERR_AUDIO = 11, - VC_ERR_INTERNAL = 12, -} vc_result; - -typedef enum vc_log_level { - VC_LOG_TRACE = 0, - VC_LOG_DEBUG = 1, - VC_LOG_INFO = 2, - VC_LOG_WARN = 3, - VC_LOG_ERROR = 4, - VC_LOG_OFF = 5, -} vc_log_level; - -typedef enum vc_connection_state { - VC_STATE_DISCONNECTED = 0, - VC_STATE_CONNECTING = 1, - VC_STATE_TLS_HANDSHAKE = 2, - VC_STATE_AUTHENTICATING = 3, - VC_STATE_CONNECTED = 4, - VC_STATE_VERIFYING_IDENTITY = 5, -} vc_connection_state; - -typedef enum vc_text_scope { - VC_TEXT_CHANNEL = 0, - VC_TEXT_PRIVATE = 1, - VC_TEXT_SERVER = 2, -} vc_text_scope; - -typedef enum vc_device_kind { - VC_DEVICE_INPUT = 0, - VC_DEVICE_OUTPUT = 1, -} vc_device_kind; - -typedef enum vc_stream_kind { - VC_STREAM_MIC = 0, - VC_STREAM_SCREEN_AUDIO = 1, - VC_STREAM_AUX_DEVICE = 2, -} vc_stream_kind; - - -typedef enum vc_input_mode { - VC_INPUT_VOICE_ACTIVATION = 0, - VC_INPUT_PUSH_TO_TALK = 1, - VC_INPUT_ALWAYS_ON = 2, -} vc_input_mode; - -typedef enum vc_event_type { - VC_EVENT_CONNECTION_STATE = 0, - VC_EVENT_AUTH_RESULT = 1, - VC_EVENT_CHANNEL_LIST = 2, /* channel tree snapshot/delta available */ - VC_EVENT_USER_JOINED = 3, /* user_id, channel_id, text = nickname */ - VC_EVENT_USER_LEFT = 4, /* user_id */ - VC_EVENT_USER_UPDATED = 5, /* user_id */ - VC_EVENT_TEXT_MESSAGE = 6, /* text_scope, user_id (sender), channel_id, text */ - VC_EVENT_STREAM_STARTED = 7, /* user_id, stream_id */ - VC_EVENT_STREAM_STOPPED = 8, /* user_id, stream_id */ - VC_EVENT_TALK_STATE = 9, /* user_id, stream_id, u32a = talking(0/1) */ - VC_EVENT_ERROR = 10, /* result, text */ - VC_EVENT_DISCONNECTED = 11, /* result, text = reason */ - VC_EVENT_JOIN_RESULT = 12, /* result (VC_OK/VC_ERR_*), channel_id, text = error on - failure. Reply to vc_join_channel(). */ - VC_EVENT_SERVER_IDENTITY = 13, /* u32a = vc_tofu_status, text = hex-encoded TLS leaf-cert - SHA-256 fingerprint (the value being pinned — see - vc_confirm_server_identity). Emitted once per connect - attempt, right after the TLS handshake succeeds. The - connection is held open until vc_confirm_server_identity() - is called. */ - VC_EVENT_GENERIC_RESULT = 14, /* result, u32a = server error code, text = message. Reply - to vc_kick_user/vc_ban_user/vc_set_permission/ - vc_move_user/vc_create_channel/vc_edit_channel/ - vc_delete_channel/vc_create_account/vc_reset_password/ - vc_delete_account. */ - VC_EVENT_ACCOUNT_LIST = 15, /* Reply to vc_list_accounts. */ - VC_EVENT_VOICE_STATE = 16, /* u32a = subscribed(0/1). Reply to vc_join_voice()/ - vc_leave_voice(), and also emitted when the server - changes your voice-subscription state. The user list - (vc_user) carries per-user voice_subscribed. */ -} vc_event_type; - -/* TOFU server-identity classification — see VC_EVENT_SERVER_IDENTITY and - * vc_confirm_server_identity. Pins the TLS leaf certificate's own SHA-256 fingerprint - * (verifiable directly from the handshake), NOT the declared Ed25519 - * server_identity_fingerprint from ServerHello — the TLS cert and the server's Ed25519 - * identity key are generated independently with no cryptographic binding between them - * (docs/security.md §1.1), so pinning the self-declared value would be circular. The Ed25519 - * fingerprint is still available for human-readable display via - * vc_get_server_identity_display(), it just isn't the value this gate accepts/rejects on. */ -typedef enum vc_tofu_status { - VC_TOFU_FIRST_CONNECT = 0, - VC_TOFU_MATCHED = 1, - VC_TOFU_MISMATCH = 2, -} vc_tofu_status; - - - -/* - * An event delivered to vc_callbacks.on_event. Pointer fields are owned by the core and - * valid ONLY for the duration of the callback — copy what you need. Which fields are - * meaningful depends on `type` (see vc_event_type comments above). - */ -typedef struct vc_event { - vc_event_type type; - vc_connection_state connection_state; - int32_t result; /* vc_result */ - uint32_t user_id; - uint32_t channel_id; - uint32_t stream_id; - vc_text_scope text_scope; - uint32_t u32a; /* generic small payload, meaning per event type */ - const char* text; - uint64_t timestamp_unix_ms; -} vc_event; - -typedef struct vc_callbacks { - /* State changes, messages, presence. Called on the core's event thread. */ - void (*on_event)(void* user, const vc_event* ev); - /* Throttled level meter (RMS 0..1) for a local or remote stream; may be NULL. */ - void (*on_level)(void* user, uint32_t stream_id, float rms); - void* user; -} vc_callbacks; - -typedef struct vc_config { - const char* client_name; /* e.g. "VoiceCat-macOS" */ - const char* client_version; /* e.g. "0.0.1" */ - vc_log_level log_level; - - const char* tofu_store_path; -} vc_config; - -typedef struct vc_stream_desc { - vc_stream_kind kind; - const char* device_id; /* NULL = default device for this kind */ - const char* label; /* human label, e.g. "Microphone" */ - /* If 1, the caller will feed PCM via vc_stream_feed_pcm; the core will NOT start its own - * WASAPI loopback capture. Only meaningful for VC_STREAM_SCREEN_AUDIO on Windows. Callers - * that brace-initialize this struct (tests, macOS) get 0 = auto-start loopback — no ABI - * break. See clients/windows/VoiceCat.App/Audio/ProcessAudioMixer.cs for the Windows - * per-app capture implementation that sets this. */ - int external_feed; -} vc_stream_desc; - -/* The effective Opus configuration in use for a stream — for a stream you own, this is - * StreamAnnounceResult.effective_audio (channel-enforced; for a remote - * stream, it's the peer's broadcast StreamInfo.audio. See vc_get_stream_audio_config. */ -typedef struct vc_audio_config { - uint32_t codec; /* 0 = OPUS */ - uint32_t mode; /* 0 = mono, 1 = stereo */ - uint32_t sample_rate; - uint32_t bitrate_bps; - uint32_t frame_ms; - uint32_t application; /* 0 = VOIP, 1 = AUDIO, 2 = LOWDELAY */ - int fec; /* bool */ - uint32_t expected_packet_loss; /* % 0..100 */ - int dtx; /* bool */ - uint32_t complexity; /* 0..10 */ - int dred; /* bool — Deep REDundancy (Opus 1.6), off by default */ -} vc_audio_config; - -/* Permission bitset (mirrors protocol Permissions). */ -typedef struct vc_permissions { - int can_create_temp_channel; /* bool */ - int can_kick; /* bool */ - int can_ban; /* bool */ - int can_move_users; /* bool */ - int can_admin_accounts; /* bool */ - int is_admin; /* bool */ -} vc_permissions; - -/* Account entry (reply to vc_list_accounts / vc_get_account_list). */ -typedef struct vc_account { - const char* username; - int is_admin; /* bool */ - uint64_t created_at_unix_ms; - uint64_t last_login_unix_ms; -} vc_account; - -typedef struct vc_account_list { - vc_account* items; - size_t count; -} vc_account_list; - -/* Channel creation/edition descriptor. */ -typedef struct vc_channel_info { - uint32_t id; /* 0 = new channel for create */ - uint32_t parent_id; /* 0 = root */ - const char* name; - const char* topic; - int password_protected; /* bool */ - const char* password; /* nullable; ignored if password_protected == 0 */ - uint32_t max_users; /* 0 = unlimited */ - uint32_t sort_order; - /* Audio config — 0/NULL fields use server defaults. */ - vc_audio_config audio; -} vc_channel_info; - -typedef struct vc_device { - const char* id; - const char* name; - int is_default; /* bool */ -} vc_device; - -typedef struct vc_device_list { - vc_device* items; - size_t count; -} vc_device_list; - -/* Channel / user / stream snapshots (for the channel-tree/user-list UI) - * Pull-based: re-call after VC_EVENT_CHANNEL_LIST / VC_EVENT_USER_JOINED / _LEFT / _UPDATED to - * refresh, there is no push variant; those events just mean "go look". Same ownership - * contract as vc_device/vc_device_list above: core-allocated, caller frees with the matching - * vc_free_*, items' const char* fields are invalid after that call. */ -typedef struct vc_channel { - uint32_t id; - uint32_t parent_id; /* 0 = root */ - const char* name; - const char* topic; - int password_protected; /* bool */ - uint32_t max_users; /* 0 = unlimited */ - uint32_t sort_order; /* channel sort order */ - /* Authoritative channel Opus params. Populated from the Channel proto - * so the edit dialog can read back the current config without a separate round-trip. */ - vc_audio_config audio; -} vc_channel; - -typedef struct vc_channel_list { - vc_channel* items; - size_t count; -} vc_channel_list; - -typedef struct vc_user { - uint32_t id; - const char* nickname; - int is_guest; /* bool */ - uint32_t channel_id; - int self_mic_muted; /* bool */ - int self_deafened; /* bool */ - int server_muted; /* bool */ - int server_deafened; /* bool */ - int voice_subscribed; /* bool — true when on the voice plane */ -} vc_user; - -typedef struct vc_user_list { - vc_user* items; - size_t count; -} vc_user_list; - -/* Per-user stream summary — lighter than vc_audio_config; for the full effective Opus config - * of a specific (user_id, stream_id), use the existing vc_get_stream_audio_config. */ -typedef struct vc_stream_summary { - uint32_t stream_id; - vc_stream_kind kind; - const char* label; -} vc_stream_summary; - -typedef struct vc_stream_summary_list { - vc_stream_summary* items; - size_t count; -} vc_stream_summary_list; - -/* Receive-side state the local listener has chosen for a specific remote stream — the - * counterpart to vc_set_remote_stream, so a UI can reopen its per-mix controls at the - * listener's actual current settings. All LOCAL (no protocol traffic). - * If (user_id, stream_id) is known but the listener has never called vc_set_remote_stream on - * it, the defaults are gain=1.0, muted=0, noise_reduction=0 (matching a fresh RemoteStream). */ -typedef struct vc_remote_stream_state { - float gain; /* 0.0… ; default 1.0 */ - int muted; /* bool */ - int noise_reduction; /* bool */ -} vc_remote_stream_state; - -/* Opaque client handle. */ -typedef struct vc_client vc_client; - -/* Lifecycle */ -VC_API const char* vc_version_string(void); -VC_API const char* vc_result_string(vc_result code); - -VC_API vc_client* vc_client_create(const vc_config* cfg, vc_callbacks cb); -VC_API void vc_client_destroy(vc_client* c); - -/* Connection & auth (async; results via on_event) */ -VC_API vc_result vc_connect(vc_client* c, const char* host, uint16_t port); -VC_API vc_result vc_disconnect(vc_client* c); -VC_API vc_result vc_authenticate_guest(vc_client* c, const char* nickname); -VC_API vc_result vc_authenticate_user(vc_client* c, const char* username, - const char* password); - -/* Channels */ -/* Result arrives as VC_EVENT_JOIN_RESULT, not a return value beyond "request queued". password - * is forwarded to the server's JoinChannelRequest.password and checked against the channel's - * stored password for channels with vc_channel.password_protected set. */ -VC_API vc_result vc_join_channel(vc_client* c, uint32_t channel_id, - const char* password /* nullable */); -VC_API vc_result vc_leave_channel(vc_client* c); - -/* Voice-plane subscription */ -/* Joining voice subscribes to the voice plane: the server starts relaying voice frames - * to you, and the core wires up remote-stream decoders so you hear other users. Leaving - * voice unsubscribes: the server stops relaying voice to you, the core tears down all - * remote decoders (playback stops), and any active local mic/screen/aux streams are - * stopped. Text chat is unaffected either way. Result arrives as VC_EVENT_VOICE_STATE - * (u32a = 1 for subscribed, 0 for unsubscribed). */ -VC_API vc_result vc_join_voice(vc_client* c); -VC_API vc_result vc_leave_voice(vc_client* c); - -/* Local media streams (mic / screen audio / aux) */ -VC_API vc_result vc_stream_start(vc_client* c, const vc_stream_desc* desc, - uint32_t* out_stream_id); -VC_API vc_result vc_stream_stop(vc_client* c, uint32_t stream_id); -VC_API vc_result vc_set_input_device(vc_client* c, uint32_t stream_id, - const char* device_id); - -/* Send-side: input gate mode + PTT key state, and self mute/deafen. */ -VC_API vc_result vc_set_input_mode(vc_client* c, vc_input_mode mode); -/* VAD threshold: normalized RMS 0.0–1.0; default ~0.025. Takes effect immediately — - * recreates the VAD gate if a MIC stream is already active. No-op when mode != VOICE_ACTIVATION - * (value is remembered and applied if the mode switches back). */ -VC_API vc_result vc_set_vad_threshold(vc_client* c, float threshold); -VC_API vc_result vc_set_push_to_talk(vc_client* c, int active /* bool */); -VC_API vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened); - -/* Global playback volume applied after mixing all remote streams. gain 0.0 = silent, - * 1.0 = unity (default), >1.0 amplifies. */ -VC_API vc_result vc_set_output_volume(vc_client* c, float gain); - -/* Send-side microphone input gain. Applied to captured MIC PCM before the VAD/PTT gate and - * Opus encode (so boosting a quiet mic also helps it cross the VAD threshold). gain 0.0 = silent, - * 1.0 = unity (default), >1.0 amplifies; the boosted signal is clamped to int16. MIC stream only; - * */ -VC_API vc_result vc_set_input_gain(vc_client* c, float gain); - -/* Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before the input - * gain and the VAD/PTT gate, so everyone hears the cleaned signal (one pass for all listeners). - * enable != 0 turns it on. MIC stream only, mono only - * Independent of the per-listener receive-side NR in vc_set_remote_stream */ -VC_API vc_result vc_set_input_noise_reduction(vc_client* c, int enable); - -/* Receive-side, per remote stream, - * gain (0..) , mute, and listener-chosen noise reduction on a specific user's stream. */ -VC_API vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, - float gain, int muted, int noise_reduction); - -/* Reads back the receive-side state last set on (user_id, stream_id) via - * vc_set_remote_stream (or the defaults if never set). VC_ERR_INVALID_ARG if the user/stream - * isn't known. */ -VC_API vc_result vc_get_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, - vc_remote_stream_state* out); - -/* Effective Opus config in use for (user_id, stream_id) — your own stream or a peer's. - * VC_ERR_INVALID_ARG if the user/stream isn't known. */ -VC_API vc_result vc_get_stream_audio_config(vc_client* c, uint32_t user_id, uint32_t stream_id, - vc_audio_config* out); - -/* TEST-ONLY — not for production use. Bypasses the real capture device, injecting raw PCM - * directly into the named local stream's encode pipeline (see AudioEngine::inject_capture). - * Exists so automated tests can drive the real vc_client/ABI path end-to-end without a - * microphone. `stream_id` is the id returned by vc_stream_start. */ -VC_API vc_result vc_test_inject_capture(vc_client* c, uint32_t stream_id, const int16_t* pcm, - size_t samples); - -/* Set the capture channel count for a local MIC stream (1 = mono, 2 = stereo interleaved). - * Must be called after vc_stream_start. Stores the value; it takes effect on the next engine - * (re)start. Does NOT restart the engine itself — the caller must follow up with - * vc_audio_restart() - * VC_ERR_INVALID_ARG if stream_id is unknown or channels is not 1 or 2. */ -VC_API vc_result vc_set_capture_channels(vc_client* c, uint32_t stream_id, uint32_t channels); - -/* External PCM feed/tap */ - -/* External PCM feed - * with caller-supplied PCM instead of (or in addition to) a hardware capture device. The - * stream must already be started (vc_stream_start). The core frames, encodes (Opus), seals - * (AEAD), and sends (UDP) the provided samples exactly as it would mic/loopback audio. - * - * pcm MUST be 48 kHz int16 — the core does NOT resample. (The whole audio engine runs at - * 48 kHz. A bot for example is responsible for resampling its source to 48 kHz.) - * - * samples_per_channel : samples per channel for THIS call. Any count is accepted — the core - * buffers and re-chunks to the channel's Opus frame size (the channel's frame_ms decides - * this: 480 @ 10 ms, 960 @ 20 ms, 1920 @ 40 ms, …). You need not match the frame size, and - * a channel with a non-20 ms window is handled transparently. - * channels : 1 (mono) or 2 (stereo interleaved L/R). VC_ERR_INVALID_ARG otherwise. - * - * Use cases: ReplayKit Broadcast Extension (iOS), ScreenCaptureKit (macOS), bots (TTS / - * music / relay), soundboards, DAW integration. Works for any stream kind (MIC / - * SCREEN_AUDIO / AUX_DEVICE). Thread-safe; may be called from any thread. - * - */ -VC_API vc_result vc_stream_feed_pcm(vc_client* c, uint32_t stream_id, - const int16_t* pcm, size_t samples_per_channel, - uint32_t channels); - -/* External PCM tap receive decoded remote audio as int16 PCM per stream, before it is - * summed into the hardware mix. The callback fires on the audio playback thread once per - * decoded Opus frame (typically every 20 ms) for each active remote stream: - * - * cb(user, user_id, stream_id, pcm, samples_per_channel, channels, sample_rate) - * - * user_id / stream_id : identify the sender (same values as VC_EVENT_STREAM_STARTED). - * pcm : decoded int16 PCM, interleaved when channels == 2. - * samples_per_channel : samples per channel for this frame (typically 960 @ 48 kHz). - * channels : 1 or 2, matching the sender's stream configuration. - * sample_rate : always 48000 in the current implementation. - * - * Pass cb = NULL to disable (default: disabled; hardware playback only). Disabling is a - * quiescence barrier: when vc_set_pcm_sink(c, NULL, NULL) returns, no callback using the old - * user pointer is still running, so the caller may safely release that state. - * The callback MUST NOT block, lock, or allocate — copy what you need and return. - * PCM is still delivered to the hardware playback device regardless (dual output). */ -typedef void (*vc_pcm_sink_cb)(void* user, uint32_t user_id, uint32_t stream_id, - const int16_t* pcm, size_t samples_per_channel, - uint32_t channels, uint32_t sample_rate); -VC_API vc_result vc_set_pcm_sink(vc_client* c, vc_pcm_sink_cb cb, void* user); - -/* External playback (iOS VPIO / echo cancellation) - * On iOS, real echo cancellation + noise suppression + AGC are provided ONLY by Apple's - * Voice-Processing I/O audio unit (VPIO), which the Swift AVAudioEngine layer owns. For VPIO - * to cancel echo, the remote-audio playback must go through the SAME VPIO unit as the mic - * capture (VPIO subtracts the played-back signal from the mic). So in that topology the core - * must NOT open/drive its own hardware playback device — its output would bypass VPIO, giving - * it no reference signal and producing echo. Instead, enable external playback: the core keeps - * decoding + mixing every remote stream on a steady ~20 ms cadence and delivers the FINAL - * MIXED PCM (post output-volume, all streams summed) to this sink, which the Swift layer - * renders through the VPIO output. - * - * cb(user, pcm, samples_per_channel, channels, sample_rate) - * - * pcm : final mixed int16 PCM, interleaved when channels == 2. - * samples_per_channel : samples per channel for this block (960 @ 20 ms / 48 kHz). - * channels : the engine's playback channel count (2 = stereo). - * sample_rate : always 48000. - * - * The callback fires on the core's mixer-timer thread (NOT a hardware audio thread). It fires - * steadily even with no remote streams (a silent block), so the renderer has a continuous - * clock. The callback MUST NOT block, lock, or allocate — copy into a lock-free ring and - * return. Independent of vc_set_pcm_sink (the per-stream tap), which still works. Pass cb=NULL - * to disable (default: disabled). Disabling is a quiescence barrier with the same callback-state - * lifetime guarantee as vc_set_pcm_sink. */ -typedef void (*vc_mixed_output_cb)(void* user, const int16_t* pcm, - size_t samples_per_channel, uint32_t channels, - uint32_t sample_rate); -VC_API vc_result vc_set_mixed_output_sink(vc_client* c, vc_mixed_output_cb cb, void* user); - -/* Enable/disable external-playback mode (default: disabled = normal hardware playback). When - * enabled, the core does NOT open a hardware playback device; decode+mix runs on an internal - * ~20 ms timer and the result is delivered via vc_set_mixed_output_sink. To also bypass the - * hardware mic (feeding VPIO-processed mic PCM instead), start the MIC stream with - * vc_stream_desc.external_feed=1 and push frames via vc_stream_feed_pcm — the core then skips - * the hardware capture device too. Apply BEFORE the engine starts, or follow with - * vc_audio_restart() to apply to a running engine. `enable` is a bool (0/1). */ -VC_API vc_result vc_set_external_playback(vc_client* c, int enable); - -/* Text */ -VC_API vc_result vc_send_text(vc_client* c, vc_text_scope scope, uint32_t target_id, - const char* utf8); - -/* Device enumeration (for UI pickers) */ -VC_API vc_result vc_list_devices(vc_client* c, vc_device_kind kind, vc_device_list* out); -VC_API void vc_free_device_list(vc_device_list* list); - -/* Channel / user / stream enumeration (mirrors vc_list_devices above) */ -VC_API vc_result vc_list_channels(vc_client* c, vc_channel_list* out); -VC_API void vc_free_channel_list(vc_channel_list* list); - -VC_API vc_result vc_list_users(vc_client* c, vc_user_list* out); -VC_API void vc_free_user_list(vc_user_list* list); - -/* Streams currently owned by user_id (their mic/screen-audio/aux), per the last snapshot/ - * event. VC_ERR_INVALID_ARG if user_id is unknown. */ -VC_API vc_result vc_list_user_streams(vc_client* c, uint32_t user_id, - vc_stream_summary_list* out); -VC_API void vc_free_stream_summary_list(vc_stream_summary_list* list); - -/* TOFU server-identity confirmation — see VC_EVENT_SERVER_IDENTITY/vc_tofu_status */ -/* Accept or reject the pending server-identity check for the in-progress connect(). Must be - * called after a VC_EVENT_SERVER_IDENTITY event; the io_thread_ holds the connection open - * (ClientHello/auth deferred) until this is called, up to a generous internal timeout (after - * which it's treated as a reject). accept=0 aborts the connection (emits - * VC_EVENT_DISCONNECTED, result=VC_ERR_CRYPTO) and does NOT update the pin file. accept=1 on - * FIRST_CONNECT/MISMATCH updates the pin file to the new fingerprint and proceeds; accept=1 on - * MATCHED is a no-op confirmation (always safe) and proceeds. VC_ERR_INVALID_ARG if no - * identity confirmation is currently pending. */ -VC_API vc_result vc_confirm_server_identity(vc_client* c, int accept /* bool */); - -/* The Ed25519 identity fingerprint from ServerHello, hex-formatted for display (e.g. "this - * server also identifies as "). Purely informational — NOT the value - * vc_confirm_server_identity gates on (see vc_tofu_status's doc comment). Empty string if not - * yet available. Pass out_buf=NULL to query the required buffer size via *out_len first; - * otherwise out_buf must be >= *out_len + 1 bytes (NUL-terminated UTF-8/ASCII hex). */ -VC_API vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap, - size_t* out_len); - -/* Moderation & admin - * All calls are async; the result arrives as VC_EVENT_GENERIC_RESULT (or - * VC_EVENT_ACCOUNT_LIST for vc_list_accounts). They require VC_STATE_CONNECTED and, - * on the server side, the appropriate permission. */ - -VC_API vc_result vc_kick_user(vc_client* c, uint32_t user_id, const char* reason); -VC_API vc_result vc_ban_user(vc_client* c, uint32_t user_id, const char* reason, - uint64_t expires_unix_ms); -VC_API vc_result vc_set_permission(vc_client* c, uint32_t user_id, - const vc_permissions* perms); -VC_API vc_result vc_set_server_mute(vc_client* c, uint32_t user_id, int muted, int deafened); -VC_API vc_result vc_move_user(vc_client* c, uint32_t user_id, uint32_t channel_id); - -VC_API vc_result vc_create_channel(vc_client* c, const vc_channel_info* info); -VC_API vc_result vc_edit_channel(vc_client* c, const vc_channel_info* info); -VC_API vc_result vc_delete_channel(vc_client* c, uint32_t channel_id); - -VC_API vc_result vc_create_account(vc_client* c, const char* username, const char* password); -VC_API vc_result vc_reset_password(vc_client* c, const char* username, - const char* new_password); -VC_API vc_result vc_delete_account(vc_client* c, const char* username); -VC_API vc_result vc_list_accounts(vc_client* c); - -/* Pull the last received account list (populated when VC_EVENT_ACCOUNT_LIST fires). - * Caller must free the list with vc_free_account_list. */ -VC_API vc_result vc_get_account_list(vc_client* c, vc_account_list* out); -VC_API void vc_free_account_list(vc_account_list* list); - -/* Pull the caller's own permissions (from the last AuthResult). */ -VC_API vc_result vc_get_permissions(vc_client* c, vc_permissions* out); - -/* AudioSession interruption hooks*/ -VC_API vc_result vc_audio_suspend(vc_client* c); -VC_API vc_result vc_audio_resume(vc_client* c); - -/* Full audio engine restart Safe to call when the engine is not running (it will just start it). */ -VC_API vc_result vc_audio_restart(vc_client* c); - -#if defined(__cplusplus) -} /* extern "C" */ -#endif - -#endif /* VOICECAT_H */ diff --git a/core/src/audio/apm_processor.cpp b/core/src/audio/apm_processor.cpp deleted file mode 100644 index 9d29abc..0000000 --- a/core/src/audio/apm_processor.cpp +++ /dev/null @@ -1,101 +0,0 @@ -#include "audio/apm_processor.h" - -#include -#include -#include -#include - -#include "rnnoise.h" - -namespace voicecat::audio { - -namespace { -int64_t steady_now_ms() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} -} // namespace - - -class EnergyVadProcessor final : public ApmProcessor { - public: - EnergyVadProcessor(float rms_threshold, int64_t hang_time_ms) - : threshold_(rms_threshold), hang_time_ms_(hang_time_ms) {} - - void process_render(const int16_t*, int, int) override {} - - bool process_capture(int16_t* pcm, int samples, int /*sample_rate*/) override { - if (samples > 0) { - double sum_sq = 0.0; - for (int i = 0; i < samples; ++i) { - double s = static_cast(pcm[i]) / 32768.0; - sum_sq += s * s; - } - double rms = std::sqrt(sum_sq / samples); - if (rms >= threshold_.load(std::memory_order_relaxed)) last_voice_ms_ = steady_now_ms(); - } - return (steady_now_ms() - last_voice_ms_) < hang_time_ms_; - } - - void set_threshold(float t) override { - threshold_.store(t, std::memory_order_relaxed); - } - - private: - std::atomic threshold_; - int64_t hang_time_ms_; - int64_t last_voice_ms_ = 0; // epoch start -> gate begins closed until first loud frame -}; - -// RnnoiseProcessor -// Real noise suppression via vendored RNNoise (native/rnnoise -// RNNoise is a mono, 48 kHz, fixed 480-sample (10 ms) speech denoiser; our engine clock is fixed -// at 48 kHz and every Opus frame size (480/960/1920/2880) is a multiple of 480, so we process -// whole 480-sample chunks with no resampling and no cross-call carry. Mono only — callers gate -// on a single channel (a stereo screen-audio share is never voice and isn't denoised). -// -// RT-safety (docs/architecture.md §3): the DenoiseState and the float scratch are allocated in the -// ctor; process_capture() does no allocation/locking. The state is owned per-stream (recv) or -// per-mic (send) so it persists across calls, which is exactly what RNNoise's overlap needs. -class RnnoiseProcessor final : public ApmProcessor { - public: - RnnoiseProcessor() : st_(rnnoise_create(nullptr)) {} - ~RnnoiseProcessor() override { - if (st_) rnnoise_destroy(st_); - } - - void process_render(const int16_t*, int, int) override {} // NS needs no AEC reference - - bool process_capture(int16_t* pcm, int samples, int sample_rate) override { - // RNNoise is 48 kHz only; anything else passes through untouched (our clock is 48 kHz, so - // this guard never trips in practice. it's just a correctness backstop). - if (!st_ || sample_rate != 48000) return true; - for (int off = 0; off + kFrame <= samples; off += kFrame) { - for (int i = 0; i < kFrame; ++i) in_[i] = static_cast(pcm[off + i]); - rnnoise_process_frame(st_, out_, in_); - for (int i = 0; i < kFrame; ++i) { - int32_t v = static_cast(std::lround(out_[i])); - pcm[off + i] = static_cast(std::clamp(v, -32768, 32767)); - } - } - return true; // NS doesn't gate. the send path's VAD stays a separate stage - } - - private: - static constexpr int kFrame = 480; // rnnoise_get_frame_size() - DenoiseState* st_; - float in_[kFrame]; - float out_[kFrame]; -}; - -std::unique_ptr ApmProcessor::create() { - return std::make_unique(); -} - -std::unique_ptr ApmProcessor::create_vad(float rms_threshold, - int64_t hang_time_ms) { - return std::make_unique(rms_threshold, hang_time_ms); -} - -} // namespace voicecat::audio diff --git a/core/src/audio/apm_processor.h b/core/src/audio/apm_processor.h deleted file mode 100644 index 3572ac9..0000000 --- a/core/src/audio/apm_processor.h +++ /dev/null @@ -1,42 +0,0 @@ -/* - * audio/apm_processor.h, Send-side audio processing module (AEC/NS/AGC/VAD). - - */ -#ifndef VOICECAT_AUDIO_APM_PROCESSOR_H -#define VOICECAT_AUDIO_APM_PROCESSOR_H - -#include -#include - -namespace voicecat::audio { - -class ApmProcessor { - public: - virtual ~ApmProcessor() = default; - - // Feed the most recent playback reference (for AEC). Call before process_capture(). - virtual void process_render(const int16_t* pcm, int samples, int sample_rate) = 0; - - // Process one capture frame in-place (AEC, NS, AGC). - // Returns true if VAD detects speech (or always true in passthrough mode). - // Returns false caller should skip encode/send (silence gate). - virtual bool process_capture(int16_t* pcm, int samples, int sample_rate) = 0; - - // Update the VAD RMS threshold in-place (used by EnergyVadProcessor; no-op in passthrough). - // Safe to call from any thread — EnergyVadProcessor stores it atomically. - virtual void set_threshold(float) {} - - // Factory for the noise-suppression backend - static std::unique_ptr create(); - - // Factory for the send-side input gate - // rms_threshold: normalized 0.0-1.0 RMS-of-int16-range; default ~0.025. - // hang_time_ms: how long the gate stays open after the last loud frame; default 300 ms - // (matches AudioEngine's kTalkHangoverMs so "talking" and "gate open" agree). - static std::unique_ptr create_vad(float rms_threshold = 0.025f, - int64_t hang_time_ms = 300); -}; - -} // namespace voicecat::audio - -#endif // VOICECAT_AUDIO_APM_PROCESSOR_H diff --git a/core/src/audio/audio_engine.cpp b/core/src/audio/audio_engine.cpp deleted file mode 100644 index 8f6b34b..0000000 --- a/core/src/audio/audio_engine.cpp +++ /dev/null @@ -1,903 +0,0 @@ -#define MINIAUDIO_IMPLEMENTATION -#include - -#include "audio/audio_engine.h" - -#include -#include -#include - -namespace voicecat::audio { - -namespace { -int64_t now_ms() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - -constexpr int32_t kMinDepthSamples = 48000 * 40 / 1000; // floor for the catch-up target depth -constexpr int32_t kCatchupSamples = 48000 * 60 / 1000; // skip when depth > target + 60 ms -constexpr int32_t kStarveSamples = 48000 * 120 / 1000; // reseed when clock 120 ms past newest - -constexpr int32_t kPlcCapSamples = 48000 * 2; // 2 s @ 48 kHz - -// device_id encoding (DeviceInfo::id / AudioParams::*_device_id): a hex string of the raw -// ma_device_id bytes. Opaque on purpose — names aren't guaranteed unique, and this is the only -// stable handle miniaudio accepts back for device selection. Internal contract only; never -// exposed as anything other than an opaque round-tripped string at the C ABI boundary. -std::string hex_encode_device_id(const ma_device_id& id) { - static constexpr char kHex[] = "0123456789abcdef"; - const auto* bytes = reinterpret_cast(&id); - std::string out; - out.reserve(sizeof(ma_device_id) * 2); - for (size_t i = 0; i < sizeof(ma_device_id); ++i) { - out.push_back(kHex[bytes[i] >> 4]); - out.push_back(kHex[bytes[i] & 0xF]); - } - return out; -} - -bool hex_decode_device_id(const std::string& hex, ma_device_id* out) { - if (hex.size() != sizeof(ma_device_id) * 2) return false; - std::memset(out, 0, sizeof(ma_device_id)); - auto* bytes = reinterpret_cast(out); - auto nibble = [](char c) -> int { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return -1; - }; - for (size_t i = 0; i < sizeof(ma_device_id); ++i) { - int hi = nibble(hex[i * 2]); - int lo = nibble(hex[i * 2 + 1]); - if (hi < 0 || lo < 0) return false; - bytes[i] = static_cast((hi << 4) | lo); - } - return true; -} -} // namespace - -// JitterBuffer - -void JitterBuffer::push(Frame f) { - std::lock_guard lk(mu_); - - uint32_t ts = f.timestamp; - bool marker = f.marker; - - // Jitter estimation (EWMA of inter-arrival gap vs the expected per-frame gap). Skip - // silence-gap outliers — a talkspurt restart (marker) or any gap far larger than a frame - // (DTX/VAD/PTT silence) is not jitter; counting it would spike the estimate and inflate the - // target depth for the rest of the call. The sender omits silence from its timestamp, so a - // restart can also arrive "behind" (negative gap) — also an outlier. - if (!first_push_) { - int32_t gap = static_cast(ts - last_push_ts_); - bool outlier = marker || gap <= 0 || - gap > static_cast(expected_gap_samples_ * 8); - if (!outlier) { - uint32_t diff = (static_cast(gap) > expected_gap_samples_) - ? (static_cast(gap) - expected_gap_samples_) - : (expected_gap_samples_ - static_cast(gap)); - jitter_est_ = (jitter_est_ * 7 + diff) / 8; - uint32_t depth = std::clamp(jitter_est_ * 2 + expected_gap_samples_, - expected_gap_samples_, 48000u * 200u / 1000u); - target_depth_ms_.store(depth * 1000u / 48000u, std::memory_order_relaxed); - } - } - last_push_ts_ = ts; - first_push_ = false; - - // Track the leading edge (newest timestamp), wrap-safe. - if (!have_newest_ || static_cast(ts - newest_ts_) > 0) newest_ts_ = ts; - have_newest_ = true; - - auto res = buf_.emplace(ts, std::move(f)); - if (!res.second) dup_.fetch_add(1, std::memory_order_relaxed); // duplicate timestamp -} - -std::optional JitterBuffer::pop(uint32_t playout_ts) { - std::unique_lock lk(mu_, std::try_to_lock); - if (!lk) return std::nullopt; // contended. caller does PLC - - if (buf_.empty()) return std::nullopt; - - auto it = buf_.begin(); - uint32_t ts = it->first; - - // Drop frames that are too old to play. The window tracks the (adaptive) target depth plus a - // margin so it never undercuts DRED's next-packet lookahead, floored/capped at the fixed - // 500 ms bound. Without this the only downward force on latency was a snap that *added* it. - constexpr uint32_t kLateMarginSamples = 48000u * 200u / 1000u; // +200 ms over target depth - constexpr uint32_t kLateDropFloor = 48000u * 200u / 1000u; // never drop earlier than 200 ms - uint32_t late_window = std::clamp(target_depth_samples() + kLateMarginSamples, - kLateDropFloor, kLateDropSamples); - if (static_cast(playout_ts - ts) > static_cast(late_window)) { - lost_.fetch_add(1, std::memory_order_relaxed); - buf_.erase(it); - return std::nullopt; - } - - // Return frame only when it's due. - if (static_cast(ts - playout_ts) > 0) return std::nullopt; - - Frame f = std::move(it->second); - buf_.erase(it); - return f; -} - -std::optional JitterBuffer::peek_front_ts() const { - std::unique_lock lk(mu_, std::try_to_lock); - if (!lk || buf_.empty()) return std::nullopt; - return buf_.begin()->first; -} - -std::optional JitterBuffer::peek_back_ts() const { - std::unique_lock lk(mu_, std::try_to_lock); - if (!lk || !have_newest_) return std::nullopt; - return newest_ts_; -} - -void JitterBuffer::drop_before(uint32_t ts) { - std::lock_guard lk(mu_); - while (!buf_.empty()) { - auto it = buf_.begin(); // oldest - if (static_cast(ts - it->first) > 0) - buf_.erase(it); // strictly before the new playout point — stale - else - break; - } -} - -size_t JitterBuffer::try_copy_front_payload(uint32_t expected_ts, uint8_t* out, size_t max_sz) { - std::unique_lock lk(mu_, std::try_to_lock); - if (!lk || buf_.empty()) return 0; - auto it = buf_.begin(); - if (it->first != expected_ts) return 0; - const auto& payload = it->second.payload; - size_t n = std::min(payload.size(), max_sz); - std::memcpy(out, payload.data(), n); - return n; -} - -void JitterBuffer::reset() { - std::lock_guard lk(mu_); - buf_.clear(); - lost_.store(0); - dup_.store(0); - first_push_ = true; - have_newest_ = false; - jitter_est_ = 0; -} - -// AudioEngine - -AudioEngine::AudioEngine() = default; - -AudioEngine::~AudioEngine() { - stop(); - stop_loopback_capture(); // loopback has an independent lifecycle, close it before the context - if (context_inited_) { - ma_context_uninit(&context_); - context_inited_ = false; - } - if (dred_dec_) { opus_dred_decoder_destroy(dred_dec_); dred_dec_ = nullptr; } -} - -ma_context_config AudioEngine::make_context_config() { - ma_context_config cfg = ma_context_config_init(); - cfg.coreaudio.sessionCategory = ma_ios_session_category_none; // don't call setCategory - cfg.coreaudio.noAudioSessionActivate = MA_TRUE; // don't setActive(true) on device init - cfg.coreaudio.noAudioSessionDeactivate = MA_TRUE; // don't setActive(false) on device uninit - return cfg; -} - -bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) { - if (running_.load()) return false; - params_ = p; - capture_cb_ = std::move(capture_cb); - frame_samples_ = static_cast(p.sample_rate / 1000 * p.frame_ms); - running_.store(true, std::memory_order_release); - if (!dred_dec_) { - int err = 0; - dred_dec_ = opus_dred_decoder_create(&err); // null on failure, DRED silently disabled - } - - // Pre-allocate capture accumulators before the devices start so on_capture / on_loopback - // never allocate on the RT thread. count=0 means "empty"; the buf is sized to exactly one - // encoder frame so a memcpy into it can never overrun. The mic accumulator is sized to - // frame_samples_ * capture_channels (1 = mono, 2 = stereo interleaved, set via - // vc_set_capture_channels, e.g. iOS stereo built-in mic). The loopback accumulator is - // sized mono here as a safe default and re-sized to frame_samples_*channels in - // start_loopback_capture() once the screen stream's channel mode is known (off the RT - // thread, before the loopback device is started). - capture_accum_.buf.assign(static_cast(frame_samples_) * p.capture_channels, 0); - capture_accum_.count = 0; - loopback_accum_.buf.assign(static_cast(frame_samples_), 0); - loopback_accum_.count = 0; - - // Own the ma_context (lazily, reused across restarts) so miniaudio does not touch - // AVAudioSession on iOS — the Swift IOSAudioRouter is the sole session owner. Without - // this, ma_device_init(nullptr, ...) below would reset the session category to Record/ - // Playback with no options on every open, killing headphone/A2DP output. See - // make_context_config() in audio_engine.h. If context init fails we fall back to a NULL - // context (degraded: miniaudio manages the session) rather than leaving audio dead. - if (!context_inited_) { - ma_context_config ctx_cfg = make_context_config(); - context_inited_ = (ma_context_init(nullptr, 0, &ctx_cfg, &context_) == MA_SUCCESS); - } - ma_context* ctx = context_inited_ ? &context_ : nullptr; - - // External playback (iOS VPIO): skip the hardware playback device entirely — a timer - // thread drives the mixer and the final mix goes to the Swift VPIO renderer (launched - // after the capture block below). - if (!external_playback_) { - // ── Playback device (opened first on iOS: commits the output route — e.g. A2DP — - // before the capture device starts. Starting stereo capture can trigger an iOS audio - // route reconfiguration; opening playback first ensures A2DP is already committed - // and is less likely to be dropped when the capture AudioUnit activates.) ────────── - ma_device_id pb_id{}; - bool have_pb_id = !p.playback_device_id.empty() && - hex_decode_device_id(p.playback_device_id, &pb_id); - - ma_device_config pb_cfg = ma_device_config_init(ma_device_type_playback); - pb_cfg.playback.format = ma_format_s16; - pb_cfg.playback.channels = p.playback_channels; - pb_cfg.sampleRate = p.sample_rate; - pb_cfg.dataCallback = playback_data_cb; - pb_cfg.pUserData = this; - pb_cfg.playback.pDeviceID = have_pb_id ? &pb_id : nullptr; - - if (ma_device_init(ctx, &pb_cfg, &playback_device_) == MA_SUCCESS) { - if (ma_device_start(&playback_device_) == MA_SUCCESS) { - playback_started_ = true; - } else { - ma_device_uninit(&playback_device_); - } - } else if (p.playback_channels != 1) { - // Fallback: some unusual hardware may not accept the requested channel count even - // though WASAPI shared mode normally remixes transparently. Retry once at mono rather - // than leaving playback dead. - pb_cfg.playback.channels = 1; - if (ma_device_init(ctx, &pb_cfg, &playback_device_) == MA_SUCCESS) { - if (ma_device_start(&playback_device_) == MA_SUCCESS) { - playback_started_ = true; - params_.playback_channels = 1; - } else { - ma_device_uninit(&playback_device_); - } - } - } - } // end if (!external_playback_) - - // External capture (iOS VPIO / external feed): skip the hardware mic device — PCM is fed - // via inject_capture / vc_stream_feed_pcm. capture_cb_ (set above) still fires for fed frames. - if (!params_.external_capture) { - // Capture device (opened after playback so the output route is already committed) - ma_device_id cap_id{}; - bool have_cap_id = !p.capture_device_id.empty() && - hex_decode_device_id(p.capture_device_id, &cap_id); - - ma_device_config cap_cfg = ma_device_config_init(ma_device_type_capture); - cap_cfg.capture.format = ma_format_s16; - cap_cfg.capture.channels = p.capture_channels; - cap_cfg.sampleRate = p.sample_rate; - cap_cfg.dataCallback = capture_data_cb; - cap_cfg.pUserData = this; - cap_cfg.capture.pDeviceID = have_cap_id ? &cap_id : nullptr; // null = default device - // Hint: request the encoder's frame size as the callback period. WASAPI shared mode may - // not honor this (the hardware period is fixed), but when it is honored the accumulator - // below becomes a zero-copy passthrough rather than a copy every two callbacks. - cap_cfg.periodSizeInFrames = static_cast(frame_samples_); - - if (ma_device_init(ctx, &cap_cfg, &capture_device_) == MA_SUCCESS) { - if (ma_device_start(&capture_device_) == MA_SUCCESS) { - capture_started_ = true; - } else { - ma_device_uninit(&capture_device_); - } - } - } // end if (!params_.external_capture) - - // External playback: launch the mixer-timer thread now that the engine is configured. It - // drives on_playback() (decode + mix all remote streams) every frame_ms and ships the final - // mix to mixed_sink_ for the Swift VPIO renderer. No hardware playback device exists. - if (external_playback_) { - mixer_scratch_.assign( - static_cast(frame_samples_) * params_.playback_channels, 0); - mixer_timer_stop_.store(false, std::memory_order_release); - mixer_timer_thread_ = std::thread([this] { run_mixer_timer(); }); - } - - return true; -} - -std::vector AudioEngine::enumerate_devices(bool capture) { - std::vector result; - // Use the no-AVAudioSession-management config here too: device enumeration runs at - // SessionState init (refreshDevices) and on settings views, and a default-config context - // would call setCategory()/setActive() on iOS, disrupting the session the Swift layer owns. - ma_context ctx; - ma_context_config ctx_cfg = make_context_config(); - if (ma_context_init(nullptr, 0, &ctx_cfg, &ctx) != MA_SUCCESS) return result; - - ma_device_info* playback_infos = nullptr; - ma_uint32 playback_count = 0; - ma_device_info* capture_infos = nullptr; - ma_uint32 capture_count = 0; - - if (ma_context_get_devices(&ctx, &playback_infos, &playback_count, &capture_infos, - &capture_count) == MA_SUCCESS) { - ma_device_info* infos = capture ? capture_infos : playback_infos; - ma_uint32 count = capture ? capture_count : playback_count; - result.reserve(count); - for (ma_uint32 i = 0; i < count; ++i) { - DeviceInfo d; - d.id = hex_encode_device_id(infos[i].id); - d.name = infos[i].name; - d.is_default = infos[i].isDefault != 0; - result.push_back(std::move(d)); - } - } - - ma_context_uninit(&ctx); - return result; -} - -void AudioEngine::stop() { - if (!running_.exchange(false)) return; - - // External playback: stop + join the mixer-timer thread before tearing down state it reads. - mixer_timer_stop_.store(true, std::memory_order_release); - if (mixer_timer_thread_.joinable()) mixer_timer_thread_.join(); - if (capture_started_) { - ma_device_stop(&capture_device_); - ma_device_uninit(&capture_device_); - capture_started_ = false; - } - if (playback_started_) { - ma_device_stop(&playback_device_); - ma_device_uninit(&playback_device_); - playback_started_ = false; - } -} - -bool AudioEngine::suspend() { - if (!running_.load(std::memory_order_acquire)) return true; - // External playback: pause the mixer timer (there is no playback device to stop). This - // matches the VPIO renderer going down during an AVAudioSession interruption. - if (external_playback_) { - mixer_timer_stop_.store(true, std::memory_order_release); - if (mixer_timer_thread_.joinable()) mixer_timer_thread_.join(); - } - bool ok = true; - if (capture_started_) ok &= (ma_device_stop(&capture_device_) == MA_SUCCESS); - if (playback_started_) ok &= (ma_device_stop(&playback_device_) == MA_SUCCESS); - return ok; -} - -bool AudioEngine::resume() { - if (!running_.load(std::memory_order_acquire)) return true; - // External playback: relaunch the mixer timer (mixer_scratch_ is still sized from start()). - if (external_playback_ && !mixer_timer_thread_.joinable()) { - mixer_timer_stop_.store(false, std::memory_order_release); - mixer_timer_thread_ = std::thread([this] { run_mixer_timer(); }); - } - bool ok = true; - if (capture_started_) ok &= (ma_device_start(&capture_device_) == MA_SUCCESS); - if (playback_started_) ok &= (ma_device_start(&playback_device_) == MA_SUCCESS); - return ok; -} - -void AudioEngine::inject_capture(int kind, const int16_t* pcm, size_t samples_per_channel, - int channels) { - const int ch = std::max(1, channels); - const size_t n = samples_per_channel * static_cast(ch); - - InjectTap* tap; - { - std::lock_guard lk(inject_mu_); - auto& slot = inject_taps_[kind]; - if (!slot) { - slot = std::make_unique(); - slot->ring.resize(kInjectCapSamples, 0); - } - tap = slot.get(); - } - - // If the channel count changed, reset the ring to avoid mixing mono and stereo samples. - if (tap->channels != ch) { - tap->write.store(0, std::memory_order_relaxed); - tap->read.store(0, std::memory_order_relaxed); - tap->channels = ch; - } - - size_t w = tap->write.load(std::memory_order_relaxed); - for (size_t i = 0; i < n; ++i) - tap->ring[(w + i) % kInjectCapSamples] = pcm[i]; - tap->write.store(w + n, std::memory_order_release); - - // Fire capture_cb_ for each complete frame (frame_samples_ * ch flat samples). - const size_t frame_flat = static_cast(frame_samples_) * static_cast(ch); - while (true) { - size_t r = tap->read.load(std::memory_order_relaxed); - size_t avail = tap->write.load(std::memory_order_acquire) - r; - if (avail < frame_flat) break; - - std::vector frame(frame_flat); - for (size_t i = 0; i < frame_flat; ++i) - frame[i] = tap->ring[(r + i) % kInjectCapSamples]; - tap->read.store(r + frame_flat, std::memory_order_release); - - if (capture_cb_) capture_cb_(kind, frame.data(), frame_samples_, ch); - } -} - -void AudioEngine::inject_capture(int kind, const int16_t* pcm, size_t n) { - inject_capture(kind, pcm, n, 1); -} - -void AudioEngine::push_recv_frame(uint32_t ssrc, JitterBuffer::Frame f) { - std::lock_guard lk(streams_mu_); - auto& s = streams_[ssrc]; - s.last_voice_ms.store(now_ms(), std::memory_order_relaxed); - if (f.marker) s.pending_marker = true; // talkspurt start → force playout reseed - s.jitter.push(std::move(f)); -} - -void AudioEngine::set_stream_gain(uint32_t ssrc, float gain) { - std::lock_guard lk(streams_mu_); - streams_[ssrc].gain = gain; -} - -void AudioEngine::set_stream_mute(uint32_t ssrc, bool mute) { - std::lock_guard lk(streams_mu_); - streams_[ssrc].mute = mute; -} - -void AudioEngine::set_stream_noise_reduction(uint32_t ssrc, bool enable) { - std::lock_guard lk(streams_mu_); - auto& s = streams_[ssrc]; - s.noise_reduction_enabled = enable; - if (enable) { - if (!s.recv_ns) s.recv_ns = ApmProcessor::create(); - } else { - s.recv_ns.reset(); - } -} - -bool AudioEngine::get_stream_state(uint32_t ssrc, float& gain, bool& mute, bool& noise_reduction) { - std::lock_guard lk(streams_mu_); - auto it = streams_.find(ssrc); - if (it == streams_.end()) return false; - const auto& s = it->second; - gain = s.gain; - mute = s.mute; - noise_reduction = s.noise_reduction_enabled; - return true; -} - -void AudioEngine::remove_stream(uint32_t ssrc) { - std::lock_guard lk(streams_mu_); - auto it = streams_.find(ssrc); - if (it != streams_.end()) { - if (it->second.dred_state_) { - opus_dred_free(it->second.dred_state_); - it->second.dred_state_ = nullptr; - } - streams_.erase(it); - } -} - -std::vector> AudioEngine::poll_talk_transitions() { - std::vector> edges; - std::lock_guard lk(streams_mu_); - int64_t now = now_ms(); - for (auto& [ssrc, stream] : streams_) { - bool now_talking = (now - stream.last_voice_ms.load(std::memory_order_relaxed)) < - kTalkHangoverMs; - if (now_talking != stream.talking) { - stream.talking = now_talking; - edges.emplace_back(ssrc, now_talking); - } - } - return edges; -} - -uint32_t AudioEngine::stream_packets_lost(uint32_t ssrc) const { - std::lock_guard lk(streams_mu_); - auto it = streams_.find(ssrc); - return (it != streams_.end()) ? it->second.jitter.packets_lost() : 0; -} - -uint32_t AudioEngine::stream_target_depth_ms(uint32_t ssrc) const { - std::lock_guard lk(streams_mu_); - auto it = streams_.find(ssrc); - return (it != streams_.end()) ? it->second.jitter.target_depth_ms() : 40; -} - -uint32_t AudioEngine::stream_duplicates(uint32_t ssrc) const { - std::lock_guard lk(streams_mu_); - auto it = streams_.find(ssrc); - return (it != streams_.end()) ? it->second.jitter.duplicates() : 0; -} - -uint64_t AudioEngine::stream_underruns(uint32_t ssrc) const { - std::lock_guard lk(streams_mu_); - auto it = streams_.find(ssrc); - return (it != streams_.end()) - ? it->second.underruns.load(std::memory_order_relaxed) - : 0; -} - -int32_t AudioEngine::stream_playout_depth_samples(uint32_t ssrc) const { - std::lock_guard lk(streams_mu_); - auto it = streams_.find(ssrc); - if (it == streams_.end() || !it->second.playout_started) return 0; - auto newest = it->second.jitter.peek_back_ts(); - if (!newest) return 0; - return static_cast(*newest - it->second.playout_ts); -} - -void AudioEngine::init_recv_stream(uint32_t ssrc, const codec::OpusParams& p, - uint32_t user_id, uint32_t stream_id, bool is_voice) { - std::lock_guard lk(streams_mu_); - auto& stream = streams_[ssrc]; - stream.user_id = user_id; - stream.stream_id = stream_id; - stream.is_voice = is_voice; - stream.fec_enabled_ = p.fec; - stream.decoder.init(p); - // Ring must be sized for this decoder's actual channel/frame-size — see RemoteStream::ring - // comment in audio_engine.h for why this can't just be the playback callback's frame count. - int channels = std::max(1, stream.decoder.channels()); - int frame_samples = stream.decoder.frame_samples(); - if (frame_samples <= 0) frame_samples = static_cast(p.sample_rate / 1000 * p.frame_ms); - stream.init_ring(channels, frame_samples); - // The expected inter-arrival gap = the sender's frame size in samples @48 kHz; the jitter - // EWMA and silence-gap outlier rejection key off it (defaults to 20 ms otherwise). - stream.jitter.set_expected_gap(static_cast(frame_samples)); - // DRED: pre-allocate per-stream scratch (no RT-thread allocation). 4000 bytes > max Opus pkt. - stream.dred_payload_scratch_.assign(4000, 0); - if (!stream.dred_state_) { - int err = 0; - stream.dred_state_ = opus_dred_alloc(&err); // null on failure — falls back to PLC - } -} - -void AudioEngine::set_pcm_sink(PcmSink cb, void* user) { - std::lock_guard setter_lk(pcm_sink_set_mu_); - pcm_sink_.store(nullptr); - while (pcm_sink_active_.load() != 0) std::this_thread::yield(); - pcm_sink_user_.store(user); - pcm_sink_.store(cb); -} - -void AudioEngine::set_mixed_output_sink(MixedSink cb, void* user) { - std::lock_guard setter_lk(mixed_sink_set_mu_); - mixed_sink_.store(nullptr); - while (mixed_sink_active_.load() != 0) std::this_thread::yield(); - mixed_sink_user_.store(user); - mixed_sink_.store(cb); -} - -void AudioEngine::capture_data_cb(ma_device* dev, void* /*out*/, - const void* in, ma_uint32 frame_count) { - auto* self = static_cast(dev->pUserData); - self->on_capture(static_cast(in), frame_count); -} - -void AudioEngine::on_capture(const int16_t* pcm, ma_uint32 frames) { - // Accumulate samples until we have exactly frame_samples_ (e.g. 960 for 20 ms @ 48 kHz), - // then fire capture_cb_. WASAPI shared mode commonly delivers 480-sample (10 ms) callbacks - // regardless of the periodSizeInFrames hint above; passing a sub-frame chunk directly to - // opus_encode() returns OPUS_BAD_ARG (negative), silently dropping every mic frame. - // PCM here is interleaved across params_.capture_channels (1 = mono, 2 = stereo L/R — - // e.g. iOS stereo built-in mic via vc_set_capture_channels) — the accumulator was sized to - // frame_samples_*capture_channels in start(), so a memcpy into it can never overrun. - // capture_cb_ receives samples-per-channel (frame_samples_) and the channel count explicitly. - if (!capture_cb_ || frame_samples_ <= 0) return; - const int ch = std::max(1u, params_.capture_channels); - const int16_t* src = pcm; - auto remaining = static_cast(frames) * ch; - const int full = frame_samples_ * ch; - while (remaining > 0) { - int space = full - capture_accum_.count; - int copy = std::min(remaining, space); - std::memcpy(capture_accum_.buf.data() + capture_accum_.count, src, - static_cast(copy) * sizeof(int16_t)); - capture_accum_.count += copy; - src += copy; - remaining -= copy; - if (capture_accum_.count == full) { - capture_cb_(0, capture_accum_.buf.data(), frame_samples_, ch); - capture_accum_.count = 0; - } - } -} - -void AudioEngine::playback_data_cb(ma_device* dev, void* out, - const void* /*in*/, ma_uint32 frame_count) { - auto* self = static_cast(dev->pUserData); - self->on_playback(static_cast(out), frame_count); -} - -void AudioEngine::on_playback(int16_t* out, ma_uint32 frames) { - const uint32_t pb_channels = params_.playback_channels; - std::memset(out, 0, frames * pb_channels * sizeof(int16_t)); - - std::unique_lock lk(streams_mu_, std::try_to_lock); - if (!lk) return; // contended: emit silence this period - - std::vector mix(frames * pb_channels, 0); - - for (auto& [ssrc, stream] : streams_) { - if (stream.mute || !stream.decoder.valid()) continue; - - // A stream's Opus channel count (mono/stereo, per-channel AudioConfig) may differ from - // the engine-wide playback channel count - // dec_channels/frame_samples are bitstream properties (fixed at decoder init); `frames` - // below is the *hardware* playback callback's period, an independent value miniaudio - // picks on its own — opus_decode's max_samples must be frame_samples, never `frames`. - // The ring decouples the two: top it up by decoding whole Opus frames, then drain exactly - // `frames` samples-per-channel from it below (silence-padding on underrun = PLC). - const int dec_channels = std::max(1, stream.decoder.channels()); - const int frame_samples = stream.decoder.frame_samples(); - - const int32_t target = - std::max(static_cast(stream.jitter.target_depth_samples()), - kMinDepthSamples); - if (auto newest = stream.jitter.peek_back_ts()) { - int32_t depth = static_cast(*newest - stream.playout_ts); // wrap-safe - if (!stream.playout_started || stream.pending_marker || depth < -kStarveSamples) { - stream.playout_ts = *newest; - stream.playout_started = true; - stream.pending_marker = false; - } else if (depth > target + kCatchupSamples) { - stream.playout_ts = *newest - static_cast(target); - stream.jitter.drop_before(stream.playout_ts); - } - } - - while (stream.ring_count < frames && frame_samples > 0) { - auto maybe_frame = stream.jitter.pop(stream.playout_ts); - int n; - - if (maybe_frame) { - n = stream.decoder.decode( - maybe_frame->payload.data(), - static_cast(maybe_frame->payload.size()), - stream.decode_scratch.data(), frame_samples); - stream.plc_samples_since_real = 0; // real packet — reset PLC streak - } else if (stream.plc_samples_since_real >= kPlcCapSamples) { - // PLC cap exhausted: emit silence instead of more comfort noise. Keeps the - // ring fed and the playout clock advancing so timing is correct if the - // source resumes, but bounds the hiss to ~2 s (kPlcCapSamples). - std::memset(stream.decode_scratch.data(), 0, - static_cast(frame_samples) * stream.ring_channels * - sizeof(int16_t)); - n = frame_samples; - } else { - // Loss recovery for a missing frame, best-quality first: DRED (Opus 1.6 ML - // reconstruction) → in-band FEC (the low-bitrate copy of this frame the encoder - // embeds in the next packet) → PLC comfort noise. DRED and FEC both need the - // *next* packet already buffered, so copy it once and try each in turn. - n = -1; - uint32_t next_ts = stream.playout_ts + static_cast(frame_samples); - size_t psz = stream.jitter.try_copy_front_payload( - next_ts, stream.dred_payload_scratch_.data(), - stream.dred_payload_scratch_.size()); - - // 1. DRED: parse the next packet's deep-redundancy extension and reconstruct. - if (psz > 0 && dred_dec_ && stream.dred_state_) { - int dred_end = 0; - int ret = opus_dred_parse( - dred_dec_, stream.dred_state_, - stream.dred_payload_scratch_.data(), - static_cast(psz), - frame_samples, static_cast(params_.sample_rate), - &dred_end, 0); - if (ret > 0) - n = stream.decoder.decode_dred(stream.dred_state_, 0, - stream.decode_scratch.data(), - frame_samples); - } - - // 2. In-band FEC: reconstruct the lost frame from the redundant copy carried in - // the next packet (decode_fec=1). Only when FEC is negotiated and the next - // packet is present; if it carries no LBRR data libopus falls back to PLC, - // so this is at worst a no-op relative to the PLC path below. - if (n <= 0 && psz > 0 && stream.fec_enabled_) { - n = stream.decoder.decode( - stream.dred_payload_scratch_.data(), static_cast(psz), - stream.decode_scratch.data(), frame_samples, /*fec=*/true); - } - // 3. PLC: synthesize a continuation when no redundancy is available. - if (n <= 0) { - n = stream.decoder.decode(nullptr, 0, stream.decode_scratch.data(), - frame_samples); - } - if (n > 0) stream.plc_samples_since_real += n; // track concealment streak - } - if (n <= 0) break; // decoder error/exhausted PLC; rest of this period stays silent - - // `n` is samples-per-channel (matches the frame_samples convention used by - // OpusEncoder::encode elsewhere in the codebase). - // Receive-side NR runs only on VOICE (MIC) streams — - if (stream.recv_ns && stream.is_voice) { - int16_t* s = stream.decode_scratch.data(); - if (dec_channels == 2) { - for (int i = 0; i < n; ++i) // L/R -> mono, packed into [0..n) - s[i] = static_cast( - (static_cast(s[2 * i]) + static_cast(s[2 * i + 1])) / - 2); - stream.recv_ns->process_capture(s, n, static_cast(params_.sample_rate)); - for (int i = n - 1; i >= 0; --i) // mono -> both channels (back-to-front) - s[2 * i] = s[2 * i + 1] = s[i]; - } else if (dec_channels == 1) { - stream.recv_ns->process_capture(s, n, static_cast(params_.sample_rate)); - } - } - - // PCM sink: deliver decoded per-stream audio to external consumer (bots, - // transcription, recording) before it enters the hardware mix. The reader count - // makes disabling the callback a quiescence barrier while keeping this RT path - // lock-free and non-blocking. - pcm_sink_active_.fetch_add(1); - if (auto sink = pcm_sink_.load()) { - sink(pcm_sink_user_.load(), - stream.user_id, stream.stream_id, - stream.decode_scratch.data(), - static_cast(n), - static_cast(dec_channels), - params_.sample_rate); - } - pcm_sink_active_.fetch_sub(1); - - stream.push_ring(stream.decode_scratch.data(), static_cast(n)); - stream.playout_ts += static_cast(n); - } - - // Diagnostic: the decode loop couldn't keep the ring fed for this hardware period while - // the stream was actively playing out a genuine underrun (decoder error / exhausted - // PLC), distinct from ordinary single-packet loss the loop conceals in place. - if (stream.playout_started && stream.ring_count < frames) - stream.underruns.fetch_add(1, std::memory_order_relaxed); - - const float g = stream.gain; - int16_t frame_buf[2]; - for (ma_uint32 i = 0; i < frames; ++i) { - stream.pop_ring(frame_buf, 2); // zero-filled if the ring underran (PLC silence) - if (dec_channels == 2) { - int32_t l = static_cast(static_cast(frame_buf[0]) * g); - int32_t r = static_cast(static_cast(frame_buf[1]) * g); - if (pb_channels == 2) { - mix[i * 2] += l; - mix[i * 2 + 1] += r; - } else { - mix[i] += (l + r) / 2; // playback device fell back to mono - } - } else { - int32_t sample = static_cast(static_cast(frame_buf[0]) * g); - for (uint32_t c = 0; c < pb_channels; ++c) - mix[i * pb_channels + c] += sample; // upmix mono -> all playback channels - } - } - } - - const float ovol = output_volume_.load(std::memory_order_relaxed); - for (ma_uint32 i = 0; i < frames * pb_channels; ++i) { - int32_t s = static_cast(static_cast(mix[i]) * ovol); - out[i] = static_cast(std::clamp(s, -32768, 32767)); - } -} - -// External-playback timer (iOS VPIO): with no hardware playback device to "pull" frames, this -// dedicated thread drives the mixer on a steady cadence. It is NOT a real-time audio thread (a -// plain timed worker, same class as vc_client::run_talk_timer), but it must still not allocate -// in the loop because on_playback() takes streams_mu_ via try_lock and runs the RT-safe decode -// path — so mixer_scratch_ is pre-sized in start(). Uses a deadline-based sleep to bound drift. -void AudioEngine::run_mixer_timer() { - using clock = std::chrono::steady_clock; - const uint32_t ch = params_.playback_channels; - const uint32_t spc = static_cast(frame_samples_); - const auto period = std::chrono::milliseconds(params_.frame_ms); - auto next = clock::now(); - while (!mixer_timer_stop_.load(std::memory_order_acquire)) { - on_playback(mixer_scratch_.data(), spc); - mixed_sink_active_.fetch_add(1); - if (auto sink = mixed_sink_.load()) { - sink(mixed_sink_user_.load(), mixer_scratch_.data(), spc, ch, - params_.sample_rate); - } - mixed_sink_active_.fetch_sub(1); - next += period; - // If we fell badly behind (e.g. the thread was descheduled), reset the deadline rather - // than spin to catch up — the VPIO renderer rides its own clock + jitter ring. - auto now = clock::now(); - if (next < now) next = now + period; - std::this_thread::sleep_until(next); - } -} - -#ifdef VOICECAT_HAS_LOOPBACK -void AudioEngine::loopback_data_cb(ma_device* dev, void* /*out*/, const void* in, - ma_uint32 frame_count) { - auto* self = static_cast(dev->pUserData); - self->on_loopback(static_cast(in), frame_count); -} - -void AudioEngine::on_loopback(const int16_t* pcm, ma_uint32 frames) { - // Same accumulation as on_capture - - if (!capture_cb_ || frame_samples_ <= 0) return; - const int ch = std::max(1, loopback_channels_); - const int16_t* src = pcm; - auto remaining = static_cast(frames) * ch; - const int full = frame_samples_ * ch; - while (remaining > 0) { - int space = full - loopback_accum_.count; - int copy = std::min(remaining, space); - std::memcpy(loopback_accum_.buf.data() + loopback_accum_.count, src, - static_cast(copy) * sizeof(int16_t)); - loopback_accum_.count += copy; - src += copy; - remaining -= copy; - if (loopback_accum_.count == full) { - capture_cb_(loopback_kind_, loopback_accum_.buf.data(), frame_samples_, ch); - loopback_accum_.count = 0; - } - } -} - -bool AudioEngine::start_loopback_capture(int kind, int channels) { - if (loopback_started_) return false; // already running; stop_loopback_capture() first - int ch = std::max(1, channels); - loopback_accum_.count = 0; // discard any partial frame from a previous loopback session - - ma_device_config cfg = ma_device_config_init(ma_device_type_loopback); - cfg.capture.format = ma_format_s16; - cfg.capture.channels = static_cast(ch); - cfg.sampleRate = params_.sample_rate; - cfg.dataCallback = loopback_data_cb; - cfg.pUserData = this; - // pDeviceID left null: captures the default render endpoint - - ma_context* ctx = context_inited_ ? &context_ : nullptr; - if (ma_device_init(ctx, &cfg, &loopback_device_) != MA_SUCCESS) { - // Fallback: some unusual render endpoints may reject channels=2 even though WASAPI - // shared mode normally remixes transparently. Retry once at mono (mirror the playback - // device's fallback in start()) rather than leaving screen-audio capture dead. - if (ch != 1) { - ch = 1; - cfg.capture.channels = 1; - if (ma_device_init(ctx, &cfg, &loopback_device_) != MA_SUCCESS) return false; - } else { - return false; - } - } - if (ma_device_start(&loopback_device_) != MA_SUCCESS) { - ma_device_uninit(&loopback_device_); - return false; - } - - loopback_channels_ = ch; - loopback_accum_.buf.assign(static_cast(frame_samples_ * ch), 0); - loopback_accum_.count = 0; - loopback_kind_ = kind; - loopback_started_ = true; - return true; -} - -void AudioEngine::stop_loopback_capture() { - if (!loopback_started_) return; - ma_device_stop(&loopback_device_); - ma_device_uninit(&loopback_device_); - loopback_started_ = false; -} -#else // !VOICECAT_HAS_LOOPBACK -bool AudioEngine::start_loopback_capture(int /*kind*/, int /*channels*/) { return false; } -void AudioEngine::stop_loopback_capture() {} -#endif // VOICECAT_HAS_LOOPBACK - -} // namespace voicecat::audio diff --git a/core/src/audio/audio_engine.h b/core/src/audio/audio_engine.h deleted file mode 100644 index 694393e..0000000 --- a/core/src/audio/audio_engine.h +++ /dev/null @@ -1,542 +0,0 @@ -/* Capture, playback, jitter buffering, and mixing. */ -#ifndef VOICECAT_AUDIO_AUDIO_ENGINE_H -#define VOICECAT_AUDIO_AUDIO_ENGINE_H - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "codec/opus_codec.h" - -#include "audio/apm_processor.h" - -namespace voicecat::audio { - -// JitterBuffer -// Per-ssrc adaptive jitter buffer. Thread-safe via internal mutex. -class JitterBuffer { - public: - struct Frame { - uint64_t seq; - uint32_t timestamp; - bool fec_present; - bool marker = false; // kFlagMarker: first frame of a talkspurt - std::vector payload; - }; - - // Insert an incoming frame. Thread-safe. - void push(Frame f); - - // Return the next frame whose timestamp <= playout_ts, or nullopt (caller should PLC). - // Drops frames that are too old (more than kLateDropSamples late). - std::optional pop(uint32_t playout_ts); - - // Timestamp of the earliest buffered frame, or nullopt if empty/contended. Lets the playout - // clock seed/re-sync itself to the arriving stream rather than free-running (see - // AudioEngine::on_playback). Uses try_lock — never blocks the real-time callback. - std::optional peek_front_ts() const; - - // Timestamp of the newest (latest) buffered frame — the stream's leading edge. Lets the - // playout clock keep a bounded depth behind the arriving stream and catch up (frame-skip) - // when the backlog grows; see AudioEngine::on_playback. try_lock — never blocks the RT thread. - std::optional peek_back_ts() const; - - // If the front frame's timestamp == expected_ts, copies its raw Opus payload into out - // (caller-allocated, max_sz bytes). Returns bytes copied, or 0 (lock miss / wrong ts / - // empty). Caller pre-allocates out to avoid RT-thread allocation. Uses try_lock. - size_t try_copy_front_payload(uint32_t expected_ts, uint8_t* out, size_t max_sz); - - // Erase all buffered frames whose timestamp is strictly before `ts` (oldest-first). Used by - // the playout catch-up so a forward clock jump doesn't flood the decode loop with stale frames. - void drop_before(uint32_t ts); - - uint32_t target_depth_ms() const { return target_depth_ms_.load(); } - uint32_t target_depth_samples() const { return target_depth_ms_.load() * 48; } // @48 kHz - uint32_t packets_lost() const { return lost_.load(); } - uint32_t duplicates() const { return dup_.load(); } - - // Set the expected inter-arrival gap (= sender frame size in samples @48 kHz) so the jitter - // EWMA and silence-gap outlier rejection are correct for non-20 ms channels. Call at init. - void set_expected_gap(uint32_t samples) { - if (samples > 0) expected_gap_samples_ = samples; - } - void reset(); - - private: - static constexpr uint32_t kLateDropSamples = 48000 / 2; // 500 ms @48 kHz (hard floor/cap) - - mutable std::mutex mu_; - std::map buf_; // keyed by timestamp (u32 wraps are handled below) - - std::atomic target_depth_ms_{40}; - std::atomic lost_{0}; - std::atomic dup_{0}; - - // Jitter estimation (EWMA). - uint32_t last_push_ts_ = 0; // sender timestamp on last push - uint32_t jitter_est_ = 0; // EWMA jitter in samples - uint32_t expected_gap_samples_ = 960; // expected inter-arrival gap (frame size @48 kHz) - uint32_t newest_ts_ = 0; // latest buffered timestamp (leading edge) - bool have_newest_ = false; - bool first_push_ = true; -}; - -// AudioParams -struct AudioParams { - uint32_t sample_rate = 48000; - uint32_t capture_channels = 1; // mic capture: 1 = mono, 2 = stereo (set via vc_set_capture_channels) - uint32_t playback_channels = 2; // true stereo output (see audio_engine.cpp on_playback) - uint32_t frame_ms = 20; - std::string capture_device_id; // "" = default; opaque id from AudioEngine::enumerate_devices - std::string playback_device_id; // "" = default; opaque id from AudioEngine::enumerate_devices - // External capture: the mic is fed via inject_capture/vc_stream_feed_pcm (e.g. iOS VPIO), - // so start() skips opening the hardware capture device. Derived from the MIC LocalStream's - // external_feed flag in vc_client::ensure_audio_running(). - bool external_capture = false; -}; - -// One enumerated device, returned by AudioEngine::enumerate_devices(). `id` is an internal, -// opaque hex-encoded ma_device_id — callers must always round-trip an id that came from -// enumerate_devices(); never construct one by hand (names aren't guaranteed unique, so the id -// is the only stable handle miniaudio accepts back for device selection). -struct DeviceInfo { - std::string id; - std::string name; - bool is_default = false; -}; - -// AudioEngine -// Owns miniaudio capture/playback, per-ssrc jitter buffers + Opus decoders, and the mixer. -class AudioEngine { - public: - // Callback type for encoded capture frames ready to be sent. `kind` identifies which - // local stream this PCM belongs to (a vc_stream_kind value; 0 = MIC for the real capture - // device, which is always the "primary" tap). `channels` is the channel count of the PCM - // buffer (1 = mono, 2 = stereo interleaved) — the mic capture device is mono by default, but - // the WASAPI loopback path (SCREEN_AUDIO) captures in the channel's mode when stereo, so - // the encoder sees real interleaved L/R PCM rather than a mono upmix. Multiple concurrent - // local streams are possible (e.g. MIC + SCREEN_AUDIO), each fed via its own injection tap - // (see inject_capture) since there is only one real hardware capture device. - using CaptureCallback = std::function; - - AudioEngine(); - ~AudioEngine(); - - AudioEngine(const AudioEngine&) = delete; - AudioEngine& operator=(const AudioEngine&) = delete; - - // Start capture+playback devices. capture_cb is called on the encode thread - // for each capture frame (not on the audio callback thread). - bool start(const AudioParams& p, CaptureCallback capture_cb = nullptr); - void stop(); - - bool running() const { return running_.load(std::memory_order_acquire); } - - // Pause/resume miniaudio device I/O (called from vc_audio_suspend/resume for - // AVAudioSession interruptions on iOS). Thread-safe via ma_device_stop/start. - // Returns true on success; false if a device stop/start fails. - bool suspend(); - bool resume(); - - // Enumerate input or output devices (for UI pickers / vc_list_devices). Static: works - // before any AudioEngine instance is running (device pickers need to populate pre-connect). - // Inits a throwaway ma_context if VOICECAT_HAS_AUDIO; returns {} otherwise. See DeviceInfo - // above for the `id` encoding contract. - static std::vector enumerate_devices(bool capture); - - // Real desktop-audio loopback capture (Windows/WASAPI only, VOICECAT_HAS_LOOPBACK). Feeds - // `kind`'s capture_cb_ directly, same pattern as the real mic capture device — NOT routed - // through inject_capture()'s test-only ring. `channels` is the channel count to open the - // loopback device with (1 = mono downmix of the system mix, 2 = stereo capture when the - // channel is configured stereo); the accumulator and capture_cb_ invocation are shaped to - // match. No-op (returns false) when unsupported. - // This has mostly been replaced by client-specific code, so this is likely ready to be revisited. - bool start_loopback_capture(int kind, int channels); - void stop_loopback_capture(); - - // Inject synthetic PCM directly into the capture pipeline (bypasses real device). - // Thread-safe; can be called from any thread including tests. `kind` selects which local - // stream's injection tap to feed (each gets its own ring buffer); the 2-arg overload - // targets kind 0 (MIC) for source compatibility with existing callers. - void inject_capture(int kind, const int16_t* pcm, size_t n); - void inject_capture(const int16_t* pcm, size_t n) { inject_capture(0, pcm, n); } - - // Called by the net thread when a decoded voice frame arrives for a remote stream. - void push_recv_frame(uint32_t ssrc, JitterBuffer::Frame f); - - // Global output volume applied after mixing all streams (default 1.0). Thread-safe. - void set_output_volume(float gain) { output_volume_.store(gain, std::memory_order_relaxed); } - - // Per-stream receive-side controls (safe from any thread). - void set_stream_gain(uint32_t ssrc, float gain); // 0.0–2.0, default 1.0 - void set_stream_mute(uint32_t ssrc, bool mute); - // Listener-chosen, local-only noise reduction on a specific remote stream - // lazily instantiates an ApmProcessor on first enable, frees it on disable. - void set_stream_noise_reduction(uint32_t ssrc, bool enable); - // Read back a stream's current receive-side state. Returns true and fills *out if the - // stream is known (even if defaults — gain=1, mute=false, nr=false), false if it has - // never been seen (no RemoteStream entry yet). - bool get_stream_state(uint32_t ssrc, float& gain, bool& mute, bool& noise_reduction); - void remove_stream(uint32_t ssrc); - - // Edge-triggered talk-state transitions since the last call : talk state - // is derived from recent frame arrival, no protocol message). Call from a lightweight - // poller, not the audio callback thread. Returns {ssrc, now_talking} for each stream whose - // state flipped. - std::vector> poll_talk_transitions(); - - // Get stats for a remote stream's jitter buffer. - uint32_t stream_packets_lost(uint32_t ssrc) const; - uint32_t stream_target_depth_ms(uint32_t ssrc) const; - uint32_t stream_duplicates(uint32_t ssrc) const; - uint64_t stream_underruns(uint32_t ssrc) const; - - // TEST-ONLY: current playout depth in samples (newest buffered ts - playout_ts), i.e. the - // standing latency held in the jitter buffer. Used by test_jitter_depth to assert the - // bounded-depth invariant. Returns 0 if the stream is unknown or not yet playing out. - int32_t stream_playout_depth_samples(uint32_t ssrc) const; - - // Configure the Opus decoder for an incoming ssrc (must be called before - // push_recv_frame for that ssrc). user_id/stream_id identify the source for the - // pcm_sink_ callback. is_voice marks a MIC stream so the receive-side NR pass knows it may - // denoise it (a stereo mic is folded to mono first); screen-audio shares are never voice. - // Thread-safe. - void init_recv_stream(uint32_t ssrc, const codec::OpusParams& p, - uint32_t user_id, uint32_t stream_id, bool is_voice); - - // External PCM tap: callback fired once per decoded Opus frame per remote stream, on the - // playback (RT) thread. Matching signature to vc_pcm_sink_cb (cast at the C-ABI boundary). - // Pass nullptr to disable. Thread-safe (atomic store; the RT read is relaxed-load). - using PcmSink = void(*)(void*, uint32_t, uint32_t, const int16_t*, size_t, uint32_t, uint32_t); - void set_pcm_sink(PcmSink cb, void* user); - - // External mixed-output sink (iOS VPIO). Receives the FINAL mixed PCM (post output-volume, - // all remote streams summed) on the mixer-timer thread when external playback is enabled. - // Matching signature to vc_mixed_output_cb (cast at the C-ABI boundary). Pass nullptr to - // disable. Thread-safe (atomic store; the timer read is relaxed-load). - using MixedSink = void(*)(void*, const int16_t*, size_t, uint32_t, uint32_t); - void set_mixed_output_sink(MixedSink cb, void* user); - - // External-playback mode (iOS VPIO): when enabled, start() does NOT open a hardware - // playback device; a timer thread drives the mixer (on_playback) on a ~20 ms cadence and - // ships the final mix to the mixed-output sink. Set before start() (or apply via restart). - void set_external_playback(bool enable) { external_playback_ = enable; } - - // External PCM feed overload: stereo-aware variant of inject_capture. samples_per_channel - // is samples per channel; total samples written = samples_per_channel * channels. - void inject_capture(int kind, const int16_t* pcm, size_t samples_per_channel, int channels); - - // TEST-ONLY — exposes the playback mixer without a real ma_device, so tests can verify - // stereo mixing end-to-end (no audio hardware needed). Same logic the real playback - // callback uses - void mix_for_test(int16_t* out, uint32_t frames) { on_playback(out, frames); } - - // TEST-ONLY — drives the capture-side frame accumulator directly with an explicit - // callback, bypassing capture_cb_. Call after engine.start(p) WITHOUT a capture - // callback so the real mic (if any) never touches capture_accum_ (on_capture returns - // early when capture_cb_ is null). The explicit `cb` is invoked only when a full - // frame_samples_ chunk is ready — that is the invariant under test. - void feed_capture_for_test(const int16_t* pcm, int frames, const CaptureCallback& cb) { - if (frame_samples_ <= 0 || !cb || capture_accum_.buf.empty()) return; - const int16_t* src = pcm; - auto remaining = frames; - while (remaining > 0) { - int space = frame_samples_ - capture_accum_.count; - int copy = std::min(remaining, space); - std::memcpy(capture_accum_.buf.data() + capture_accum_.count, src, - static_cast(copy) * sizeof(int16_t)); - capture_accum_.count += copy; - src += copy; - remaining -= copy; - if (capture_accum_.count == frame_samples_) { - cb(0, capture_accum_.buf.data(), frame_samples_, 1); - capture_accum_.count = 0; - } - } - } - // TEST-ONLY — stereo-aware variant: drives the capture accumulator with interleaved L/R - // PCM (channels=2) or mono (channels=1). Sizes the accumulator to frame_samples_*channels - // and invokes `cb` with the channel count passed through — mirrors feed_loopback_for_test. - // Use to verify stereo mic capture (vc_set_capture_channels on_capture's accumulator). - void feed_capture_for_test(const int16_t* pcm, int frames_per_channel, int channels, - const CaptureCallback& cb) { - if (frame_samples_ <= 0 || !cb) return; - const int ch = std::max(1, channels); - const int full = frame_samples_ * ch; - if (static_cast(capture_accum_.buf.size()) != full) { - capture_accum_.buf.assign(static_cast(full), 0); - capture_accum_.count = 0; - } - const int16_t* src = pcm; - auto remaining = frames_per_channel * ch; - while (remaining > 0) { - int space = full - capture_accum_.count; - int copy = std::min(remaining, space); - std::memcpy(capture_accum_.buf.data() + capture_accum_.count, src, - static_cast(copy) * sizeof(int16_t)); - capture_accum_.count += copy; - src += copy; - remaining -= copy; - if (capture_accum_.count == full) { - cb(0, capture_accum_.buf.data(), frame_samples_, ch); - capture_accum_.count = 0; - } - } - } - -#ifdef VOICECAT_HAS_LOOPBACK - // TEST-ONLY — drives the loopback accumulator directly with an explicit callback, the - // loopback analogue of feed_capture_for_test. Self-contained: sizes the accumulator and - // sets loopback_channels_ itself, so it works on headless CI where start_loopback_capture - // can't init a real WASAPI device. `channels` selects mono (1) or interleaved stereo (2). - // PCM is interleaved L/R when channels==2. Invokes `cb` once per full frame_samples_ - // per-channel chunk, with the channel count passed through so the encoder branch in - // on_capture_frame sees real stereo (channels==2) rather than a mono upmix. - void feed_loopback_for_test(const int16_t* pcm, int frames_per_channel, int channels, - const CaptureCallback& cb) { - if (frame_samples_ <= 0 || !cb) return; - const int ch = std::max(1, channels); - const int full = frame_samples_ * ch; - // Size the accumulator for the requested channel count (off the RT thread; this is a - // test-only path). start_loopback_capture() does the same sizing when it opens a real - // device, but on headless CI that init fails — so do it here too. - if (static_cast(loopback_accum_.buf.size()) != full) { - loopback_accum_.buf.assign(static_cast(full), 0); - loopback_accum_.count = 0; - } - loopback_channels_ = ch; - const int16_t* src = pcm; - auto remaining = frames_per_channel * ch; - while (remaining > 0) { - int space = full - loopback_accum_.count; - int copy = std::min(remaining, space); - std::memcpy(loopback_accum_.buf.data() + loopback_accum_.count, src, - static_cast(copy) * sizeof(int16_t)); - loopback_accum_.count += copy; - src += copy; - remaining -= copy; - if (loopback_accum_.count == full) { - cb(loopback_kind_, loopback_accum_.buf.data(), frame_samples_, ch); - loopback_accum_.count = 0; - } - } - } -#endif - - private: - static void capture_data_cb(ma_device*, void*, const void*, ma_uint32); - static void playback_data_cb(ma_device*, void*, const void*, ma_uint32); - void on_capture(const int16_t* pcm, ma_uint32 frames); - void on_playback(int16_t* out, ma_uint32 frames); - - // Build the ma_context config that keeps miniaudio from managing AVAudioSession on iOS. - // With a NULL context, ma_device_init runs miniaudio's iOS "hack" (miniaudio.h ~44057) - // that calls setCategory()/setActive() on EVERY device open — capture - // AVAudioSessionCategoryRecord with zero options. That obliterates the category/mode/ - // options the Swift IOSAudioRouter configured (notably .playAndRecord and - // .allowBluetoothA2DP), which is what killed headphone/A2DP output when stereo was - // selected. The iOS Swift layer is the SOLE owner of the audio session (activated in - // AppState on connect, configured by IOSAudioRouter); miniaudio must only open the - // AudioUnit against the already-configured, already-active route. These coreaudio fields - // are no-ops on non-Apple backends. - static ma_context_config make_context_config(); - - // Owned context, shared by the playback, capture, and loopback devices so they all honor - // the no-session-management config above. Lives for the engine's lifetime (init lazily in - // start(), reused across stop()/start() restarts, uninit in the destructor) — loopback has - // an independent start/stop lifecycle, so the context must outlive a single stop(). - ma_context context_{}; - bool context_inited_ = false; - - ma_device capture_device_{}; - ma_device playback_device_{}; - bool capture_started_ = false; - bool playback_started_ = false; - - // External-playback mode (iOS VPIO): no hardware playback device; this timer thread drives - // on_playback() on a ~20 ms cadence and delivers the final mix to mixed_sink_. mixer_scratch_ - // is pre-sized in start() (frame_samples_ * playback_channels) so the loop never allocates. - std::thread mixer_timer_thread_; - std::atomic mixer_timer_stop_{false}; - std::vector mixer_scratch_; - void run_mixer_timer(); - -#ifdef VOICECAT_HAS_LOOPBACK - // Desktop-audio loopback capture (SCREEN_AUDIO) — own lifecycle, decoupled from - // capture_device_/playback_device_ start/stop (a screen-share can start/stop independently - // of the mic and of whether anything is currently playing back). - static void loopback_data_cb(ma_device*, void*, const void*, ma_uint32); - void on_loopback(const int16_t* pcm, ma_uint32 frames); - - ma_device loopback_device_{}; - bool loopback_started_ = false; - int loopback_kind_ = 0; - int loopback_channels_ = 1; // channel count the loopback device was opened with -#endif - - AudioParams params_{}; - CaptureCallback capture_cb_; - std::atomic running_{false}; - std::atomic output_volume_{1.0f}; - - // Device callback periods are independent of codec frame size. These preallocated - // accumulators emit complete frames without allocating on an RT thread. - struct CaptureAccum { - std::vector buf; // pre-sized to frame_samples_ in start() - int count = 0; - }; - CaptureAccum capture_accum_; // mic / real capture device (on_capture) - CaptureAccum loopback_accum_; // screen-audio WASAPI loopback (on_loopback) - - // Inject ring(s): stores raw int16 PCM written by inject_capture(), one ring per local - // stream kind so e.g. MIC and SCREEN_AUDIO can each be fed independently in tests. - // The encode thread reads from these (no real capture device needed in tests). - struct InjectTap { - std::vector ring; // circular, size = kInjectCapSamples - std::atomic write{0}; - std::atomic read{0}; - int channels{1}; // channel count last written; resets ring on change - }; - std::mutex inject_mu_; - std::unordered_map> inject_taps_; - static constexpr size_t kInjectCapSamples = 48000 * 2; // 2 s @48 kHz mono - - // Per remote stream (protected by streams_mu_). - struct RemoteStream { - JitterBuffer jitter; - codec::OpusDecoder decoder; - float gain = 1.0f; - bool mute = false; - uint32_t playout_ts = 0; - // Re-seeded from the stream timeline after late joins and transmission gaps. - bool playout_started = false; - - // A talkspurt marker forces playout-clock reseeding. - bool pending_marker = false; - - // Diagnostic: times the decode/playback ring underran (produced silence because the - // jitter buffer had nothing due) while the stream was actively playing out — i.e. the - // "frames arriving but silent / latency starved" signal. Polled via stream_underruns(). - std::atomic underruns{0}; - - // Bounds consecutive PLC output so a stale stream eventually becomes silent. - int64_t plc_samples_since_real = 0; - - // Listener-chosen, local-only noise reduction (docs/voice.md §10). Lazily - // created only when enabled — bounded by how many remote streams this listener - // subscribes to, so no separate instance cap is needed. - bool noise_reduction_enabled = false; - std::unique_ptr recv_ns; - - // True for a MIC stream (voice). The receive-side NR pass only denoises voice — a stereo - // mic is folded to mono first (RNNoise is mono-only); a stereo screen-audio share is not - // voice and is left untouched. Set in init_recv_stream. (docs/voice.md §10.) - bool is_voice = false; - - // DRED: pre-allocated scratch for loss recovery. dred_state_ is per-stream; see - // AudioEngine::dred_dec_ (shared). Allocated in init_recv_stream(); freed in remove_stream(). - ::OpusDRED* dred_state_ = nullptr; - std::vector dred_payload_scratch_; // pre-sized to 4000 bytes - - // In-band FEC: whether the sender negotiated OPUS_SET_INBAND_FEC for this stream. - // When set, on_playback attempts decode_fec=1 from the next buffered packet to recover a - // lost frame (between DRED and PLC). Captured from OpusParams at init_recv_stream(). - bool fec_enabled_ = false; - - // Source identity: stored at init_recv_stream() so the pcm_sink_ callback can receive - // (user_id, stream_id) without a separate map lookup from the RT playback thread. - uint32_t user_id = 0; - uint32_t stream_id = 0; - - // Talk-indicator edge detection updated by push_recv_frame - // (already off the real-time audio thread), polled by poll_talk_transitions(). - std::atomic last_voice_ms{0}; - bool talking = false; - - // Decoding uses the bitstream frame size, while playback drains the device callback - // size. This preallocated ring decouples those clocks and is never resized on the RT path. - std::vector ring; // capacity = (frame_samples * 8) frames * ring_channels - size_t ring_channels = 1; - size_t ring_head = 0; // next frame (sample-per-channel) to read - size_t ring_count = 0; // buffered frames (samples-per-channel) ready - std::vector decode_scratch; // pre-sized: frame_samples * ring_channels - - void init_ring(int channels, int frame_samples) { - ring_channels = static_cast(std::max(1, channels)); - size_t cap_frames = static_cast(std::max(1, frame_samples)) * 8; // ~160ms @20ms frames - ring.assign(cap_frames * ring_channels, 0); - ring_head = 0; - ring_count = 0; - decode_scratch.assign(static_cast(std::max(1, frame_samples)) * ring_channels, 0); - } - - // Appends `n_frames` samples-per-channel (ring_channels each) from `pcm`. Drops the - // tail (rather than overwriting unread data) if the ring is unexpectedly full — should - // not happen with the generous 8x sizing above. - void push_ring(const int16_t* pcm, size_t n_frames) { - if (ring.empty() || ring_channels == 0) return; - size_t cap_frames = ring.size() / ring_channels; - for (size_t i = 0; i < n_frames; ++i) { - if (ring_count >= cap_frames) return; - size_t widx = (ring_head + ring_count) % cap_frames; - for (size_t c = 0; c < ring_channels; ++c) - ring[widx * ring_channels + c] = pcm[i * ring_channels + c]; - ++ring_count; - } - } - - // Pops one frame (sample-per-channel) into `out` (sized `out_channels`, zero-filled - // first — covers both a fully-drained ring and ring_channels < out_channels). - void pop_ring(int16_t* out, size_t out_channels) { - for (size_t c = 0; c < out_channels; ++c) out[c] = 0; - if (ring_count == 0 || ring.empty() || ring_channels == 0) return; - size_t cap_frames = ring.size() / ring_channels; - for (size_t c = 0; c < ring_channels && c < out_channels; ++c) - out[c] = ring[ring_head * ring_channels + c]; - ring_head = (ring_head + 1) % cap_frames; - --ring_count; - } - }; - mutable std::mutex streams_mu_; - std::unordered_map streams_; - - int frame_samples_ = 960; // 20 ms @48 kHz - - // External PCM tap. The in-flight counter lets set_pcm_sink(nullptr, nullptr) wait for an - // already-entered callback without making the RT thread lock or block. Sequentially - // consistent operations are intentional here: once the setter observes zero readers, a - // later reader must observe the disabled callback before it can dereference the old user. - std::atomic pcm_sink_{nullptr}; - std::atomic pcm_sink_user_{nullptr}; - std::atomic pcm_sink_active_{0}; - std::mutex pcm_sink_set_mu_; - - // External mixed-output sink + mode flag (iOS VPIO). mixed_sink_ is written by - // set_mixed_output_sink (any thread); read by run_mixer_timer via relaxed load. - // external_playback_ is read in start() to gate hardware-playback-device creation. - std::atomic mixed_sink_{nullptr}; - std::atomic mixed_sink_user_{nullptr}; - std::atomic mixed_sink_active_{0}; - std::mutex mixed_sink_set_mu_; - bool external_playback_ = false; - - ::OpusDREDDecoder* dred_dec_ = nullptr; // shared DRED decoder; null if unsupported - - static constexpr int64_t kTalkHangoverMs = 300; -}; - -} // namespace voicecat::audio - -#endif // VOICECAT_AUDIO_AUDIO_ENGINE_H diff --git a/core/src/codec/opus_codec.cpp b/core/src/codec/opus_codec.cpp deleted file mode 100644 index c5fd673..0000000 --- a/core/src/codec/opus_codec.cpp +++ /dev/null @@ -1,104 +0,0 @@ -#include "codec/opus_codec.h" - -namespace voicecat::codec { - -// Map an intended channel/capture sample rate (Hz) to the Opus max-bandwidth constant. The -// codec always runs at 48 kHz internally. this caps the bandwidth the -// encoder will select so a channel can request narrowband/wideband audio for low-bitrate rooms. -// 0 (unset) and >= 48000 map to FULLBAND, which is the encoder default (i.e. a no-op cap). -static opus_int32 opus_max_bandwidth_for(uint32_t rate_hz) { - if (rate_hz == 0) return OPUS_BANDWIDTH_FULLBAND; - if (rate_hz <= 8000) return OPUS_BANDWIDTH_NARROWBAND; // ~4 kHz audio - if (rate_hz <= 12000) return OPUS_BANDWIDTH_MEDIUMBAND; // ~6 kHz - if (rate_hz <= 16000) return OPUS_BANDWIDTH_WIDEBAND; // ~8 kHz - if (rate_hz <= 24000) return OPUS_BANDWIDTH_SUPERWIDEBAND; // ~12 kHz - return OPUS_BANDWIDTH_FULLBAND; // ~20 kHz -} - -// OpusEncoder - -bool OpusEncoder::init(const OpusParams& p) { - destroy(); - channels_ = p.stereo ? 2 : 1; - frame_samples_ = opus_frame_samples(p); - - int opus_application = OPUS_APPLICATION_VOIP; - switch (p.application) { - case OpusApplication::Audio: opus_application = OPUS_APPLICATION_AUDIO; break; - case OpusApplication::LowDelay: opus_application = OPUS_APPLICATION_RESTRICTED_LOWDELAY; break; - case OpusApplication::Voip: default: opus_application = OPUS_APPLICATION_VOIP; break; - } - - int err = 0; - enc_ = opus_encoder_create(static_cast(p.sample_rate), channels_, - opus_application, &err); - if (err != OPUS_OK || !enc_) { - err_ = opus_strerror(err); - return false; - } - - opus_encoder_ctl(enc_, OPUS_SET_BITRATE(static_cast(p.bitrate_bps))); - opus_encoder_ctl(enc_, OPUS_SET_MAX_BANDWIDTH(opus_max_bandwidth_for(p.max_bandwidth_hz))); - opus_encoder_ctl(enc_, OPUS_SET_COMPLEXITY(static_cast(p.complexity))); - opus_encoder_ctl(enc_, OPUS_SET_INBAND_FEC(p.fec ? 1 : 0)); - opus_encoder_ctl(enc_, OPUS_SET_DTX(p.dtx ? 1 : 0)); - opus_encoder_ctl(enc_, OPUS_SET_PACKET_LOSS_PERC( - static_cast(p.expected_packet_loss))); - // DRED: embed enough redundancy to cover one full previous frame at any frame size. - // OPUS_SET_DRED_DURATION takes units of 10ms; ceil(frame_ms/10) ensures one complete frame - // of ML-reconstructed redundancy regardless of whether the channel runs at 10/20/40/60 ms. - // At 10ms frames this yields 1 unit (one frame back); a burst-loss floor of 2 ensures - // two consecutive 10ms frames can be recovered. Cost: ~800 bps per 10ms unit. - uint32_t dred_units = std::max(2u, (p.frame_ms + 9u) / 10u); - opus_encoder_ctl(enc_, OPUS_SET_DRED_DURATION(p.dred ? static_cast(dred_units) : 0)); - return true; -} - -int OpusEncoder::encode(const int16_t* pcm, int frame_samples, uint8_t* out_buf, int out_cap) { - if (!enc_) return -1; - int n = opus_encode(enc_, pcm, frame_samples, out_buf, out_cap); - if (n < 0) { err_ = opus_strerror(n); return -1; } - return n; -} - -void OpusEncoder::destroy() { - if (enc_) { opus_encoder_destroy(enc_); enc_ = nullptr; } -} - -// OpusDecoder - -bool OpusDecoder::init(const OpusParams& p) { - destroy(); - channels_ = p.stereo ? 2 : 1; - frame_samples_ = opus_frame_samples(p); - - int err = 0; - dec_ = opus_decoder_create(static_cast(p.sample_rate), channels_, &err); - if (err != OPUS_OK || !dec_) { - err_ = opus_strerror(err); - return false; - } - return true; -} - -int OpusDecoder::decode(const uint8_t* opus_data, int len, int16_t* out_pcm, int max_samples, - bool fec) { - if (!dec_) return -1; - int n = opus_decode(dec_, opus_data, len, out_pcm, max_samples, fec ? 1 : 0); - if (n < 0) { err_ = opus_strerror(n); return -1; } - return n; -} - -int OpusDecoder::decode_dred(::OpusDRED* dred, int32_t dred_offset, - int16_t* out_pcm, int max_samples) { - if (!dec_ || !dred) return -1; - int n = opus_decoder_dred_decode(dec_, dred, dred_offset, out_pcm, max_samples); - if (n < 0) { err_ = opus_strerror(n); return -1; } - return n; -} - -void OpusDecoder::destroy() { - if (dec_) { opus_decoder_destroy(dec_); dec_ = nullptr; } -} - -} // namespace voicecat::codec diff --git a/core/src/codec/opus_codec.h b/core/src/codec/opus_codec.h deleted file mode 100644 index a7087ab..0000000 --- a/core/src/codec/opus_codec.h +++ /dev/null @@ -1,119 +0,0 @@ -/* - * codec/opus_codec.h: Opus encode/decode (libopus 1.6). - * - * Per-channel AudioConfig (mono/stereo, bitrate, frame size, - * FEC, DTX, complexity). The server relays Opus payloads unmodified (no transcode). - */ -#ifndef VOICECAT_CODEC_OPUS_CODEC_H -#define VOICECAT_CODEC_OPUS_CODEC_H - -#include -#include - -#include - -namespace voicecat::codec { - -// Mirrors voicecat::v1::OpusApplication (proto/voicecat.proto) without depending on -// generated protobuf headers from this low-level codec module. -enum class OpusApplication { - Voip = 0, // speech, optimized for low-rate intelligibility - Audio = 1, // music/screen-audio, optimized for fidelity - LowDelay = 2, // monitoring, minimal algorithmic delay -}; - -struct OpusParams { - uint32_t sample_rate = 48000; - // Intended channel/capture sample rate (Hz), used ONLY to cap the encoder's audio bandwidth - // (narrowband/wideband/…) via OPUS_SET_MAX_BANDWIDTH. this lets a low-bitrate channel constrain encoded bandwidth - // without changing the PCM clock. 0 = unset full band. Decoder ignores it. - uint32_t max_bandwidth_hz = 0; - uint32_t bitrate_bps = 24000; - uint32_t frame_ms = 20; - bool stereo = false; - bool fec = true; - bool dtx = false; - uint32_t complexity = 10; - uint32_t expected_packet_loss = 0; // % 0..100 - OpusApplication application = OpusApplication::Voip; - bool dred = false; -}; - -// Returns frame_samples for a given sample_rate + frame_ms. -inline int opus_frame_samples(const OpusParams& p) { - return static_cast(p.sample_rate / 1000 * p.frame_ms); -} - -class OpusEncoder { - public: - OpusEncoder() = default; - ~OpusEncoder() { destroy(); } - - OpusEncoder(const OpusEncoder&) = delete; - OpusEncoder& operator=(const OpusEncoder&) = delete; - - // Initialise with the given params. Must be called before encode(). - // Returns true on success; check error_string() on failure. - bool init(const OpusParams& p); - - // Encode one frame of PCM (frame_ms ms @ sample_rate Hz, mono or stereo). - // pcm: interleaved int16 samples (frame_samples * channels samples). - // out_buf: caller-allocated output buffer (recommend >= 4000 bytes). - // Returns number of bytes written to out_buf, or -1 on error. - int encode(const int16_t* pcm, int frame_samples, uint8_t* out_buf, int out_cap); - - void destroy(); - - bool valid() const { return enc_ != nullptr; } - int frame_samples()const { return frame_samples_; } - int channels() const { return channels_; } - const char* error_string() const { return err_; } - - private: - ::OpusEncoder* enc_ = nullptr; - int frame_samples_ = 0; - int channels_ = 1; - const char* err_ = nullptr; -}; - -class OpusDecoder { - public: - OpusDecoder() = default; - ~OpusDecoder() { destroy(); } - - OpusDecoder(const OpusDecoder&) = delete; - OpusDecoder& operator=(const OpusDecoder&) = delete; - - // Initialise. Must be called before decode(). - bool init(const OpusParams& p); - - // Decode one Opus packet into out_pcm (frame_samples * channels int16 samples). - // opus_data=nullptr, len=0 PLC (free, always enabled by libopus). - // fec=true, next valid packet in opus_data → FEC recovery from previous loss. - // Returns number of samples decoded (= frame_samples), or -1 on error. - int decode(const uint8_t* opus_data, int len, int16_t* out_pcm, int max_samples, - bool fec = false); - - // Decode a lost frame using pre-parsed DRED state from the next received packet. - // dred_offset=0 means the frame immediately before the next packet. - // Returns frame_samples on success, -1 if DRED unavailable or decode failed. - int decode_dred(::OpusDRED* dred, int32_t dred_offset, int16_t* out_pcm, int max_samples); - ::OpusDecoder* raw() const { return dec_; } - - void destroy(); - - bool valid() const { return dec_ != nullptr; } - int frame_samples()const { return frame_samples_; } - int channels() const { return channels_; } - const char* error_string() const { return err_; } - - private: - ::OpusDecoder* dec_ = nullptr; - int frame_samples_ = 0; - int channels_ = 1; - const char* err_ = nullptr; -}; - -} // namespace voicecat::codec - -#endif // VOICECAT_CODEC_OPUS_CODEC_H diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp deleted file mode 100644 index db6a703..0000000 --- a/core/src/core/client.cpp +++ /dev/null @@ -1,2155 +0,0 @@ -#include "core/client.h" - -#ifdef _WIN32 -# ifndef WIN32_LEAN_AND_MEAN -# define WIN32_LEAN_AND_MEAN -# endif -# include -# include - using sock_t = SOCKET; - static constexpr sock_t kBadSock = INVALID_SOCKET; - static void close_sock(sock_t s) { ::closesocket(s); } -#else -# include -# include -# include -# include -# include - using sock_t = int; - static constexpr sock_t kBadSock = -1; - static void close_sock(sock_t s) { ::close(s); } -#endif - -#include -#include -#include -#include -#include -#include - -#include "protocol/protocol.h" - -namespace { - -// Build a length-prefixed frame from an Envelope and return the raw bytes. -std::vector make_frame(const voicecat::v1::Envelope& env) { - std::vector out; - voicecat::protocol::encode_envelope(env, out); - return out; -} - -} // namespace - - - -vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) { - // TOFU: NULL/empty tofu_store_path falls back to a relative default so existing tests - // (which never set this field) keep working without real per-user persistence. - std::filesystem::path tofu_path = (cfg.tofu_store_path && cfg.tofu_store_path[0]) - ? std::filesystem::path(cfg.tofu_store_path) - : std::filesystem::path("voicecat_tofu_pins.txt"); - tofu_store_ = std::make_unique(std::move(tofu_path)); -} - -vc_client::~vc_client() { disconnect(); } - -void vc_client::emit(const vc_event& ev) const { - if (cb_.on_event) cb_.on_event(cb_.user, &ev); -} - -void vc_client::set_state(vc_connection_state s) { - state_net_.store(s, std::memory_order_release); - vc_event ev{}; - ev.type = VC_EVENT_CONNECTION_STATE; - ev.connection_state = s; - emit(ev); -} - -void vc_client::emit_error(vc_result r, const char* text) { - vc_event ev{}; - ev.type = VC_EVENT_ERROR; - ev.result = static_cast(r); - ev.text = text; - emit(ev); -} - -void vc_client::emit_disconnected(vc_result r, const char* reason) { - vc_event ev{}; - ev.type = VC_EVENT_DISCONNECTED; - ev.result = static_cast(r); - ev.text = reason; - emit(ev); - set_state(VC_STATE_DISCONNECTED); -} - - - -vc_result vc_client::connect(const char* host, uint16_t port) { - auto cur = state_net_.load(std::memory_order_acquire); - if (cur != VC_STATE_DISCONNECTED) return VC_ERR_ALREADY; - - io_stop_.store(false, std::memory_order_release); - io_fd_.store(-1, std::memory_order_release); - // Pre-set so authenticate_*() called right after connect() doesn't see DISCONNECTED. - state_net_.store(VC_STATE_CONNECTING, std::memory_order_release); - - std::string h = host; - io_thread_ = std::thread([this, h, port] { run_io(h, port); }); - return VC_OK; -} - -vc_result vc_client::disconnect() { - auto cur = state_net_.load(std::memory_order_acquire); - if (cur == VC_STATE_DISCONNECTED && !io_thread_.joinable()) return VC_ERR_NOT_CONNECTED; - - if (cur == VC_STATE_CONNECTED && io_thread_.joinable()) { - voicecat::v1::Envelope env; - env.set_request_id(next_req_id_++); - auto* d = env.mutable_disconnect(); - d->set_code(0); // 0 = graceful client-initiated - d->set_reason("client disconnect"); - queue_envelope(env); - graceful_disconnect_pending_.store(true, std::memory_order_release); - - // Wait for the io thread to drain the queue, send the Disconnect, and exit. - // Its cleanup handles teardown_voice() + socket close. No main-thread socket - // close needed. If the io thread is stuck (unlikely), the caller can force-close - // by calling disconnect() again — but the second call hits the non-graceful path - // below since io_thread_ is no longer joinable after the join returns. - if (io_thread_.joinable()) io_thread_.join(); - return VC_OK; - } - - // Non-graceful path: force-close Used when the io thread is already - // gone, the connection is in an early state, or as a fallback. - io_stop_.store(true, std::memory_order_release); - - // Unblock a run_io() thread that's currently waiting on vc_confirm_server_identity() — - // without this, disconnecting mid-dialog would strand io_thread_ until the 120s timeout. - { - std::lock_guard lk(tofu_mu_); - if (tofu_decision_pending_) { - tofu_decision_pending_ = false; - tofu_cv_.notify_all(); - } - } - - // Close the socket to unblock blocking TLS reads/writes. - int fd = io_fd_.load(std::memory_order_acquire); - if (fd != -1) { -#ifdef _WIN32 - ::shutdown(static_cast(fd), SD_BOTH); - ::closesocket(static_cast(fd)); -#else - ::shutdown(fd, SHUT_RDWR); - ::close(fd); -#endif - io_fd_.store(-1, std::memory_order_release); - } - - teardown_voice(); - - if (io_thread_.joinable()) io_thread_.join(); - return VC_OK; -} - -// io_thread_ entry point - -void vc_client::run_io(std::string host, uint16_t port) { - udp_host_ = host; - set_state(VC_STATE_CONNECTING); - - // TCP connect -#ifdef _WIN32 - WSADATA wsa{}; - WSAStartup(MAKEWORD(2, 2), &wsa); -#else - // POSIX/macOS: ignore SIGPIPE — a write to a closed socket returns EPIPE instead of - // terminating the process. On macOS SIGPIPE is delivered by default (unlike Windows - // where it doesn't exist); without this, a peer dropping mid-TLS-write kills us. - // Process-global and idempotent (safe to call per run_io). - std::signal(SIGPIPE, SIG_IGN); -#endif - - struct addrinfo hints{}; - hints.ai_family = AF_UNSPEC; - hints.ai_socktype = SOCK_STREAM; - struct addrinfo* res = nullptr; - std::string port_str = std::to_string(port); - - if (io_stop_.load()) goto cleanup; - - if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0 || !res) { - emit_disconnected(VC_ERR_IO, "hostname resolution failed"); - goto cleanup; - } - - { - sock_t sock = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol); - if (sock == kBadSock) { - freeaddrinfo(res); - emit_disconnected(VC_ERR_IO, "socket() failed"); - goto cleanup; - } - - if (::connect(sock, res->ai_addr, static_cast(res->ai_addrlen)) != 0) { - close_sock(sock); - freeaddrinfo(res); - emit_disconnected(VC_ERR_IO, "TCP connect failed"); - goto cleanup; - } - freeaddrinfo(res); - res = nullptr; - io_fd_.store(static_cast(sock), std::memory_order_release); - - // TLS handshake - set_state(VC_STATE_TLS_HANDSHAKE); - - tls_ = std::make_unique( - voicecat::crypto::TlsContext::Role::Client, nullptr); - - { - std::string tls_err; - if (!tls_->handshake(static_cast(sock), tls_err)) { - tls_.reset(); - emit_disconnected(VC_ERR_CRYPTO, tls_err.c_str()); - close_sock(sock); - io_fd_.store(-1); - goto cleanup; - } - } - - // TOFU server-identity gate - - set_state(VC_STATE_VERIFYING_IDENTITY); - { - std::array peer_fp{}; - vc_tofu_status status = VC_TOFU_MISMATCH; - if (tls_->peer_cert_fingerprint(peer_fp) && tofu_store_) { - auto r = tofu_store_->peek(host, port, peer_fp); - status = (r == voicecat::crypto::TofuResult::FirstConnect) ? VC_TOFU_FIRST_CONNECT - : (r == voicecat::crypto::TofuResult::Matched) ? VC_TOFU_MATCHED - : VC_TOFU_MISMATCH; - } - - std::string fp_hex; - { - static const char* hex = "0123456789abcdef"; - fp_hex.reserve(peer_fp.size() * 2); - for (auto b : peer_fp) { fp_hex += hex[b >> 4]; fp_hex += hex[b & 0xf]; } - } - - { - std::lock_guard set_lk(tofu_mu_); - tofu_decision_pending_ = true; - tofu_accept_ = false; - } - - vc_event ev{}; - ev.type = VC_EVENT_SERVER_IDENTITY; - ev.u32a = static_cast(status); - ev.text = fp_hex.c_str(); - emit(ev); - - bool accepted; - { - std::unique_lock lk(tofu_mu_); - tofu_cv_.wait_for(lk, std::chrono::seconds(120), [&] { - return !tofu_decision_pending_ || io_stop_.load(std::memory_order_acquire); - }); - // Timeout or an external stop (disconnect() during the wait) both leave - // tofu_decision_pending_ true here — treated as a reject - accepted = tofu_decision_pending_ ? false : tofu_accept_; - tofu_decision_pending_ = false; - } - - if (!accepted) { - tls_.reset(); - emit_disconnected(VC_ERR_CRYPTO, "server identity rejected"); - close_sock(sock); - io_fd_.store(-1); - goto cleanup; - } - if (status != VC_TOFU_MATCHED && tofu_store_) { - tofu_store_->pin(host, port, peer_fp); - } - } - - // 50 ms timeout so we can drain sends between reads. - tls_->set_read_timeout(50); - - // ── Send ClientHello ───────────────────────────────────────────────── - set_state(VC_STATE_AUTHENTICATING); - { - voicecat::v1::Envelope env; - env.set_request_id(next_req_id_++); - auto* hello = env.mutable_client_hello(); - hello->set_proto_version(2); - hello->set_client_name(cfg_.client_name ? cfg_.client_name : "vccli"); - hello->set_client_version(cfg_.client_version ? cfg_.client_version : "0.1.0"); - auto frame = make_frame(env); - size_t off = 0; - while (off < frame.size()) { - int n = tls_->write(frame.data() + off, frame.size() - off); - if (n <= 0) { - tls_.reset(); - emit_disconnected(VC_ERR_IO, "write ClientHello failed"); - close_sock(sock); - io_fd_.store(-1); - goto cleanup; - } - off += static_cast(n); - } - } - - // Read loop - { - voicecat::protocol::FrameCodec codec; - std::vector buf(16384); - - // Seed the keepalive clock so the first Ping goes out ~15s after connect, - // not immediately. - last_ping_ms_.store(std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count(), - std::memory_order_release); - - while (!io_stop_.load(std::memory_order_acquire)) { - drain_sends(); - - // Graceful disconnect: if disconnect() queued a Disconnect{code=0} and set - // the flag, drain_sends() just sent it. Exit the read loop now — the server - // will close the connection on receipt, but we don't need to wait for that. - // Setting io_stop_ prevents the emit_disconnected() call below the loop - // (this is a user-initiated exit, not an error). - if (graceful_disconnect_pending_.load(std::memory_order_acquire)) { - io_stop_.store(true, std::memory_order_release); - break; - } - - // Keepalive: send a Ping every ~15s so the server's reaper doesn't drop us - - { - auto now_ms = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count(); - if (now_ms - last_ping_ms_.load(std::memory_order_acquire) >= kPingIntervalMs) { - send_ping(); - drain_sends(); // flush the ping immediately - } - } - - int n = tls_->read(buf.data(), buf.size()); - if (voicecat::crypto::TlsContext::is_timeout_error(n)) continue; - if (n <= 0) break; - - std::vector> frames; - if (!codec.feed(buf.data(), static_cast(n), frames)) break; - for (auto& frame : frames) { - voicecat::v1::Envelope env; - if (voicecat::protocol::decode_envelope(frame, env)) { - handle_envelope(env); - } - } - } - } - - teardown_voice(); - tls_.reset(); - close_sock(sock); - io_fd_.store(-1); - } - - if (!io_stop_.load()) emit_disconnected(VC_ERR_IO, "connection closed"); - -cleanup: -#ifdef _WIN32 - WSACleanup(); -#endif - return; -} - -void vc_client::teardown_voice() { - std::lock_guard teardown_lk(teardown_mu_); - - udp_stop_.store(true, std::memory_order_release); - int ufd = udp_fd_.load(std::memory_order_acquire); - if (ufd != -1) { - close_sock(static_cast(ufd)); - udp_fd_.store(-1, std::memory_order_release); - } - if (udp_thread_.joinable()) udp_thread_.join(); - udp_ready_.store(false, std::memory_order_release); - - talk_timer_stop_.store(true, std::memory_order_release); - if (talk_timer_thread_.joinable()) talk_timer_thread_.join(); - - audio_engine_.stop(); - { - std::lock_guard lk(local_streams_mu_); - for (auto& [kind, ls] : local_streams_) { - (void)kind; - ls.active.store(false, std::memory_order_release); - ls.pending = false; - ls.encoder.destroy(); - } - local_streams_.clear(); - pending_announce_kind_.clear(); - } - - media_send_crypto_.reset(); - media_recv_crypto_.reset(); - - std::lock_guard lk(remote_streams_mu_); - remote_streams_.clear(); -} - -void vc_client::drain_sends() { - while (true) { - std::vector frame; - { - std::lock_guard lk(send_mutex_); - if (send_queue_.empty()) { - send_cv_.notify_all(); // unblock disconnect()'s drain wait - return; - } - frame = std::move(send_queue_.front()); - send_queue_.pop_front(); - } - if (!tls_) { send_cv_.notify_all(); return; } - size_t off = 0; - while (off < frame.size()) { - int n = tls_->write(frame.data() + off, frame.size() - off); - if (n <= 0) { - io_stop_.store(true); - send_cv_.notify_all(); // unblock disconnect() even on write failure - return; - } - off += static_cast(n); - } - } -} - -void vc_client::queue_envelope(const voicecat::v1::Envelope& env) { - auto frame = make_frame(env); - if (frame.empty()) return; - std::lock_guard lk(send_mutex_); - send_queue_.push_back(std::move(frame)); -} - -void vc_client::send_ping() { - auto now_ms = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count(); - last_ping_ms_.store(now_ms, std::memory_order_release); - uint64_t nonce = ping_nonce_.fetch_add(1, std::memory_order_relaxed); - { - std::lock_guard lk(ping_mutex_); - pending_pings_[nonce] = now_ms; - } - voicecat::v1::Envelope env; - env.set_request_id(next_req_id_++); - env.mutable_ping()->set_nonce(nonce); - queue_envelope(env); -} - -// Protocol dispatch - -void vc_client::handle_envelope(const voicecat::v1::Envelope& env) { - switch (env.body_case()) { - case voicecat::v1::Envelope::kServerHello: - handle_server_hello(env.server_hello(), env.request_id()); - break; - case voicecat::v1::Envelope::kAuthResult: - handle_auth_result(env.auth_result()); - break; - case voicecat::v1::Envelope::kServerState: - handle_server_state(env.server_state()); - break; - case voicecat::v1::Envelope::kUserEvent: - handle_user_event(env.user_event()); - break; - case voicecat::v1::Envelope::kChannelEvent: - handle_channel_event(env.channel_event()); - break; - case voicecat::v1::Envelope::kJoinChannelResult: - handle_join_channel_result(env.join_channel_result()); - break; - case voicecat::v1::Envelope::kTextMessage: - handle_text_message(env.text_message()); - break; - case voicecat::v1::Envelope::kDisconnect: - handle_disconnect(env.disconnect()); - break; - case voicecat::v1::Envelope::kUdpBinding: - handle_udp_binding_ack(env.udp_binding()); - break; - case voicecat::v1::Envelope::kStreamAnnounceResult: - handle_stream_announce_result(env.request_id(), env.stream_announce_result()); - break; - case voicecat::v1::Envelope::kVoiceSubscriptionResult: - handle_voice_subscription_result(env.voice_subscription_result()); - break; - case voicecat::v1::Envelope::kPong: { - // Correlate the echoed nonce to measure RTT - uint64_t nonce = env.pong().nonce(); - auto now_ms = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count(); - std::lock_guard lk(ping_mutex_); - auto it = pending_pings_.find(nonce); - if (it != pending_pings_.end()) { - last_rtt_ms_.store(now_ms - it->second, std::memory_order_relaxed); - pending_pings_.erase(it); - } - break; - } - case voicecat::v1::Envelope::kGenericResult: { - vc_event ev{}; - ev.type = VC_EVENT_GENERIC_RESULT; - ev.result = env.generic_result().ok() ? VC_OK : VC_ERR_PERMISSION_DENIED; - ev.u32a = env.generic_result().code(); - ev.text = env.generic_result().message().c_str(); - emit(ev); - break; - } - case voicecat::v1::Envelope::kListAccountsResult: { - { - std::lock_guard lk(account_list_mu_); - last_account_list_.assign(env.list_accounts_result().accounts().begin(), - env.list_accounts_result().accounts().end()); - } - vc_event ev{}; - ev.type = VC_EVENT_ACCOUNT_LIST; - emit(ev); - break; - } - default: - break; - } -} - -void vc_client::handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t /*req_id*/) { - server_udp_port_ = static_cast(msg.udp_port()); - - // Stash the declared Ed25519 fingerprint for vc_get_server_identity_display() — - // display-only, not the TOFU-pinned value (that's the TLS cert fingerprint, gated before - // ClientHello was even sent — see the TOFU block above in run_io()). - { - const std::string& raw = msg.server_identity_fingerprint(); - static const char* hex = "0123456789abcdef"; - std::string fp_hex; - fp_hex.reserve(raw.size() * 2); - for (unsigned char b : raw) { fp_hex += hex[b >> 4]; fp_hex += hex[b & 0xf]; } - std::lock_guard lk(tofu_mu_); - pending_identity_fp_hex_ = std::move(fp_hex); - } - - // Server acknowledged our ClientHello. Now send AuthRequest (or queue it). - std::optional auth; - { - std::lock_guard lk(pending_auth_mutex_); - auth = pending_auth_; - } - if (!auth) return; // caller will call authenticate_*() later - - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* ar = req.mutable_auth_request(); - if (auth->is_guest) { - ar->mutable_guest()->set_nickname(auth->nick_or_user); - } else { - ar->mutable_password()->set_username(auth->nick_or_user); - ar->mutable_password()->set_password(auth->password); - } - queue_envelope(req); - (void)msg; -} - -void vc_client::handle_auth_result(const voicecat::v1::AuthResult& msg) { - vc_event ev{}; - ev.type = VC_EVENT_AUTH_RESULT; - ev.result = msg.ok() ? VC_OK : VC_ERR_AUTH_FAILED; - - if (msg.ok()) { - self_user_id_ = msg.self().id(); - server_session_id_ = msg.session_id(); - ev.user_id = self_user_id_; - set_state(VC_STATE_CONNECTED); - - // Store own permissions. - const auto& perms = msg.permissions(); - own_permissions_.can_create_temp_channel = perms.can_create_temp_channel() ? 1 : 0; - own_permissions_.can_kick = perms.can_kick() ? 1 : 0; - own_permissions_.can_ban = perms.can_ban() ? 1 : 0; - own_permissions_.can_move_users = perms.can_move_users() ? 1 : 0; - own_permissions_.can_admin_accounts = perms.can_admin_accounts() ? 1 : 0; - own_permissions_.is_admin = perms.is_admin() ? 1 : 0; - - const std::string& tok = msg.udp_token(); - if (tok.size() == udp_token_.size()) { - std::memcpy(udp_token_.data(), tok.data(), udp_token_.size()); - start_udp_binding(); - } - } else { - ev.text = msg.error().c_str(); - } - emit(ev); -} - -void vc_client::handle_server_state(const voicecat::v1::ServerStateSnapshot& snap) { - { - std::lock_guard lk(session_model_mu_); - session_model_.apply_snapshot(snap); - } - for (const auto& u : snap.users()) sync_remote_streams(u); - vc_event ev{}; - ev.type = VC_EVENT_CHANNEL_LIST; - emit(ev); -} - -void vc_client::handle_channel_event(const voicecat::v1::ChannelEvent& ce) { - bool audio_changed = false; - uint32_t updated_channel_id = 0; - { - std::lock_guard lk(session_model_mu_); - - // Detect audio-config changes on our current channel BEFORE apply_channel_event - // overwrites the stored config. Encoders/decoders are frozen at StreamAnnounceResult - // time , so a channel audio change requires restarting active - // local streams to pick up the new params. - if (ce.kind() == voicecat::v1::ChannelEvent::UPDATED) { - updated_channel_id = ce.channel().id(); - const auto* self_user = session_model_.find_user(self_user_id_); - if (self_user && self_user->channel_id == updated_channel_id) { - const auto* old_ch = session_model_.find_channel(updated_channel_id); - const auto& na = ce.channel().audio(); - if (old_ch) { - audio_changed = - old_ch->audio_sample_rate != (na.sample_rate() ? na.sample_rate() : 48000) || - old_ch->audio_frame_ms != (na.frame_ms() ? na.frame_ms() : 20) || - old_ch->audio_mode != static_cast(na.mode()) || - old_ch->audio_bitrate_bps != na.bitrate_bps() || - old_ch->audio_application != static_cast(na.application()) || - old_ch->audio_fec != na.fec() || - old_ch->audio_expected_packet_loss != na.expected_packet_loss() || - old_ch->audio_dtx != na.dtx() || - old_ch->audio_complexity != (na.complexity() ? na.complexity() : 10) || - old_ch->audio_dred != na.dred(); - } - } - } - - session_model_.apply_channel_event(ce); - } - - if (audio_changed) { - restart_active_streams_for_channel(updated_channel_id); - } - - vc_event ev{}; - ev.type = VC_EVENT_CHANNEL_LIST; - emit(ev); -} - -void vc_client::handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg) { - vc_event ev{}; - ev.type = VC_EVENT_JOIN_RESULT; - ev.result = msg.ok() ? VC_OK : VC_ERR_PROTOCOL; - ev.channel_id = msg.channel_id(); - if (!msg.ok()) ev.text = msg.error().c_str(); - emit(ev); -} - -void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) { - { - std::lock_guard lk(session_model_mu_); - session_model_.apply_user_event(ue); - } - - vc_event ev{}; - const auto& user = ue.user(); - ev.user_id = user.id(); - ev.channel_id = user.channel_id(); - - std::string nick = user.nickname(); - - switch (ue.kind()) { - case voicecat::v1::UserEvent::JOINED: - ev.type = VC_EVENT_USER_JOINED; - ev.text = nick.c_str(); - emit(ev); - sync_remote_streams(user); - break; - case voicecat::v1::UserEvent::LEFT: { - ev.type = VC_EVENT_USER_LEFT; - emit(ev); - std::lock_guard lk(remote_streams_mu_); - for (auto it = remote_streams_.begin(); it != remote_streams_.end();) { - if (it->second.first == user.id()) { - audio_engine_.remove_stream(it->first); - it = remote_streams_.erase(it); - } else { - ++it; - } - } - break; - } - case voicecat::v1::UserEvent::UPDATED: - ev.type = VC_EVENT_USER_UPDATED; - emit(ev); - // If this is an update to our own user, reflect server-mute/deafen locally. - if (user.id() == self_user_id_) { - server_muted_.store(user.server_muted(), std::memory_order_release); - server_deafened_.store(user.server_deafened(), std::memory_order_release); - std::lock_guard lk(remote_streams_mu_); - bool muted = self_deafened_.load(std::memory_order_acquire) || - server_deafened_.load(std::memory_order_acquire); - for (auto& [ssrc, info] : remote_streams_) { - (void)info; - audio_engine_.set_stream_mute(ssrc, muted); - } - } - sync_remote_streams(user); - break; - default: - break; - } -} - -void vc_client::handle_text_message(const voicecat::v1::TextMessage& msg) { - vc_event ev{}; - ev.type = VC_EVENT_TEXT_MESSAGE; - ev.text_scope = (msg.scope() == voicecat::v1::TEXT_PRIVATE) ? VC_TEXT_PRIVATE : VC_TEXT_CHANNEL; - ev.user_id = msg.sender_id(); - ev.channel_id = msg.target_id(); - ev.text = msg.body().c_str(); - ev.timestamp_unix_ms = static_cast(msg.sent_at_unix_ms()); - emit(ev); -} - -void vc_client::handle_disconnect(const voicecat::v1::Disconnect& msg) { - io_stop_.store(true, std::memory_order_release); - emit_disconnected(VC_ERR_IO, msg.reason().c_str()); -} - -// Auth / channel / text commands - -vc_result vc_client::authenticate_guest(const char* nickname) { - auto cur = state_net_.load(std::memory_order_acquire); - if (cur == VC_STATE_DISCONNECTED) return VC_ERR_NOT_CONNECTED; - - PendingAuth pa{true, nickname, {}}; - { - std::lock_guard lk(pending_auth_mutex_); - pending_auth_ = pa; - } - - // If already past ServerHello, send AuthRequest immediately. - if (cur == VC_STATE_CONNECTED || cur == VC_STATE_AUTHENTICATING) { - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - req.mutable_auth_request()->mutable_guest()->set_nickname(nickname); - queue_envelope(req); - } - return VC_OK; -} - -vc_result vc_client::authenticate_user(const char* username, const char* password) { - auto cur = state_net_.load(std::memory_order_acquire); - if (cur == VC_STATE_DISCONNECTED) return VC_ERR_NOT_CONNECTED; - - PendingAuth pa{false, username, password}; - { - std::lock_guard lk(pending_auth_mutex_); - pending_auth_ = pa; - } - - if (cur == VC_STATE_CONNECTED || cur == VC_STATE_AUTHENTICATING) { - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* pw = req.mutable_auth_request()->mutable_password(); - pw->set_username(username); - pw->set_password(password); - queue_envelope(req); - } - return VC_OK; -} - -vc_result vc_client::join_channel(uint32_t channel_id, const char* password) { - if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* jc = req.mutable_join_channel(); - jc->set_channel_id(channel_id); - if (password) jc->set_password(password); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::leave_channel() { - if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - req.mutable_leave_channel(); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::send_text(vc_text_scope scope, uint32_t target_id, const char* utf8) { - if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* tm = req.mutable_text_message(); - tm->set_scope(scope == VC_TEXT_PRIVATE ? voicecat::v1::TEXT_PRIVATE : voicecat::v1::TEXT_CHANNEL); - tm->set_target_id(target_id); - tm->set_body(utf8); - queue_envelope(req); - return VC_OK; -} - -// UDP binding - -void vc_client::start_udp_binding() { - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - req.mutable_udp_binding()->set_udp_token( - reinterpret_cast(udp_token_.data()), udp_token_.size()); - queue_envelope(req); -} - -void vc_client::handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg) { - if (!msg.ack()) return; - finish_udp_binding(); -} - -void vc_client::finish_udp_binding() { - if (udp_ready_.load(std::memory_order_acquire)) return; - if (!tls_ || server_udp_port_ == 0) return; - - media_send_crypto_ = voicecat::crypto::SodiumMediaCrypto::derive_send(*tls_, true); - media_recv_crypto_ = voicecat::crypto::SodiumMediaCrypto::derive_recv(*tls_, true); - if (!media_send_crypto_ || !media_recv_crypto_) { - emit_error(VC_ERR_CRYPTO, "failed to derive media keys"); - return; - } - - sock_t s = ::socket(AF_INET, SOCK_DGRAM, 0); - if (s == kBadSock) { - emit_error(VC_ERR_IO, "udp socket() failed"); - return; - } - - struct addrinfo hints{}; - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_DGRAM; - struct addrinfo* res = nullptr; - std::string port_str = std::to_string(server_udp_port_); - if (getaddrinfo(udp_host_.c_str(), port_str.c_str(), &hints, &res) != 0 || !res) { - close_sock(s); - emit_error(VC_ERR_IO, "udp hostname resolution failed"); - return; - } - - auto* sin = reinterpret_cast(res->ai_addr); - udp_dest_addr_ = sin->sin_addr.s_addr; - udp_dest_port_ = sin->sin_port; - - auto pkt = voicecat::net::make_udp_binding_packet(udp_token_.data(), udp_token_.size()); - ::sendto(s, reinterpret_cast(pkt.data()), static_cast(pkt.size()), 0, - res->ai_addr, static_cast(res->ai_addrlen)); - freeaddrinfo(res); - - udp_fd_.store(static_cast(s), std::memory_order_release); - udp_stop_.store(false, std::memory_order_release); - udp_ready_.store(true, std::memory_order_release); - udp_thread_ = std::thread([this] { run_udp_recv(); }); - - talk_timer_stop_.store(false, std::memory_order_release); - talk_timer_thread_ = std::thread([this] { run_talk_timer(); }); -} - -void vc_client::run_udp_recv() { - int fd = udp_fd_.load(std::memory_order_acquire); - if (fd == -1) return; - -#ifdef _WIN32 - DWORD tv = 200; - setsockopt(static_cast(fd), SOL_SOCKET, SO_RCVTIMEO, - reinterpret_cast(&tv), sizeof(tv)); -#else - struct timeval tv{0, 200000}; - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); -#endif - - // Seed the keepalive clock so the first KEEPALIVE goes out ~5s after binding, not - // immediately (the UdpBinding bootstrap itself is a recent packet). - last_udp_keepalive_ms_.store(std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count(), - std::memory_order_release); - - std::vector buf(2048); - while (!udp_stop_.load(std::memory_order_acquire)) { - int n = static_cast(::recv(static_cast(fd), reinterpret_cast(buf.data()), - static_cast(buf.size()), 0)); - if (n < static_cast(voicecat::net::kVoiceHeaderSize)) { - // Timeout or short packet, send a KEEPALIVE if the interval has elapsed. - send_udp_keepalive(); - continue; - } - - voicecat::net::VoiceFrame hdr{}; - if (!voicecat::net::parse_header(buf.data(), static_cast(n), hdr)) continue; - if (hdr.type == voicecat::net::kFrameKeepalive) { - continue; - } - if (hdr.type != voicecat::net::kFrameVoice) continue; - if (!media_recv_crypto_) continue; - - size_t sealed_len = static_cast(n) - voicecat::net::kVoiceHeaderSize; - std::vector plain(sealed_len); - long plain_len = media_recv_crypto_->open( - buf.data() + voicecat::net::kVoiceHeaderSize, sealed_len, buf.data(), - voicecat::net::kVoiceHeaderSize, plain.data(), plain.size()); - if (plain_len < 0) continue; - plain.resize(static_cast(plain_len)); - - voicecat::audio::JitterBuffer::Frame jf; - jf.seq = hdr.seq; - jf.timestamp = hdr.timestamp; - jf.fec_present = (hdr.flags & voicecat::net::kFlagFecPresent) != 0; - jf.marker = (hdr.flags & voicecat::net::kFlagMarker) != 0; - jf.payload = std::move(plain); - audio_engine_.push_recv_frame(hdr.ssrc, std::move(jf)); - } -} - -void vc_client::send_udp_keepalive() { - int fd = udp_fd_.load(std::memory_order_acquire); - if (fd == -1) return; - - auto now_ms = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count(); - if (now_ms - last_udp_keepalive_ms_.load(std::memory_order_acquire) < kUdpKeepaliveIntervalMs) - return; - last_udp_keepalive_ms_.store(now_ms, std::memory_order_release); - - // Plaintext KEEPALIVE: 14-byte header, type=2, no payload, no AEAD. The server - // identifies by the verified UDP endpoint (set during the UdpBinding handshake). - uint8_t pkt[voicecat::net::kVoiceHeaderSize] = {0}; - pkt[0] = voicecat::net::kFrameKeepalive; - - sockaddr_in dest{}; - dest.sin_family = AF_INET; - dest.sin_addr.s_addr = udp_dest_addr_; - dest.sin_port = udp_dest_port_; - ::sendto(static_cast(fd), reinterpret_cast(pkt), - static_cast(sizeof(pkt)), 0, reinterpret_cast(&dest), sizeof(dest)); -} - -namespace { -int64_t client_now_ms() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - -// Maps the complete wire AudioConfig for both local encoders and remote decoders. -voicecat::codec::OpusParams opus_params_from_audio_config(const voicecat::v1::AudioConfig& a) { - voicecat::codec::OpusParams p; - // Opus always runs at 48 kHz internally: the whole AudioEngine clock is - // 48 kHz and external PCM is fed at 48 kHz, so the codec must match regardless of what a - // channel advertises. Honoring a non-48k effective sample_rate as the codec rate would - // create an encoder expecting e.g. 16k PCM while being fed 48k frames — wrong pitch/duration. - // Instead the channel's sample_rate is carried as max_bandwidth_hz and caps the encoder's - // selected audio bandwidth (narrowband/wideband/…) — a low-bitrate room still benefits. - p.sample_rate = 48000; - p.max_bandwidth_hz = a.sample_rate(); // 0 = unset → full band - p.bitrate_bps = a.bitrate_bps() ? a.bitrate_bps() : 24000; - p.frame_ms = a.frame_ms() ? a.frame_ms() : 20; - p.stereo = (a.mode() == voicecat::v1::MODE_STEREO); - p.fec = a.fec(); - p.dtx = a.dtx(); - p.complexity = a.complexity() ? a.complexity() : 10; - p.expected_packet_loss = a.expected_packet_loss(); - p.application = static_cast(a.application()); - p.dred = a.dred(); - return p; -} - -voicecat::v1::AudioConfig audio_config_from_vc(const vc_audio_config& c) { - voicecat::v1::AudioConfig a; - a.set_codec(c.codec); - a.set_mode(c.mode == 1 ? voicecat::v1::MODE_STEREO : voicecat::v1::MODE_MONO); - a.set_sample_rate(c.sample_rate); - a.set_bitrate_bps(c.bitrate_bps); - a.set_frame_ms(c.frame_ms); - a.set_application(static_cast(c.application)); - a.set_fec(c.fec != 0); - a.set_expected_packet_loss(c.expected_packet_loss); - a.set_dtx(c.dtx != 0); - a.set_complexity(c.complexity); - a.set_dred(c.dred != 0); - return a; -} - -voicecat::v1::Channel channel_from_vc(const vc_channel_info& c) { - voicecat::v1::Channel ch; - ch.set_id(c.id); - ch.set_parent_id(c.parent_id); - ch.set_name(c.name ? c.name : ""); - ch.set_topic(c.topic ? c.topic : ""); - ch.set_password_protected(c.password_protected != 0); - ch.set_max_users(c.max_users); - ch.set_type(voicecat::v1::CHANNEL_PERMANENT); - *ch.mutable_audio() = audio_config_from_vc(c.audio); - ch.set_order(static_cast(c.sort_order)); - return ch; -} - -} // namespace - -void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples, int channels) { - std::lock_guard lk(local_streams_mu_); - auto it = local_streams_.find(kind); - if (it == local_streams_.end() || !it->second.active.load(std::memory_order_acquire)) return; - // "Mic muted" only gates the MIC stream — a concurrently-running SCREEN_AUDIO share keeps - // playing while the user's mic is muted - // Server-mute is also a hard gate on MIC transmission. - if (kind == static_cast(VC_STREAM_MIC) && - (self_mic_muted_.load(std::memory_order_acquire) || - server_muted_.load(std::memory_order_acquire))) return; - - // Send-side mic noise suppression (vc_set_input_noise_reduction) — MIC only. Runs first, on - // the raw mic, so the gain boost and the VAD gate below both see the cleaned signal. mic_ns_ - // exists for the lifetime of the MIC stream; the atomic flip gates it without touching the - // pointer on this RT callback. RNNoise is a mono 48 kHz denoiser, so a stereo mic is downmixed - // to mono IN PLACE here — but only when NS is enabled. With NS off this block is skipped - // entirely, so a stereo mic keeps full stereo: we never collapse mic quality unless asked. - if (kind == static_cast(VC_STREAM_MIC) && mic_ns_ && - input_noise_reduction_.load(std::memory_order_relaxed)) { - int16_t* w = const_cast(pcm); - if (channels == 2) { - for (int i = 0; i < samples; ++i) - w[i] = static_cast( - (static_cast(w[2 * i]) + static_cast(w[2 * i + 1])) / 2); - channels = 1; // rest of the pipeline (gain, gate, encode) now sees a mono frame - } - if (channels == 1) mic_ns_->process_capture(w, samples, 48000); - } - - // Send-side mic input gain (vc_set_input_gain) — MIC only. Applied in place before the gate - // so a boosted quiet mic also helps cross the VAD threshold. EnergyVadProcessor never writes - // through its pointer, so the const_cast (same as the VAD path below) is safe; no allocation. - if (kind == static_cast(VC_STREAM_MIC)) { - const float gain = input_gain_.load(std::memory_order_relaxed); - if (gain != 1.0f) { - int16_t* w = const_cast(pcm); - const int n = samples * std::max(1, channels); - for (int i = 0; i < n; ++i) { - int32_t v = static_cast(std::lround(w[i] * gain)); - w[i] = static_cast(std::clamp(v, -32768, 32767)); - } - } - } - - - if (kind == static_cast(VC_STREAM_MIC)) { - auto mode = current_input_mode_.load(std::memory_order_acquire); - if (mode == VC_INPUT_PUSH_TO_TALK) { - if (!ptt_active_.load(std::memory_order_acquire)) return; // gate closed - } else if (mode == VC_INPUT_VOICE_ACTIVATION && mic_vad_) { - // EnergyVadProcessor never writes through the pointer (see apm_processor.cpp); the - // const_cast is safe and avoids splitting ApmProcessor's interface just for this. - if (!mic_vad_->process_capture(const_cast(pcm), samples, 48000)) return; - } - // VC_INPUT_ALWAYS_ON: no gate — fall through and always send. - } - - if (!media_send_crypto_) return; - int fd = udp_fd_.load(std::memory_order_acquire); - if (fd == -1) return; - - auto& ls = it->second; - // Updated only after the gate above, so a VAD/PTT-closed frame never shows as "talking". - ls.last_capture_ms.store(client_now_ms(), std::memory_order_relaxed); - - // The AudioEngine clock is fixed at 48 kHz / 20 ms, so `samples` is always 960. The encoder, - // however, wants ls.frame_samples per call, which the channel's frame_ms can make smaller - // (480 @10ms) or larger (1920 @40ms). Reframe to that size before encoding (docs/voice.md §3). - const int target = ls.frame_samples; - if (samples == target) { - // Fast path, the common 20 ms channel: encode the engine frame directly, no buffering. - encode_and_send_frame(ls, pcm, samples, channels, fd); - return; - } - - // Reframe: accumulate engine frames and emit target-sized chunks. encode_accum/upmix_scratch - // were pre-sized in handle_stream_announce_result, so no allocation happens here. - const int ch = std::max(1, channels); - if (ls.accum_channels != ch) { // mono/stereo source change — never mix the two - ls.accum_count = 0; - ls.accum_channels = ch; - } - const size_t add = static_cast(samples) * static_cast(ch); - const size_t chunk = static_cast(target) * static_cast(ch); - if (ls.accum_count + add > ls.encode_accum.size()) return; // capacity guard (shouldn't trip) - std::memcpy(ls.encode_accum.data() + ls.accum_count, pcm, add * sizeof(int16_t)); - ls.accum_count += add; - - size_t off = 0; - while (ls.accum_count - off >= chunk) { - encode_and_send_frame(ls, ls.encode_accum.data() + off, target, ch, fd); - off += chunk; - } - if (off > 0) { // shift the sub-frame remainder to the front - const size_t rem = ls.accum_count - off; - if (rem > 0) - std::memmove(ls.encode_accum.data(), ls.encode_accum.data() + off, - rem * sizeof(int16_t)); - ls.accum_count = rem; - } -} - -void vc_client::encode_and_send_frame(LocalStream& ls, const int16_t* pcm, int samples, - int channels, int fd) { - uint8_t opus_buf[1500]; - int opus_len; - if (channels == 2 && ls.effective_params.stereo) { - // Real interleaved stereo PCM (a stereo mic via vc_set_capture_channels, or SCREEN_AUDIO - // loopback) on a channel configured for stereo — encode directly, true L/R, no fold. - // `samples` is samples-per-channel, as OpusEncoder::encode expects. - opus_len = ls.encoder.encode(pcm, samples, opus_buf, sizeof(opus_buf)); - } else if (channels == 2) { - // Stereo capture (stereo mic / line-in) on a MONO channel: the encoder is mono, so fold - // L/R to mono first. Feeding interleaved pairs straight to a mono opus_encode would make - // it read 2× the samples it should (wrong pitch / garbage). upmix_scratch is pre-sized at - // announce and easily holds `samples` mono values. This keeps a stereo-mic toggle safe on - // every channel: real L/R when the channel is stereo, a clean downmix when it isn't. - int16_t* mono = ls.upmix_scratch.data(); - for (int i = 0; i < samples; ++i) - mono[i] = static_cast( - (static_cast(pcm[2 * i]) + static_cast(pcm[2 * i + 1])) / 2); - opus_len = ls.encoder.encode(mono, samples, opus_buf, sizeof(opus_buf)); - } else if (ls.effective_params.stereo) { - // Mono capture (mono mic, or loopback on a mono channel, or test injection) on a channel - // configured for stereo — upmix L=R so the stream is still a spec-correct stereo Opus - // bitstream (a stereo channel requires a stereo bitstream). upmix_scratch is pre-sized at announce. - int16_t* st = ls.upmix_scratch.data(); - for (int i = 0; i < samples; ++i) { - st[i * 2] = pcm[i]; - st[i * 2 + 1] = pcm[i]; - } - opus_len = ls.encoder.encode(st, samples, opus_buf, sizeof(opus_buf)); - } else { - opus_len = ls.encoder.encode(pcm, samples, opus_buf, sizeof(opus_buf)); - } - if (opus_len <= 0) return; - - voicecat::net::VoiceFrame hdr; - hdr.ssrc = ls.ssrc; - hdr.seq = media_send_crypto_->peek_send_counter(); - hdr.timestamp = ls.timestamp; - ls.timestamp += static_cast(samples); - - // Talkspurt marker: first frame overall, or the first after a transmission gap longer than a - // few frame intervals (VAD/PTT closed, or DTX silence). The sender omits silence from the - // timestamp, so this is how the receiver knows to reseed its playout clock (see on_playback). - const int64_t now_ms = client_now_ms(); - const int64_t frame_ms = std::max(1, samples / 48); // @48 kHz - if (ls.last_send_ms < 0 || (now_ms - ls.last_send_ms) > frame_ms * 3) - hdr.flags |= voicecat::net::kFlagMarker; - ls.last_send_ms = now_ms; - // DTX: Opus emits a 1–2 byte comfort-noise packet when it gates silence. Flag it so the - // receiver can treat it as such (informational; the bounded-depth playout handles timing). - if (opus_len <= 2) hdr.flags |= voicecat::net::kFlagDtx; - - uint8_t header_bytes[voicecat::net::kVoiceHeaderSize]; - voicecat::net::serialize_header(hdr, header_bytes); - - uint8_t sealed[1500]; - long sealed_len = media_send_crypto_->seal(opus_buf, static_cast(opus_len), - header_bytes, voicecat::net::kVoiceHeaderSize, - sealed, sizeof(sealed)); - if (sealed_len < 0) return; - - std::vector pkt(voicecat::net::kVoiceHeaderSize + static_cast(sealed_len)); - std::memcpy(pkt.data(), header_bytes, voicecat::net::kVoiceHeaderSize); - std::memcpy(pkt.data() + voicecat::net::kVoiceHeaderSize, sealed, - static_cast(sealed_len)); - - sockaddr_in dest{}; - dest.sin_family = AF_INET; - dest.sin_addr.s_addr = udp_dest_addr_; - dest.sin_port = udp_dest_port_; - ::sendto(static_cast(fd), reinterpret_cast(pkt.data()), - static_cast(pkt.size()), 0, reinterpret_cast(&dest), sizeof(dest)); -} - -void vc_client::ensure_audio_running() { - if (audio_engine_.running()) return; - voicecat::audio::AudioParams p; - p.sample_rate = 48000; - p.capture_channels = 1; - p.playback_channels = 2; // true stereo output (audio_engine.cpp on_playback) - p.frame_ms = 20; - { - std::lock_guard lk(local_streams_mu_); - auto it = local_streams_.find(static_cast(VC_STREAM_MIC)); - if (it != local_streams_.end()) { - p.capture_device_id = it->second.capture_device_id; - p.capture_channels = it->second.capture_channels; - // iOS VPIO: when the mic is fed externally, skip the hardware capture device — the - // Swift AVAudioEngine VPIO path feeds processed mic PCM via vc_stream_feed_pcm. - p.external_capture = it->second.external_feed; - } - } - - if (external_playback_.load(std::memory_order_acquire)) { - p.external_capture = true; - } - - audio_engine_.set_external_playback(external_playback_.load(std::memory_order_acquire)); - audio_engine_.start(p, [this](int kind, const int16_t* pcm, int samples, int channels) { - on_capture_frame(kind, pcm, samples, channels); - }); -} - -void vc_client::sync_remote_streams(const voicecat::v1::User& user) { - if (user.id() == self_user_id_) return; - // Don't wire up remote decoders when not on the voice plane — the server isn't relaying - // voice to us, and decoders would sit idle consuming memory. resync_remote_streams() - // wires them up on voice join. - if (!voice_subscribed_.load(std::memory_order_acquire)) return; - - std::vector current_ssrcs; - for (const auto& si : user.streams()) current_ssrcs.push_back(si.ssrc()); - - std::vector> newly_added; // {ssrc, stream_id} - { - std::lock_guard lk(remote_streams_mu_); - for (const auto& si : user.streams()) { - uint32_t ssrc = si.ssrc(); - if (remote_streams_.count(ssrc)) continue; - - remote_streams_[ssrc] = {user.id(), si.stream_id()}; - - voicecat::codec::OpusParams p = opus_params_from_audio_config(si.audio()); - // Only a MIC stream is voice; receive-side NR denoises voice only - bool is_voice = si.kind() == voicecat::v1::STREAM_MIC; - audio_engine_.init_recv_stream(ssrc, p, user.id(), si.stream_id(), is_voice); - bool muted = self_deafened_.load(std::memory_order_acquire) || - server_deafened_.load(std::memory_order_acquire); - audio_engine_.set_stream_mute(ssrc, muted); - - newly_added.emplace_back(ssrc, si.stream_id()); - } - - for (auto it = remote_streams_.begin(); it != remote_streams_.end();) { - if (it->second.first == user.id() && - std::find(current_ssrcs.begin(), current_ssrcs.end(), it->first) == - current_ssrcs.end()) { - uint32_t stream_id = it->second.second; - audio_engine_.remove_stream(it->first); - it = remote_streams_.erase(it); - - vc_event ev{}; - ev.type = VC_EVENT_STREAM_STOPPED; - ev.user_id = user.id(); - ev.stream_id = stream_id; - emit(ev); - } else { - ++it; - } - } - } - - if (!newly_added.empty()) ensure_audio_running(); - for (auto& [ssrc, stream_id] : newly_added) { - (void)ssrc; - vc_event ev{}; - ev.type = VC_EVENT_STREAM_STARTED; - ev.user_id = user.id(); - ev.stream_id = stream_id; - emit(ev); - } -} - -// Stream / device control - -vc_result vc_client::stream_start(const vc_stream_desc& desc, uint32_t* out_stream_id) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - - int kind = static_cast(desc.kind); - uint64_t req_id; - { - std::lock_guard lk(local_streams_mu_); - auto& ls = local_streams_[kind]; // default-constructs if this kind is new - if (ls.active.load(std::memory_order_acquire) || ls.pending) return VC_ERR_ALREADY; - - uint32_t sid = next_local_stream_id_++; - ls.stream_id = sid; - ls.pending = true; - ls.external_feed = (desc.external_feed != 0); - ls.label = desc.label ? desc.label : ""; - if (out_stream_id) *out_stream_id = sid; - - req_id = next_req_id_++; - ls.pending_request_id = req_id; - pending_announce_kind_[req_id] = kind; - } - - voicecat::v1::Envelope req; - req.set_request_id(req_id); - auto* ann = req.mutable_stream_announce(); - ann->set_kind(desc.kind == VC_STREAM_SCREEN_AUDIO ? voicecat::v1::STREAM_SCREEN_AUDIO - : desc.kind == VC_STREAM_AUX_DEVICE ? voicecat::v1::STREAM_AUX_DEVICE - : voicecat::v1::STREAM_MIC); - if (desc.label) ann->set_label(desc.label); - auto* audio = ann->mutable_requested_audio(); - audio->set_sample_rate(48000); - // bitrate_bps intentionally left unset (0 = "no preference"): the channel's AudioConfig - // is authoritative when the channel has one - // as a fallback when it doesn't. - audio->set_frame_ms(20); - audio->set_fec(true); - queue_envelope(req); - return VC_OK; -} - -void vc_client::handle_stream_announce_result(uint64_t req_id, - const voicecat::v1::StreamAnnounceResult& msg) { - uint32_t self_uid; - uint32_t emit_stream_id; - bool ok_to_emit = false; - int kind; - int loopback_channels = 1; // only meaningful for SCREEN_AUDIO; set under the lock - bool loopback_external = false; - { - std::lock_guard lk(local_streams_mu_); - auto pit = pending_announce_kind_.find(req_id); - if (pit == pending_announce_kind_.end()) return; // stray/duplicate — ignore - kind = pit->second; - pending_announce_kind_.erase(pit); - - auto sit = local_streams_.find(kind); - if (sit == local_streams_.end() || !sit->second.pending) return; - auto& ls = sit->second; - ls.pending = false; - - if (!msg.ok()) { - emit_error(VC_ERR_PROTOCOL, msg.error().c_str()); - return; - } - - ls.ssrc = msg.ssrc(); - ls.timestamp = 0; - ls.effective_params = opus_params_from_audio_config(msg.effective_audio()); - ls.frame_samples = static_cast(voicecat::codec::opus_frame_samples(ls.effective_params)); - - if (!ls.encoder.init(ls.effective_params)) { - emit_error(VC_ERR_AUDIO, "opus encoder init failed"); - return; - } - - // Pre-size the reframe buffers used by on_capture_frame when the channel's frame_ms - // differs from the engine's 20 ms - const size_t fs = ls.frame_samples; - ls.encode_accum.assign((fs + 960) * 2, 0); - ls.upmix_scratch.assign(fs * 2, 0); - ls.accum_count = 0; - ls.accum_channels = 0; - - ls.active.store(true, std::memory_order_release); - self_uid = self_user_id_; - emit_stream_id = ls.stream_id; - ok_to_emit = true; - - // Construct the MIC VAD once, here on io_thread_ (not the RT capture callback) — see - // client.h's comment on mic_vad_. - if (kind == static_cast(VC_STREAM_MIC) && !mic_vad_) { - mic_vad_ = voicecat::audio::ApmProcessor::create_vad( - vad_threshold_.load(std::memory_order_relaxed)); - } - // Mic noise suppressor, built once here (not on the RT capture callback). Always created - // so the toggle is a pure atomic flip — see client.h's comment on mic_ns_. - if (kind == static_cast(VC_STREAM_MIC) && !mic_ns_) { - mic_ns_ = voicecat::audio::ApmProcessor::create(); - } - - // SCREEN_AUDIO loopback opens the WASAPI device in the channel's mode: stereo capture - // when the channel is stereo (real L/R, no downmix), mono otherwise. Captured under - // the lock alongside the rest of the LocalStream setup; used below after unlock. - if (kind == static_cast(VC_STREAM_SCREEN_AUDIO)) { - loopback_channels = ls.effective_params.stereo ? 2 : 1; - loopback_external = ls.external_feed; - } - } - - ensure_audio_running(); - if (kind == static_cast(VC_STREAM_SCREEN_AUDIO) && !loopback_external) { - audio_engine_.start_loopback_capture(kind, loopback_channels); - } - - if (ok_to_emit) { - vc_event ev{}; - ev.type = VC_EVENT_STREAM_STARTED; - ev.user_id = self_uid; - ev.stream_id = emit_stream_id; - emit(ev); - } -} - -vc_result vc_client::stream_stop(uint32_t stream_id) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - - int stopped_kind = -1; - bool stopped_external = false; - { - std::lock_guard lk(local_streams_mu_); - for (auto& [k, ls] : local_streams_) { - if (ls.stream_id == stream_id) { stopped_kind = k; break; } - } - LocalStream* ls = find_local_stream_by_id(stream_id); - if (!ls || !ls->active.load(std::memory_order_acquire)) return VC_ERR_INVALID_ARG; - stopped_external = ls->external_feed; - ls->active.store(false, std::memory_order_release); - ls->encoder.destroy(); - } - - if (stopped_kind == static_cast(VC_STREAM_SCREEN_AUDIO) && !stopped_external) { - audio_engine_.stop_loopback_capture(); - } - - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - req.mutable_stream_stop()->set_stream_id(stream_id); - queue_envelope(req); - - vc_event ev{}; - ev.type = VC_EVENT_STREAM_STOPPED; - ev.user_id = self_user_id_; - ev.stream_id = stream_id; - emit(ev); - return VC_OK; -} - -vc_client::LocalStream* vc_client::find_local_stream_by_id(uint32_t stream_id) { - for (auto& [kind, ls] : local_streams_) { - (void)kind; - if (ls.stream_id == stream_id) return &ls; - } - return nullptr; -} - -void vc_client::restart_active_streams_for_channel(uint32_t channel_id) { - // Collect active stream info under the lock, then stopstart outside the lock (stream_stop - // and stream_start both acquire local_streams_mu_). capture_device_id and capture_channels - // survive the restart: stream_stop doesn't clear them, and stream_start reuses the existing - // LocalStream entry (auto& ls = local_streams_[kind]) without resetting them. - struct ActiveStreamInfo { - int kind; - std::string label; - int external_feed; - uint32_t stream_id; - }; - std::vector active; - { - std::lock_guard lk(local_streams_mu_); - for (auto& [kind, ls] : local_streams_) { - if (!ls.active.load(std::memory_order_acquire)) continue; - active.push_back({kind, ls.label, ls.external_feed ? 1 : 0, ls.stream_id}); - } - } - - for (const auto& info : active) { - stream_stop(info.stream_id); - vc_stream_desc desc{}; - desc.kind = static_cast(info.kind); - desc.device_id = nullptr; // default — capture_device_id is retained on the LocalStream - desc.label = info.label.c_str(); - desc.external_feed = info.external_feed; - uint32_t new_sid = 0; - stream_start(desc, &new_sid); - } -} - -// Voice-plane subscription - -vc_result vc_client::join_voice() { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - req.mutable_subscribe_voice(); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::leave_voice() { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - req.mutable_unsubscribe_voice(); - queue_envelope(req); - return VC_OK; -} - -void vc_client::handle_voice_subscription_result( - const voicecat::v1::VoiceSubscriptionResult& msg) { - bool subscribed = msg.subscribed(); - voice_subscribed_.store(subscribed, std::memory_order_release); - - if (subscribed) { - // Wire up remote-stream decoders for all users already in the session model. - // While we were unsubscribed, sync_remote_streams was gated off, so no decoders - // exist — resync from the current session state. - resync_remote_streams(); - } else { - // Stop all active local streams (emits VC_EVENT_STREAM_STOPPED for each) and tear - // down every remote decoder so playback ceases. The server has already stopped - // relaying voice to us. - stop_all_local_streams(); - clear_all_remote_streams(); - } - - vc_event ev{}; - ev.type = VC_EVENT_VOICE_STATE; - ev.u32a = subscribed ? 1 : 0; - emit(ev); -} - -void vc_client::resync_remote_streams() { - bool any_added = false; - { - std::lock_guard sm_lk(session_model_mu_); - std::lock_guard rs_lk(remote_streams_mu_); - for (const auto& u : session_model_.users()) { - if (u.id == self_user_id_) continue; - for (const auto& s : u.streams) { - if (remote_streams_.count(s.ssrc)) continue; - remote_streams_[s.ssrc] = {u.id, s.stream_id}; - - voicecat::v1::AudioConfig audio; - audio.set_sample_rate(s.sample_rate); - audio.set_frame_ms(s.frame_ms); - audio.set_mode(static_cast(s.mode)); - audio.set_bitrate_bps(s.bitrate_bps); - audio.set_application(static_cast(s.application)); - audio.set_fec(s.fec); - audio.set_expected_packet_loss(s.expected_packet_loss); - audio.set_dtx(s.dtx); - audio.set_complexity(s.complexity); - audio.set_dred(s.dred); - - voicecat::codec::OpusParams p = opus_params_from_audio_config(audio); - bool is_voice = (s.kind == static_cast(voicecat::v1::STREAM_MIC)); - audio_engine_.init_recv_stream(s.ssrc, p, u.id, s.stream_id, is_voice); - bool muted = self_deafened_.load(std::memory_order_acquire) || - server_deafened_.load(std::memory_order_acquire); - audio_engine_.set_stream_mute(s.ssrc, muted); - any_added = true; - } - } - } - if (any_added) ensure_audio_running(); -} - -void vc_client::clear_all_remote_streams() { - std::lock_guard lk(remote_streams_mu_); - for (auto& [ssrc, info] : remote_streams_) { - audio_engine_.remove_stream(ssrc); - } - remote_streams_.clear(); -} - -void vc_client::stop_all_local_streams() { - std::vector active_ids; - { - std::lock_guard lk(local_streams_mu_); - for (auto& [kind, ls] : local_streams_) { - if (ls.active.load(std::memory_order_acquire)) { - active_ids.push_back(ls.stream_id); - } - } - } - for (uint32_t sid : active_ids) { - stream_stop(sid); - } -} - -vc_result vc_client::set_input_device(uint32_t stream_id, const char* device_id) { - int kind = -1; - { - std::lock_guard lk(local_streams_mu_); - LocalStream* ls = find_local_stream_by_id(stream_id); - if (!ls) return VC_ERR_INVALID_ARG; - ls->capture_device_id = device_id ? device_id : ""; - for (auto& [k, entry] : local_streams_) { - if (&entry == ls) { kind = k; break; } - } - } - - if (kind == static_cast(VC_STREAM_MIC) && audio_engine_.running()) { - audio_engine_.stop(); - ensure_audio_running(); - } - return VC_OK; -} - -vc_result vc_client::set_capture_channels(uint32_t stream_id, uint32_t channels) { - if (channels != 1 && channels != 2) return VC_ERR_INVALID_ARG; - std::lock_guard lk(local_streams_mu_); - LocalStream* ls = find_local_stream_by_id(stream_id); - if (!ls) return VC_ERR_INVALID_ARG; - ls->capture_channels = channels; - // Engine restart is the caller's responsibility (via vc_audio_restart), issued AFTER - // AVAudioSession routing has settled. The stored channel count is picked up by - // ensure_audio_running() on the next (re)start. On iOS this avoids the race where - // starting the stereo capture AudioUnit immediately (before the playback device is - // committed to its route) collapses the A2DP output. - return VC_OK; -} - -vc_result vc_client::set_input_mode(vc_input_mode mode) { - current_input_mode_.store(mode, std::memory_order_release); - return VC_OK; -} - -vc_result vc_client::set_vad_threshold(float threshold) { - if (threshold < 0.0f || threshold > 1.0f) return VC_ERR_INVALID_ARG; - vad_threshold_.store(threshold, std::memory_order_relaxed); - if (mic_vad_) mic_vad_->set_threshold(threshold); - return VC_OK; -} - -vc_result vc_client::set_push_to_talk(bool active) { - ptt_active_.store(active, std::memory_order_release); - return VC_OK; -} - -vc_result vc_client::set_self_mute(bool mic_muted, bool deafened) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - self_mic_muted_.store(mic_muted, std::memory_order_release); - self_deafened_.store(deafened, std::memory_order_release); - - std::lock_guard lk(remote_streams_mu_); - bool muted = deafened || server_deafened_.load(std::memory_order_acquire); - for (auto& [ssrc, info] : remote_streams_) { - (void)info; - audio_engine_.set_stream_mute(ssrc, muted); - } - return VC_OK; -} - -vc_result vc_client::set_output_volume(float gain) { - audio_engine_.set_output_volume(gain < 0.0f ? 0.0f : gain); - return VC_OK; -} - -vc_result vc_client::set_input_gain(float gain) { - input_gain_.store(gain < 0.0f ? 0.0f : gain, std::memory_order_relaxed); - return VC_OK; -} - -vc_result vc_client::set_input_noise_reduction(bool enable) { - // Pure local toggle (like set_input_gain): mic_ns_ is created with the MIC stream, this only - // flips whether the capture callback runs it. Safe to call before a MIC stream exists. - input_noise_reduction_.store(enable, std::memory_order_relaxed); - return VC_OK; -} - -vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, - bool muted, bool noise_reduction) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - uint32_t ssrc = 0; - bool found = false; - { - std::lock_guard lk(session_model_mu_); - const auto* user = session_model_.find_user(user_id); - if (user) { - for (const auto& s : user->streams) { - if (s.stream_id == stream_id) { ssrc = s.ssrc; found = true; break; } - } - } - } - if (!found) return VC_ERR_INVALID_ARG; - audio_engine_.set_stream_gain(ssrc, gain); - audio_engine_.set_stream_mute(ssrc, muted); - audio_engine_.set_stream_noise_reduction(ssrc, noise_reduction); - return VC_OK; -} - -vc_result vc_client::get_remote_stream(uint32_t user_id, uint32_t stream_id, - vc_remote_stream_state* out) { - if (out == nullptr) return VC_ERR_INVALID_ARG; - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - uint32_t ssrc = 0; - bool found = false; - { - std::lock_guard lk(session_model_mu_); - const auto* user = session_model_.find_user(user_id); - if (user) { - for (const auto& s : user->streams) { - if (s.stream_id == stream_id) { ssrc = s.ssrc; found = true; break; } - } - } - } - if (!found) return VC_ERR_INVALID_ARG; - // The (user, stream) is known. The RemoteStream entry may not exist yet if the listener - // has neither set any control nor received audio for it, in that case report the - // defaults so the UI opens at 100/unmuted/NR-off. - float gain = 1.0f; bool mute = false; bool nr = false; - if (audio_engine_.get_stream_state(ssrc, gain, mute, nr)) { - out->gain = gain; - out->muted = mute ? 1 : 0; - out->noise_reduction = nr ? 1 : 0; - } else { - out->gain = 1.0f; - out->muted = 0; - out->noise_reduction = 0; - } - return VC_OK; -} - -vc_result vc_client::audio_suspend() { - return audio_engine_.suspend() ? VC_OK : VC_ERR_AUDIO; -} - -vc_result vc_client::audio_resume() { - return audio_engine_.resume() ? VC_OK : VC_ERR_AUDIO; -} - -vc_result vc_client::audio_restart() { - // Full uninit + re-init (not just stop/start like suspend/resume) so the miniaudio - // devices reopen against the current AVAudioSession route. - // IMPORTANT: only restart if the engine was already running — calling - // ensure_audio_running() when the engine isn't running would start it prematurely - // (opening the capture device / mic without an active mic stream), which on iOS - // triggers a route reconfiguration that can collapse the output route. - bool was_running = audio_engine_.running(); - audio_engine_.stop(); - if (was_running) { - ensure_audio_running(); - } - return VC_OK; -} - -vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id, - vc_audio_config* out) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - - if (user_id == self_user_id_) { - std::lock_guard lk(local_streams_mu_); - LocalStream* ls = find_local_stream_by_id(stream_id); - if (!ls || !ls->active.load(std::memory_order_acquire)) return VC_ERR_INVALID_ARG; - const auto& p = ls->effective_params; - out->codec = 0; - out->mode = p.stereo ? 1u : 0u; - // Report the channel's configured sample_rate (carried as max_bandwidth_hz), not the - // fixed 48 kHz codec clock, matches what the remote-stream path below reports. - out->sample_rate = p.max_bandwidth_hz ? p.max_bandwidth_hz : p.sample_rate; - out->bitrate_bps = p.bitrate_bps; - out->frame_ms = p.frame_ms; - out->application = static_cast(p.application); - out->fec = p.fec ? 1 : 0; - out->expected_packet_loss = p.expected_packet_loss; - out->dtx = p.dtx ? 1 : 0; - out->complexity = p.complexity; - out->dred = p.dred ? 1 : 0; - return VC_OK; - } - - std::lock_guard lk(session_model_mu_); - const auto* user = session_model_.find_user(user_id); - if (!user) return VC_ERR_INVALID_ARG; - for (const auto& s : user->streams) { - if (s.stream_id != stream_id) continue; - out->codec = 0; - out->mode = s.mode; - out->sample_rate = s.sample_rate; - out->bitrate_bps = s.bitrate_bps; - out->frame_ms = s.frame_ms; - out->application = s.application; - out->fec = s.fec ? 1 : 0; - out->expected_packet_loss = s.expected_packet_loss; - out->dtx = s.dtx ? 1 : 0; - out->complexity = s.complexity; - out->dred = s.dred ? 1 : 0; - return VC_OK; - } - return VC_ERR_INVALID_ARG; -} - -vc_result vc_client::stream_feed_pcm(uint32_t stream_id, const int16_t* pcm, - size_t samples_per_channel, uint32_t channels) { - int kind = -1; - { - std::lock_guard lk(local_streams_mu_); - for (auto& [k, ls] : local_streams_) { - if (ls.stream_id == stream_id && ls.active.load(std::memory_order_acquire)) { - kind = k; - break; - } - } - } - if (kind < 0) return VC_ERR_INVALID_ARG; - audio_engine_.inject_capture(kind, pcm, samples_per_channel, static_cast(channels)); - return VC_OK; -} - -vc_result vc_client::set_pcm_sink(vc_pcm_sink_cb cb, void* user) { - audio_engine_.set_pcm_sink( - reinterpret_cast(cb), user); - return VC_OK; -} - -vc_result vc_client::set_mixed_output_sink(vc_mixed_output_cb cb, void* user) { - audio_engine_.set_mixed_output_sink( - reinterpret_cast(cb), user); - return VC_OK; -} - -vc_result vc_client::set_external_playback(bool enable) { - external_playback_.store(enable, std::memory_order_release); - // Stored on the engine too. takes effect on the next start()/vc_audio_restart() (matching - // the vc_set_capture_channels "apply on next restart" - audio_engine_.set_external_playback(enable); - return VC_OK; -} - -vc_result vc_client::test_inject_capture(uint32_t stream_id, const int16_t* pcm, size_t samples) { - return stream_feed_pcm(stream_id, pcm, samples, 1); -} - -vc_result vc_client::list_devices(vc_device_kind kind, vc_device_list* out) { - // Works in any connection state, device pickers need to populate pre-connect. - auto devices = voicecat::audio::AudioEngine::enumerate_devices(kind == VC_DEVICE_INPUT); - - auto* items = new vc_device[devices.size()]; - for (size_t i = 0; i < devices.size(); ++i) { - const auto& d = devices[i]; - auto* id = new char[d.id.size() + 1]; - auto* name = new char[d.name.size() + 1]; - std::memcpy(id, d.id.c_str(), d.id.size() + 1); - std::memcpy(name, d.name.c_str(), d.name.size() + 1); - items[i].id = id; - items[i].name = name; - items[i].is_default = d.is_default ? 1 : 0; - } - out->items = items; - out->count = devices.size(); - return VC_OK; -} - - - -vc_result vc_client::list_channels(vc_channel_list* out) { - std::lock_guard lk(session_model_mu_); - const auto& channels = session_model_.channels(); - auto* items = new vc_channel[channels.size()]; - for (size_t i = 0; i < channels.size(); ++i) { - const auto& ch = channels[i]; - auto* name = new char[ch.name.size() + 1]; - auto* topic = new char[ch.topic.size() + 1]; - std::memcpy(name, ch.name.c_str(), ch.name.size() + 1); - std::memcpy(topic, ch.topic.c_str(), ch.topic.size() + 1); - items[i].id = ch.id; - items[i].parent_id = ch.parent_id; - items[i].name = name; - items[i].topic = topic; - items[i].password_protected = ch.password_protected ? 1 : 0; - items[i].max_users = ch.max_users; - items[i].sort_order = ch.sort_order; - auto& ac = items[i].audio; - ac.codec = 0; // OPUS - ac.mode = ch.audio_mode; - ac.sample_rate = ch.audio_sample_rate; - ac.bitrate_bps = ch.audio_bitrate_bps; - ac.frame_ms = ch.audio_frame_ms; - ac.application = ch.audio_application; - ac.fec = ch.audio_fec ? 1 : 0; - ac.expected_packet_loss = ch.audio_expected_packet_loss; - ac.dtx = ch.audio_dtx ? 1 : 0; - ac.complexity = ch.audio_complexity; - ac.dred = ch.audio_dred ? 1 : 0; - } - out->items = items; - out->count = channels.size(); - return VC_OK; -} - -vc_result vc_client::list_users(vc_user_list* out) { - std::lock_guard lk(session_model_mu_); - const auto& users = session_model_.users(); - auto* items = new vc_user[users.size()]; - for (size_t i = 0; i < users.size(); ++i) { - const auto& u = users[i]; - auto* nick = new char[u.nickname.size() + 1]; - std::memcpy(nick, u.nickname.c_str(), u.nickname.size() + 1); - items[i].id = u.id; - items[i].nickname = nick; - items[i].is_guest = u.is_guest ? 1 : 0; - items[i].channel_id = u.channel_id; - items[i].self_mic_muted = u.self_mic_muted ? 1 : 0; - items[i].self_deafened = u.self_deafened ? 1 : 0; - items[i].server_muted = u.server_muted ? 1 : 0; - items[i].server_deafened = u.server_deafened ? 1 : 0; - items[i].voice_subscribed = u.voice_subscribed ? 1 : 0; - } - out->items = items; - out->count = users.size(); - return VC_OK; -} - -vc_result vc_client::list_user_streams(uint32_t user_id, vc_stream_summary_list* out) { - std::lock_guard lk(session_model_mu_); - const auto* user = session_model_.find_user(user_id); - if (!user) return VC_ERR_INVALID_ARG; - auto* items = new vc_stream_summary[user->streams.size()]; - for (size_t i = 0; i < user->streams.size(); ++i) { - const auto& s = user->streams[i]; - auto* label = new char[s.label.size() + 1]; - std::memcpy(label, s.label.c_str(), s.label.size() + 1); - items[i].stream_id = s.stream_id; - items[i].kind = static_cast(s.kind); - items[i].label = label; - } - out->items = items; - out->count = user->streams.size(); - return VC_OK; -} - - - -vc_result vc_client::confirm_server_identity(bool accept) { - std::lock_guard lk(tofu_mu_); - if (!tofu_decision_pending_) return VC_ERR_INVALID_ARG; - tofu_accept_ = accept; - tofu_decision_pending_ = false; - tofu_cv_.notify_all(); - return VC_OK; -} - -vc_result vc_client::get_server_identity_display(char* out_buf, size_t buf_cap, - size_t* out_len) { - std::string display; - { - std::lock_guard lk(tofu_mu_); - display = pending_identity_fp_hex_; - } - if (out_len) *out_len = display.size(); - if (!out_buf) return VC_OK; // size-query mode - if (buf_cap < display.size() + 1) return VC_ERR_INVALID_ARG; - std::memcpy(out_buf, display.c_str(), display.size() + 1); - return VC_OK; -} - - - -vc_result vc_client::kick_user(uint32_t user_id, const char* reason) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* k = req.mutable_kick(); - k->set_user_id(user_id); - k->set_reason(reason ? reason : ""); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::ban_user(uint32_t user_id, const char* reason, uint64_t expires_unix_ms) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* b = req.mutable_ban(); - b->set_user_id(user_id); - b->set_reason(reason ? reason : ""); - b->set_expires_unix_ms(expires_unix_ms); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::set_permission(uint32_t user_id, const vc_permissions* perms) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - if (!perms) return VC_ERR_INVALID_ARG; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* sp = req.mutable_set_permission(); - sp->set_user_id(user_id); - auto* p = sp->mutable_permissions(); - p->set_can_create_temp_channel(perms->can_create_temp_channel != 0); - p->set_can_kick(perms->can_kick != 0); - p->set_can_ban(perms->can_ban != 0); - p->set_can_move_users(perms->can_move_users != 0); - p->set_can_admin_accounts(perms->can_admin_accounts != 0); - p->set_is_admin(perms->is_admin != 0); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::set_server_mute(uint32_t user_id, bool muted, bool deafened) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* sm = req.mutable_server_mute(); - sm->set_user_id(user_id); - sm->set_muted(muted); - sm->set_deafened(deafened); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::move_user(uint32_t user_id, uint32_t channel_id) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* m = req.mutable_move_user(); - m->set_user_id(user_id); - m->set_channel_id(channel_id); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::create_channel(const vc_channel_info* info) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - if (!info || !info->name) return VC_ERR_INVALID_ARG; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* cc = req.mutable_create_channel(); - *cc->mutable_channel() = channel_from_vc(*info); - if (info->password_protected && info->password) cc->set_password(info->password); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::edit_channel(const vc_channel_info* info) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - if (!info || !info->name) return VC_ERR_INVALID_ARG; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* ec = req.mutable_edit_channel(); - *ec->mutable_channel() = channel_from_vc(*info); - if (info->password_protected && info->password) ec->set_password(info->password); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::delete_channel(uint32_t channel_id) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - req.mutable_delete_channel()->set_channel_id(channel_id); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::create_account(const char* username, const char* password) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - if (!username || !password) return VC_ERR_INVALID_ARG; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* ca = req.mutable_create_account(); - ca->set_username(username); - ca->set_password(password); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::reset_password(const char* username, const char* new_password) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - if (!username || !new_password) return VC_ERR_INVALID_ARG; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - auto* rp = req.mutable_reset_password(); - rp->set_username(username); - rp->set_new_password(new_password); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::delete_account(const char* username) { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - if (!username) return VC_ERR_INVALID_ARG; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - req.mutable_delete_account()->set_username(username); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::list_accounts() { - if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - voicecat::v1::Envelope req; - req.set_request_id(next_req_id_++); - req.mutable_list_accounts(); - queue_envelope(req); - return VC_OK; -} - -vc_result vc_client::get_account_list(vc_account_list* out) { - if (!out) return VC_ERR_INVALID_ARG; - std::lock_guard lk(account_list_mu_); - auto* items = new vc_account[last_account_list_.size()]; - for (size_t i = 0; i < last_account_list_.size(); ++i) { - const auto& a = last_account_list_[i]; - auto* user = new char[a.username().size() + 1]; - std::memcpy(user, a.username().c_str(), a.username().size() + 1); - items[i].username = user; - items[i].is_admin = a.is_admin() ? 1 : 0; - items[i].created_at_unix_ms = a.created_at_unix_ms(); - items[i].last_login_unix_ms = a.last_login_unix_ms(); - } - out->items = items; - out->count = last_account_list_.size(); - return VC_OK; -} - -vc_result vc_client::get_permissions(vc_permissions* out) { - if (!out) return VC_ERR_INVALID_ARG; - *out = own_permissions_; - return state_net_.load(std::memory_order_acquire) == VC_STATE_CONNECTED ? VC_OK : VC_ERR_NOT_CONNECTED; -} - -void vc_client::run_talk_timer() { - while (!talk_timer_stop_.load(std::memory_order_acquire)) { - // Remote streams: ask the engine for edge-triggered transitions, map ssrc -> (user, - // stream) via remote_streams_, emit. - for (auto& [ssrc, talking] : audio_engine_.poll_talk_transitions()) { - uint32_t uid = 0, sid = 0; - { - std::lock_guard lk(remote_streams_mu_); - auto it = remote_streams_.find(ssrc); - if (it == remote_streams_.end()) continue; - uid = it->second.first; - sid = it->second.second; - } - vc_event ev{}; - ev.type = VC_EVENT_TALK_STATE; - ev.user_id = uid; - ev.stream_id = sid; - ev.u32a = talking ? 1u : 0u; - emit(ev); - } - - // Local streams: same hangover logic, driven by on_capture_frame's last_capture_ms. - std::vector local_events; - { - std::lock_guard lk(local_streams_mu_); - int64_t now = client_now_ms(); - for (auto& [kind, ls] : local_streams_) { - (void)kind; - if (!ls.active.load(std::memory_order_acquire)) continue; - bool now_talking = - (now - ls.last_capture_ms.load(std::memory_order_relaxed)) < kTalkHangoverMs; - if (now_talking != ls.talking) { - ls.talking = now_talking; - vc_event ev{}; - ev.type = VC_EVENT_TALK_STATE; - ev.user_id = self_user_id_; - ev.stream_id = ls.stream_id; - ev.u32a = now_talking ? 1u : 0u; - local_events.push_back(ev); - } - } - } - for (auto& ev : local_events) emit(ev); - - std::this_thread::sleep_for(std::chrono::milliseconds(kTalkPollMs)); - } -} diff --git a/core/src/core/client.h b/core/src/core/client.h deleted file mode 100644 index 7ea8f8a..0000000 --- a/core/src/core/client.h +++ /dev/null @@ -1,409 +0,0 @@ -/* - * client.h: the implementation type behind the opaque `vc_client*` handle. - */ -#ifndef VOICECAT_CORE_CLIENT_H -#define VOICECAT_CORE_CLIENT_H - -#include "voicecat.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "audio/audio_engine.h" -#include "codec/opus_codec.h" -#include "crypto/crypto.h" -#include "crypto/tofu_store.h" -#include "net/voice_frame.h" -#include "protocol/envelope.h" -#include "protocol/protocol.h" -#include "session/session.h" -#include "proto/voicecat.pb.h" - -struct vc_client { - vc_client(const vc_config& cfg, vc_callbacks cb); - ~vc_client(); - - vc_client(const vc_client&) = delete; - vc_client& operator=(const vc_client&) = delete; - - vc_result connect(const char* host, uint16_t port); - vc_result disconnect(); - vc_result authenticate_guest(const char* nickname); - vc_result authenticate_user(const char* username, const char* password); - - vc_result join_channel(uint32_t channel_id, const char* password); - vc_result leave_channel(); - - vc_result join_voice(); - vc_result leave_voice(); - - vc_result stream_start(const vc_stream_desc& desc, uint32_t* out_stream_id); - vc_result stream_stop(uint32_t stream_id); - vc_result set_input_device(uint32_t stream_id, const char* device_id); - vc_result set_capture_channels(uint32_t stream_id, uint32_t channels); - vc_result set_input_mode(vc_input_mode mode); - vc_result set_vad_threshold(float threshold); - vc_result set_push_to_talk(bool active); - vc_result set_self_mute(bool mic_muted, bool deafened); - vc_result set_output_volume(float gain); - vc_result set_input_gain(float gain); - vc_result set_input_noise_reduction(bool enable); - vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted, - bool noise_reduction); - vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id, - vc_remote_stream_state* out); - vc_result audio_suspend(); - vc_result audio_resume(); - vc_result audio_restart(); - - vc_result send_text(vc_text_scope scope, uint32_t target_id, const char* utf8); - - vc_result list_devices(vc_device_kind kind, vc_device_list* out); - - // Channel/user/stream snapshot getters (read session_model_; see voicecat.h). - vc_result list_channels(vc_channel_list* out); - vc_result list_users(vc_user_list* out); - vc_result list_user_streams(uint32_t user_id, vc_stream_summary_list* out); - - // TOFU server-identity gate - vc_result confirm_server_identity(bool accept); - vc_result get_server_identity_display(char* out_buf, size_t buf_cap, size_t* out_len); - - // Effective Opus config for a (user_id, stream_id) — our own pending/active local - // streams, or any peer's broadcast StreamInfo.audio. - vc_result get_stream_audio_config(uint32_t user_id, uint32_t stream_id, - vc_audio_config* out); - - - vc_result stream_feed_pcm(uint32_t stream_id, const int16_t* pcm, - size_t samples_per_channel, uint32_t channels); - - // External PCM sink (see voicecat.h: vc_set_pcm_sink). Delegates to AudioEngine. - vc_result set_pcm_sink(vc_pcm_sink_cb cb, void* user); - - // External mixed-output sink + external-playback mode (iOS VPIO; see voicecat.h). - vc_result set_mixed_output_sink(vc_mixed_output_cb cb, void* user); - vc_result set_external_playback(bool enable); - - vc_result test_inject_capture(uint32_t stream_id, const int16_t* pcm, size_t samples); - - // Moderation & admin. - vc_result kick_user(uint32_t user_id, const char* reason); - vc_result ban_user(uint32_t user_id, const char* reason, uint64_t expires_unix_ms); - vc_result set_permission(uint32_t user_id, const vc_permissions* perms); - vc_result set_server_mute(uint32_t user_id, bool muted, bool deafened); - vc_result move_user(uint32_t user_id, uint32_t channel_id); - vc_result create_channel(const vc_channel_info* info); - vc_result edit_channel(const vc_channel_info* info); - vc_result delete_channel(uint32_t channel_id); - vc_result create_account(const char* username, const char* password); - vc_result reset_password(const char* username, const char* new_password); - vc_result delete_account(const char* username); - vc_result list_accounts(); - vc_result get_account_list(vc_account_list* out); - vc_result get_permissions(vc_permissions* out); - - vc_connection_state state() const { - return state_net_.load(std::memory_order_acquire); - } - - private: - void emit(const vc_event& ev) const; - - vc_config cfg_{}; - vc_callbacks cb_{}; - - // TCP/TLS control channel - std::atomic state_net_{VC_STATE_DISCONNECTED}; - - // Blocking I/O thread (one per vc_client lifetime) - std::thread io_thread_; - std::atomic io_stop_{false}; - - // Send queue: pushed by any thread, drained by io_thread_ - std::mutex send_mutex_; - std::condition_variable send_cv_; - std::deque> send_queue_; - - // TLS context — created + used exclusively on io_thread_ - std::unique_ptr tls_; - - // Raw socket fd (stored after TCP connect; closed by disconnect()) - std::atomic io_fd_{-1}; - - // Pending auth stored before ServerHello arrives - struct PendingAuth { - bool is_guest{true}; - std::string nick_or_user; - std::string password; - }; - std::mutex pending_auth_mutex_; - std::optional pending_auth_; - - // Self identity filled in after AuthResult - uint32_t self_user_id_{0}; - uint64_t server_session_id_{0}; - std::atomic next_req_id_{1}; - - // Voice-plane subscription state. When false, the server does not relay voice frames to - // us, and we do not wire up remote-stream decoders (sync_remote_streams is gated on this). - // Toggled by vc_join_voice / vc_leave_voice; confirmed via VoiceSubscriptionResult. - std::atomic voice_subscribed_{false}; - - // Keepalive: client sends a Ping every ~15s (docs/protocol.md §7) so the server's - // last_seen stays fresh and the reaper doesn't drop us. Pong echoes the nonce, which - // we correlate to measure RTT. The read loop's 50ms TLS timeout means it spins fast - // enough to check the ping interval with ample resolution. - static constexpr int64_t kPingIntervalMs = 15000; - std::atomic last_ping_ms_{0}; - std::atomic ping_nonce_{1}; - std::mutex ping_mutex_; - std::unordered_map pending_pings_; // nonce → sent_ms - std::atomic last_rtt_ms_{0}; - - // Graceful disconnect: set by disconnect() after queueing Disconnect{code=0}. The io - // thread checks this after drain_sends() — when set, it sets io_stop_ and exits the - // read loop, so the Disconnect is sent before the thread ends. The main thread just - // joins; no socket close from the main thread (the io thread's cleanup closes it), - // avoiding the double-close race that the send_cv_ wait approach exposed. - std::atomic graceful_disconnect_pending_{false}; - - // UDP keepalive: send a lightweight KEEPALIVE frame every ~5s to hold NAT bindings and - // bump the server's last_seen independently of the TCP ping (docs/voice.md §6). Sent - // as plaintext (no AEAD) — the server identifies the sender by its already-verified UDP - // endpoint, and the TCP reaper is the real timeout authority. Avoids racing the - // non-atomic send_counter_ in SodiumMediaCrypto::seal() with the audio callback thread. - static constexpr int64_t kUdpKeepaliveIntervalMs = 5000; - std::atomic last_udp_keepalive_ms_{0}; - - // Client-side session model. Mutated only on io_thread_ (handle_server_state/ - // handle_user_event/handle_channel_event), but read from any thread via the - // list_channels/list_users/list_user_streams getters — session_model_mu_ guards both. - voicecat::session::SessionModel session_model_; - mutable std::mutex session_model_mu_; - - // Last ListAccountsResult snapshot, populated on io_thread_ when - // VC_EVENT_ACCOUNT_LIST fires and read by vc_get_account_list on caller threads. - std::vector last_account_list_; - mutable std::mutex account_list_mu_; - - // TOFU server-identity gate - std::unique_ptr tofu_store_; // owns the pin file - std::mutex tofu_mu_; - std::condition_variable tofu_cv_; - bool tofu_decision_pending_{false}; - bool tofu_accept_{false}; - std::string pending_identity_fp_hex_; // ServerHello's Ed25519 fp, display-only - - // UDP / media plane - std::array udp_token_{}; - uint16_t server_udp_port_{0}; - std::string udp_host_; - - std::atomic udp_fd_{-1}; - std::thread udp_thread_; - std::atomic udp_stop_{false}; - std::atomic udp_ready_{false}; - - std::unique_ptr media_send_crypto_; - std::unique_ptr media_recv_crypto_; - - voicecat::audio::AudioEngine audio_engine_; - - // One LocalStream per concurrently-active stream kind (MIC/SCREEN_AUDIO/AUX_DEVICE - // are each singletons for a given client). - struct LocalStream { - voicecat::codec::OpusEncoder encoder; - std::atomic active{false}; - bool pending{false}; // announced, awaiting StreamAnnounceResult - uint32_t stream_id{0}; - uint32_t ssrc{0}; - uint32_t timestamp{0}; - uint16_t frame_samples{960}; - uint64_t pending_request_id{0}; - // Full effective OpusParams from the last StreamAnnounceResult — retained so - // vc_get_stream_audio_config() has something to read back for our own streams. - voicecat::codec::OpusParams effective_params; - - - std::atomic last_capture_ms{0}; - bool talking = false; - - // Talkspurt marker: the sender's `timestamp` omits VAD/PTT/DTX silence, so the receiver - // can't tell a continuation from a post-silence restart. encode_and_send_frame stamps - // kFlagMarker on the first frame after a transmission gap (detected via last_send_ms) so - // the receiver reseeds its playout clock cleanly. -1 = no frame sent yet (first frame is - // always a marker). - int64_t last_send_ms = -1; - - // Device-enumeration follow-up: the device this stream's capture should use ("" = - // default). Only meaningful for VC_STREAM_MIC today (the real capture device); set via - // vc_set_input_device. Opaque id from AudioEngine::enumerate_devices — see - // audio_engine.h's DeviceInfo doc comment. - std::string capture_device_id; - - // Capture channel count (1 = mono, 2 = stereo interleaved). Only meaningful for - // VC_STREAM_MIC. Set via vc_set_capture_channels; read by ensure_audio_running() to - // configure AudioParams.capture_channels before the device opens. Defaults to 1 (mono). - uint32_t capture_channels = 1; - - // If true, the caller is feeding PCM via vc_stream_feed_pcm — skip start/stop of the - // core's WASAPI loopback device. Set from vc_stream_desc::external_feed at stream_start - // time and checked in handle_stream_announce_result / stream_stop. - bool external_feed = false; - - - std::string label; - - // Reframe buffer: the AudioEngine clock is fixed at 48 kHz / 20 ms, so capture/feed - // always delivers 960-sample frames — but the channel's frame_ms (docs/voice.md §3) - // can be 2.5…60 ms, so the encoder needs frame_samples per call (480 @10ms, 1920 @40ms, - // …). on_capture_frame accumulates the engine's 960-sample frames here and emits - // frame_samples-sized chunks. The 20 ms case (frame_samples == 960) bypasses this - // entirely. encode_accum holds interleaved int16 - // at accum_channels; upmix_scratch is the pre-sized monostereo upmix target. - std::vector encode_accum; - size_t accum_count = 0; // flat samples currently buffered - int accum_channels = 0; // channel count of buffered data; resets on change - std::vector upmix_scratch; - }; - mutable std::mutex local_streams_mu_; - std::unordered_map local_streams_; // keyed by vc_stream_kind - std::unordered_map pending_announce_kind_; // request_id -> kind - uint32_t next_local_stream_id_{1}; - - // ssrc (user_id, stream_id) for remote streams already wired into audio_engine_. - mutable std::mutex remote_streams_mu_; - std::unordered_map> remote_streams_; - - // Talk-indicator polling thread (separate from udp_thread_ / the miniaudio callback - // thread. - std::thread talk_timer_thread_; - std::atomic talk_timer_stop_{false}; - static constexpr int64_t kTalkPollMs = 100; - static constexpr int64_t kTalkHangoverMs = 300; - - // Local UDP destination (server media endpoint), resolved once during binding. - uint32_t udp_dest_addr_{0}; // network byte order - uint16_t udp_dest_port_{0}; // network byte order - - std::atomic self_mic_muted_{false}; - std::atomic self_deafened_{false}; - std::atomic server_muted_{false}; - std::atomic server_deafened_{false}; - - // Permissions from last AuthResult. - vc_permissions own_permissions_{}; - - // Send-side input gate MIC-only — SCREEN_AUDIO/ - // AUX_DEVICE are never gated - std::atomic current_input_mode_{VC_INPUT_VOICE_ACTIVATION}; - std::atomic ptt_active_{false}; - std::atomic vad_threshold_{0.025f}; // remembered across mode switches - std::atomic input_gain_{1.0f}; // send-side MIC gain (vc_set_input_gain) - std::atomic input_noise_reduction_{false}; // send-side MIC NS (vc_set_input_noise_reduction) - - - std::atomic external_playback_{false}; - std::unique_ptr mic_vad_; - // Send-side mic noise suppressor (RNNoise). Constructed once with the MIC stream alongside - // mic_vad_ (off the RT capture callback); toggling only flips input_noise_reduction_, so the - // capture callback never allocates or races this pointer. - std::unique_ptr mic_ns_; - - // teardown_voice() is called from run_io() and disconnect() concurrently; this mutex - // makes it idempotent (avoids a double-join race on udp_thread_/talk_timer_thread_). - std::mutex teardown_mu_; - - // io_thread_ entry point - void run_io(std::string host, uint16_t port); - - // Protocol dispatch (called on io_thread_) - void handle_envelope(const voicecat::v1::Envelope& env); - void handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t req_id); - void handle_auth_result(const voicecat::v1::AuthResult& msg); - void handle_server_state(const voicecat::v1::ServerStateSnapshot& snap); - void handle_user_event(const voicecat::v1::UserEvent& ue); - void handle_channel_event(const voicecat::v1::ChannelEvent& ce); - // Called from handle_channel_event when the current channel's audio config changed: - // stops tart every active local stream so the new Opus params take effect (encoders are - // frozen at StreamAnnounceResult time . capture_device_id and - // capture_channels survive the restart (stream_stop doesn't clear them; stream_start - // reuses the existing LocalStream entry). The server reads the updated channel config - // on re-announce and returns new effective_audio; peers' sync_remote_streams wire up - // fresh decoders at the new ssrc. - void restart_active_streams_for_channel(uint32_t channel_id); - void handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg); - void handle_text_message(const voicecat::v1::TextMessage& msg); - void handle_disconnect(const voicecat::v1::Disconnect& msg); - void handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg); - void handle_stream_announce_result(uint64_t req_id, - const voicecat::v1::StreamAnnounceResult& msg); - void handle_voice_subscription_result(const voicecat::v1::VoiceSubscriptionResult& msg); - // Wire up remote-stream decoders for all users in the session model (used on voice join). - void resync_remote_streams(); - // Tear down all remote-stream decoders and clear remote_streams_ (used on voice leave). - void clear_all_remote_streams(); - // Stop every active local stream (used on voice leave — emits STREAM_STOPPED for each). - void stop_all_local_streams(); - - // UDP / media helpers - // Kicks off TCP UdpBinding request, called once after a successful AuthResult. - void start_udp_binding(); - // Opens the UDP socket, sends the plaintext bootstrap packet, starts udp_thread_. - void finish_udp_binding(); - // udp_thread_ entry point: recv loop, AEAD-open, decode, push to audio_engine_. - void run_udp_recv(); - // Send a plaintext KEEPALIVE frame to the server media endpoint. Called from run_udp_recv - // every kUdpKeepaliveIntervalMs to hold NAT bindings + bump the server's last_seen - void send_udp_keepalive(); - // capture_cb passed to audio_engine_.start(): encode + seal + send one frame for the - // given local stream `kind` (multiple concurrent local streams are possible). - void on_capture_frame(int kind, const int16_t* pcm, int samples, int channels); - // Encode one frame of exactly ls.frame_samples samples-per-channel (upmixing mono to stereo - // for a stereo channel as needed), seal it, and send it over UDP, advancing ls.timestamp. - // Called by on_capture_frame for each frame_samples-sized chunk. Assumes local_streams_mu_ - // is held and the send gate/crypto checks have already passed. - void encode_and_send_frame(LocalStream& ls, const int16_t* pcm, int samples, int channels, - int fd); - // Inspect a User proto's streams and wire up any new remote ssrc into audio_engine_, - // emitting VC_EVENT_STREAM_STARTED/STOPPED as streams appear/disappear. - void sync_remote_streams(const voicecat::v1::User& user); - // Starts audio_engine_ (capture+playback) if not already running. - void ensure_audio_running(); - // Joins udp_thread_, stops audio_engine_, clears media crypto/remote-stream state. - // Safe to call multiple times. Called both from run_io()'s cleanup and disconnect(). - void teardown_voice(); - // talk_timer_thread_ entry point: polls audio_engine_ for remote talk-state edges - // and local capture activity, emitting VC_EVENT_TALK_STATE. Never the audio RT thread. - void run_talk_timer(); - // Find a LocalStream by its client-assigned stream_id (held under local_streams_mu_ by - // the caller, or taken internally). Returns nullptr if not found/not active. - LocalStream* find_local_stream_by_id(uint32_t stream_id); - - - void queue_envelope(const voicecat::v1::Envelope& env); - - // Keepalive: send a Ping envelope with a fresh nonce and record the sent time for - // RTT measurement when the Pong arrives. Called from the read loop when kPingIntervalMs - // has elapsed. - void send_ping(); - - void drain_sends(); - - // Transition state + emit VC_EVENT_CONNECTION_STATE. - void set_state(vc_connection_state s); - - void emit_error(vc_result r, const char* text); - void emit_disconnected(vc_result r, const char* reason); -}; - -#endif // VOICECAT_CORE_CLIENT_H diff --git a/core/src/core/worker_pool.cpp b/core/src/core/worker_pool.cpp deleted file mode 100644 index 1333e66..0000000 --- a/core/src/core/worker_pool.cpp +++ /dev/null @@ -1,2 +0,0 @@ -#include "core/worker_pool.h" -// WorkerPool is header-only via asio::thread_pool. nothing to define here. diff --git a/core/src/core/worker_pool.h b/core/src/core/worker_pool.h deleted file mode 100644 index 73cca0c..0000000 --- a/core/src/core/worker_pool.h +++ /dev/null @@ -1,37 +0,0 @@ -/* - * core/worker_pool.h — fixed-size thread pool for blocking work. - * - * Used for Argon2id password hashing (deliberately slow) and TLS handshakes so - * neither blocks the net thread. Real-time audio threads never use this. - * - */ -#ifndef VOICECAT_CORE_WORKER_POOL_H -#define VOICECAT_CORE_WORKER_POOL_H - -#include -#include -#include -#include - -namespace voicecat { - -class WorkerPool { - public: - explicit WorkerPool(std::size_t threads = 3) : pool_(threads) {} - ~WorkerPool() { pool_.join(); } - - template - void post(F&& fn) { - asio::post(pool_, std::forward(fn)); - } - - // Wait for all outstanding tasks to finish. - void join() { pool_.join(); } - - private: - asio::thread_pool pool_; -}; - -} // namespace voicecat - -#endif // VOICECAT_CORE_WORKER_POOL_H diff --git a/core/src/crypto/crypto.cpp b/core/src/crypto/crypto.cpp deleted file mode 100644 index 92f77bd..0000000 --- a/core/src/crypto/crypto.cpp +++ /dev/null @@ -1,399 +0,0 @@ -#include "crypto/crypto.h" - -#include -#include -#include -#include - -#include -#include -#include -#include - -namespace voicecat::crypto { - - -static void throw_if(int rc, const char* msg) { - if (rc != 0) { - char buf[256]; - mbedtls_strerror(rc, buf, sizeof(buf)); - throw std::runtime_error(std::string(msg) + ": " + buf); - } -} - -static std::string compute_hex_fingerprint(const uint8_t* data, size_t len) { - uint8_t hash[32]; - mbedtls_sha256(data, len, hash, 0); - std::string s; - s.reserve(64); - const char* hex = "0123456789abcdef"; - for (auto b : hash) { - s += hex[b >> 4]; - s += hex[b & 0xf]; - } - return s; -} - -// ServerIdentity - -ServerIdentity ServerIdentity::generate() { - ServerIdentity id; - crypto_sign_ed25519_keypair(id.pk.data(), id.sk.data()); - // Fingerprint = SHA-256 of the public key - mbedtls_sha256(id.pk.data(), id.pk.size(), id.fingerprint.data(), 0); - return id; -} - -ServerIdentity ServerIdentity::load(const std::filesystem::path& path) { - std::ifstream f(path, std::ios::binary); - if (!f) throw std::runtime_error("Cannot open identity file: " + path.string()); - ServerIdentity id; - f.read(reinterpret_cast(id.pk.data()), id.pk.size()); - f.read(reinterpret_cast(id.sk.data()), id.sk.size()); - if (!f) throw std::runtime_error("Identity file truncated: " + path.string()); - mbedtls_sha256(id.pk.data(), id.pk.size(), id.fingerprint.data(), 0); - return id; -} - -void ServerIdentity::save(const std::filesystem::path& path) const { - std::ofstream f(path, std::ios::binary | std::ios::trunc); - if (!f) throw std::runtime_error("Cannot write identity file: " + path.string()); - f.write(reinterpret_cast(pk.data()), pk.size()); - f.write(reinterpret_cast(sk.data()), sk.size()); -} - -std::string ServerIdentity::fingerprint_hex() const { - std::string s; - s.reserve(96); - const char* hex = "0123456789ABCDEF"; - for (size_t i = 0; i < fingerprint.size(); ++i) { - if (i > 0) s += ':'; - s += hex[fingerprint[i] >> 4]; - s += hex[fingerprint[i] & 0xf]; - } - return s; -} - -// ServerCert - -ServerCert ServerCert::generate(const std::string& server_name) { - mbedtls_entropy_context entropy; - mbedtls_ctr_drbg_context ctr_drbg; - mbedtls_pk_context key; - mbedtls_x509write_cert cert; - - mbedtls_entropy_init(&entropy); - mbedtls_ctr_drbg_init(&ctr_drbg); - mbedtls_pk_init(&key); - mbedtls_x509write_crt_init(&cert); - - try { - const char* pers = "voicecat_cert_gen"; - throw_if(mbedtls_ctr_drbg_seed(&ctr_drbg, mbedtls_entropy_func, &entropy, - reinterpret_cast(pers), - strlen(pers)), - "ctr_drbg_seed"); - - throw_if(mbedtls_pk_setup(&key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY)), - "pk_setup"); - throw_if(mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(key), - mbedtls_ctr_drbg_random, &ctr_drbg), - "ecp_gen_key"); - - mbedtls_x509write_crt_set_version(&cert, MBEDTLS_X509_CRT_VERSION_3); - mbedtls_x509write_crt_set_md_alg(&cert, MBEDTLS_MD_SHA256); - mbedtls_x509write_crt_set_subject_key(&cert, &key); - mbedtls_x509write_crt_set_issuer_key(&cert, &key); - - std::string dn = "CN=" + (server_name.empty() ? std::string("voicecat") : server_name); - throw_if(mbedtls_x509write_crt_set_subject_name(&cert, dn.c_str()), "set_subject"); - throw_if(mbedtls_x509write_crt_set_issuer_name(&cert, dn.c_str()), "set_issuer"); - - uint8_t serial_raw[] = {0x01}; - throw_if(mbedtls_x509write_crt_set_serial_raw(&cert, serial_raw, sizeof(serial_raw)), - "set_serial"); - - // Valid for 10 years - throw_if(mbedtls_x509write_crt_set_validity(&cert, "20240101000000", - "20340101000000"), - "set_validity"); - throw_if(mbedtls_x509write_crt_set_basic_constraints(&cert, 0, -1), - "set_basic_constraints"); - - unsigned char cert_buf[4096] = {}; - throw_if(mbedtls_x509write_crt_pem(&cert, cert_buf, sizeof(cert_buf), - mbedtls_ctr_drbg_random, &ctr_drbg), - "write_cert_pem"); - - unsigned char key_buf[4096] = {}; - throw_if(mbedtls_pk_write_key_pem(&key, key_buf, sizeof(key_buf)), "write_key_pem"); - - ServerCert result; - result.pem_cert = reinterpret_cast(cert_buf); - result.pem_key = reinterpret_cast(key_buf); - - mbedtls_x509write_crt_free(&cert); - mbedtls_pk_free(&key); - mbedtls_ctr_drbg_free(&ctr_drbg); - mbedtls_entropy_free(&entropy); - return result; - } catch (...) { - mbedtls_x509write_crt_free(&cert); - mbedtls_pk_free(&key); - mbedtls_ctr_drbg_free(&ctr_drbg); - mbedtls_entropy_free(&entropy); - throw; - } -} - -ServerCert ServerCert::load(const std::filesystem::path& cert_path, - const std::filesystem::path& key_path) { - auto read_file = [](const std::filesystem::path& p) -> std::string { - std::ifstream f(p); - if (!f) throw std::runtime_error("Cannot open: " + p.string()); - return {std::istreambuf_iterator(f), {}}; - }; - ServerCert c; - c.pem_cert = read_file(cert_path); - c.pem_key = read_file(key_path); - return c; -} - -void ServerCert::save(const std::filesystem::path& cert_path, - const std::filesystem::path& key_path) const { - auto write_file = [](const std::filesystem::path& p, const std::string& s) { - std::ofstream f(p, std::ios::trunc); - if (!f) throw std::runtime_error("Cannot write: " + p.string()); - f << s; - }; - write_file(cert_path, pem_cert); - write_file(key_path, pem_key); -} - -// TlsContext - -TlsContext::TlsContext(Role role, const ServerCert* server_cert, - const std::array* pinned_fp) - : role_(role), pinned_fp_(pinned_fp) { - mbedtls_entropy_init(&entropy_); - mbedtls_ctr_drbg_init(&ctr_drbg_); - mbedtls_ssl_init(&ssl_); - mbedtls_ssl_config_init(&conf_); - mbedtls_x509_crt_init(&srvcert_); - mbedtls_pk_init(&pkey_); - - const char* pers = (role == Role::Server) ? "vc_server_tls" : "vc_client_tls"; - throw_if(mbedtls_ctr_drbg_seed(&ctr_drbg_, mbedtls_entropy_func, &entropy_, - reinterpret_cast(pers), - strlen(pers)), - "ctr_drbg_seed"); - - int endpoint = (role == Role::Server) ? MBEDTLS_SSL_IS_SERVER : MBEDTLS_SSL_IS_CLIENT; - throw_if(mbedtls_ssl_config_defaults(&conf_, endpoint, MBEDTLS_SSL_TRANSPORT_STREAM, - MBEDTLS_SSL_PRESET_DEFAULT), - "ssl_config_defaults"); - - // TLS 1.3 only - mbedtls_ssl_conf_min_tls_version(&conf_, MBEDTLS_SSL_VERSION_TLS1_3); - mbedtls_ssl_conf_max_tls_version(&conf_, MBEDTLS_SSL_VERSION_TLS1_3); - - mbedtls_ssl_conf_rng(&conf_, mbedtls_ctr_drbg_random, &ctr_drbg_); - - if (role == Role::Server && server_cert) { - // Parse server cert + key - throw_if(mbedtls_x509_crt_parse( - &srvcert_, - reinterpret_cast(server_cert->pem_cert.c_str()), - server_cert->pem_cert.size() + 1), - "x509_crt_parse"); - throw_if(mbedtls_pk_parse_key( - &pkey_, - reinterpret_cast(server_cert->pem_key.c_str()), - server_cert->pem_key.size() + 1, - nullptr, 0, mbedtls_ctr_drbg_random, &ctr_drbg_), - "pk_parse_key"); - throw_if(mbedtls_ssl_conf_own_cert(&conf_, &srvcert_, &pkey_), "conf_own_cert"); - } - - if (role == Role::Client) { - // Skip CA chain verification, we use TOFU via the server identity fingerprint. - mbedtls_ssl_conf_authmode(&conf_, MBEDTLS_SSL_VERIFY_NONE); - } - - throw_if(mbedtls_ssl_setup(&ssl_, &conf_), "ssl_setup"); -} - -TlsContext::~TlsContext() { - mbedtls_ssl_close_notify(&ssl_); - mbedtls_pk_free(&pkey_); - mbedtls_x509_crt_free(&srvcert_); - mbedtls_ssl_free(&ssl_); - mbedtls_ssl_config_free(&conf_); - mbedtls_ctr_drbg_free(&ctr_drbg_); - mbedtls_entropy_free(&entropy_); -} - -void TlsContext::set_read_timeout(uint32_t ms) { - mbedtls_ssl_conf_read_timeout(&conf_, ms); -} - -bool TlsContext::is_timeout_error(int rc) { - return rc == MBEDTLS_ERR_SSL_TIMEOUT; -} - -bool TlsContext::handshake(int socket_fd, std::string& error) { - net_ctx_.fd = socket_fd; - // Use the timeout-capable recv callback so set_read_timeout() takes effect. - mbedtls_ssl_set_bio(&ssl_, &net_ctx_, mbedtls_net_send, mbedtls_net_recv, - mbedtls_net_recv_timeout); - - int rc; - while ((rc = mbedtls_ssl_handshake(&ssl_)) != 0) { - if (rc != MBEDTLS_ERR_SSL_WANT_READ && rc != MBEDTLS_ERR_SSL_WANT_WRITE) { - char buf[256]; - mbedtls_strerror(rc, buf, sizeof(buf)); - error = buf; - return false; - } - } - ready_ = true; - return true; -} - -int TlsContext::read(uint8_t* buf, size_t len) { - return mbedtls_ssl_read(&ssl_, buf, len); -} - -int TlsContext::write(const uint8_t* buf, size_t len) { - return mbedtls_ssl_write(&ssl_, buf, len); -} - -bool TlsContext::export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len, - uint8_t* out, size_t out_len) { - return mbedtls_ssl_export_keying_material( - &ssl_, out, out_len, label, strlen(label), - ctx, ctx_len, ctx != nullptr) == 0; -} - -bool TlsContext::peer_cert_fingerprint(std::array& out) const { - if (!ready_) return false; - const mbedtls_x509_crt* peer = mbedtls_ssl_get_peer_cert(&ssl_); - if (!peer) return false; - mbedtls_sha256(peer->raw.p, peer->raw.len, out.data(), 0); - return true; -} - -// SodiumMediaCrypto - -SodiumMediaCrypto::SodiumMediaCrypto( - const uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES]) { - std::memcpy(key_.data(), key, key_.size()); -} - -std::unique_ptr SodiumMediaCrypto::derive(TlsContext& tls, uint8_t ctx_byte) { - uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES]{}; - if (!tls.export_keying_material("voicecat media v1", &ctx_byte, 1, key, sizeof(key))) - return nullptr; - auto p = std::make_unique(key); - sodium_memzero(key, sizeof(key)); - return p; -} - -std::unique_ptr SodiumMediaCrypto::derive_send(TlsContext& tls, - bool is_client) { - return derive(tls, is_client ? 0x00 : 0x01); -} - -std::unique_ptr SodiumMediaCrypto::derive_recv(TlsContext& tls, - bool is_client) { - return derive(tls, is_client ? 0x01 : 0x00); -} - -void SodiumMediaCrypto::build_nonce(uint64_t counter, uint8_t nonce[12]) const { - // nonce[0..3] = 0x00 (reserved / zero-padded) - // nonce[4..11] = counter (big-endian u64) - nonce[0] = nonce[1] = nonce[2] = nonce[3] = 0; - nonce[4] = static_cast(counter >> 56); - nonce[5] = static_cast(counter >> 48); - nonce[6] = static_cast(counter >> 40); - nonce[7] = static_cast(counter >> 32); - nonce[8] = static_cast(counter >> 24); - nonce[9] = static_cast(counter >> 16); - nonce[10] = static_cast(counter >> 8); - nonce[11] = static_cast(counter & 0xFF); -} - -long SodiumMediaCrypto::seal(const uint8_t* plain, size_t len, const uint8_t* aad, - size_t aad_len, uint8_t* out, size_t out_cap) { - if (out_cap < len + crypto_aead_chacha20poly1305_ietf_ABYTES) return -1; - - uint8_t nonce[crypto_aead_chacha20poly1305_ietf_NPUBBYTES]; - build_nonce(send_counter_++, nonce); - - unsigned long long sealed_len = 0; - if (crypto_aead_chacha20poly1305_ietf_encrypt( - out, &sealed_len, plain, static_cast(len), - aad, static_cast(aad_len), - nullptr, nonce, key_.data()) != 0) - return -1; - - return static_cast(sealed_len); -} - -long SodiumMediaCrypto::open(const uint8_t* sealed, size_t len, const uint8_t* aad, - size_t aad_len, uint8_t* out, size_t out_cap) { - if (len < crypto_aead_chacha20poly1305_ietf_ABYTES) return -1; - if (out_cap < len - crypto_aead_chacha20poly1305_ietf_ABYTES) return -1; - - // Read the full 64-bit nonce counter directly from aad[8..15] (seq, big-endian - // u64). - if (aad_len < 16) return -1; - uint64_t counter = (static_cast(aad[8]) << 56) | - (static_cast(aad[9]) << 48) | - (static_cast(aad[10]) << 40) | - (static_cast(aad[11]) << 32) | - (static_cast(aad[12]) << 24) | - (static_cast(aad[13]) << 16) | - (static_cast(aad[14]) << 8) | - static_cast(aad[15]); - - // ── Anti-replay: REJECT-ONLY checks (no state mutation) ─────────────────── - // The counter comes from the UNAUTHENTICATED header, so we must NOT advance the - // window before the AEAD tag is verified — otherwise a single corrupted/forged - // packet would shove recv_highest_ far ahead and reject every later legitimate - // packet as "too old", permanently wedging the stream. Order per RFC 3711 §3.3: - // replay-check authenticate update. - if (recv_initialized_ && counter <= recv_highest_) { - uint64_t offset = recv_highest_ - counter; - if (offset >= 64) return -1; // too old - if (recv_window_ & (UINT64_C(1) << offset)) return -1; // replay - } - - uint8_t nonce[crypto_aead_chacha20poly1305_ietf_NPUBBYTES]; - build_nonce(counter, nonce); - - unsigned long long plain_len = 0; - if (crypto_aead_chacha20poly1305_ietf_decrypt( - out, &plain_len, nullptr, sealed, static_cast(len), - aad, static_cast(aad_len), - nonce, key_.data()) != 0) - return -1; // auth failure — leave the replay window untouched - - // Authenticated: now it's safe to advance the window ──────────────────── - if (!recv_initialized_) { - recv_highest_ = counter; - recv_window_ = 1; // bit0 = highest itself - recv_initialized_ = true; - } else if (counter > recv_highest_) { - uint64_t shift = counter - recv_highest_; - recv_window_ = (shift >= 64) ? 0 : (recv_window_ << shift); - recv_window_ |= 1; // bit0 = the new highest - recv_highest_ = counter; - } else { - recv_window_ |= (UINT64_C(1) << (recv_highest_ - counter)); - } - - return static_cast(plain_len); -} - -} // namespace voicecat::crypto diff --git a/core/src/crypto/crypto.h b/core/src/crypto/crypto.h deleted file mode 100644 index f9e8792..0000000 --- a/core/src/crypto/crypto.h +++ /dev/null @@ -1,187 +0,0 @@ -/* - * crypto/crypto.h: TLS 1.3 (mbedTLS) and the media AEAD (libsodium). - * - *Control channel = TLS 1.3. Media = keys exported from the TLS - * session (RFC 5705 / 8446) + per-frame ChaCha20-Poly1305 with a counter nonce and a - * sliding-window replay filter. Encryption is MANDATORY — never add a plaintext path. - */ -#ifndef VOICECAT_CRYPTO_CRYPTO_H -#define VOICECAT_CRYPTO_CRYPTO_H - -#include -#include -#include - -#include -#include -#include -#include - -// libsodium -#include - -// mbedTLS -#include -#include -#include -#include -#include -#include - -namespace voicecat::crypto { - -// Server identity -// Long-lived Ed25519 key identifying this server instance across cert rotations. -// Fingerprint is the 32-byte SHA-256 of the public key. -struct ServerIdentity { - std::array pk{}; - std::array sk{}; - std::array fingerprint{}; - - static ServerIdentity generate(); - static ServerIdentity load(const std::filesystem::path& path); - void save(const std::filesystem::path& path) const; - std::string fingerprint_hex() const; -}; - -// Server TLS certificate -// Self-signed ECDSA-P256 cert for TLS. On first run, generated and persisted. -struct ServerCert { - std::string pem_cert; - std::string pem_key; - - static ServerCert generate(const std::string& server_name); - static ServerCert load(const std::filesystem::path& cert_path, - const std::filesystem::path& key_path); - void save(const std::filesystem::path& cert_path, - const std::filesystem::path& key_path) const; -}; - -// TLS 1.3 context -// Wraps mbedTLS for one TLS connection (server or client side). -// All public methods must be called from a single thread at a time. -class TlsContext { - public: - enum class Role { Server, Client }; - - // server_cert: required for server role; nullptr for client - // pinned_fp: 32-byte Ed25519 fingerprint to accept (client TOFU); nullptr = any - TlsContext(Role role, const ServerCert* server_cert, - const std::array* pinned_fp = nullptr); - ~TlsContext(); - - TlsContext(const TlsContext&) = delete; - TlsContext& operator=(const TlsContext&) = delete; - - // Perform the TLS handshake over an already-connected BSD socket fd. - // Blocking — run from a WorkerPool thread. - // Returns true on success; error contains a diagnostic string on failure. - bool handshake(int socket_fd, std::string& error); - - // Read/write post-handshake (single-threaded). Returns bytes transferred, or <0 on error. - int read(uint8_t* buf, size_t len); - int write(const uint8_t* buf, size_t len); - - // RFC 5705 / RFC 8446 §7.5 exporter — derive media keys after handshake. - bool export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len, - uint8_t* out, size_t out_len); - - // TOFU: SHA-256 of the peer's leaf X.509 certificate (DER), valid only after a - // successful Role::Client handshake(). This is the value vc_client pins — see - // voicecat.h's vc_tofu_status doc comment for why the cert fingerprint is pinned instead - // of the declared Ed25519 server_identity_fingerprint. Returns false if no peer cert is - // available (e.g. Role::Server, or handshake() hasn't succeeded). - bool peer_cert_fingerprint(std::array& out) const; - - // Whether the handshake completed. - bool ready() const { return ready_; } - - // Underlying socket fd (valid after handshake). For select() in the caller. - int native_fd() const { return net_ctx_.fd; } - - // Set per-read timeout (ms, 0 = blocking). Affects post-handshake reads. - void set_read_timeout(uint32_t ms); - - // True when the given return value from read() indicates a read timeout. - static bool is_timeout_error(int rc); - - private: - Role role_; - const std::array* pinned_fp_; - bool ready_{false}; - - mbedtls_entropy_context entropy_{}; - mbedtls_ctr_drbg_context ctr_drbg_{}; - mbedtls_ssl_context ssl_{}; - mbedtls_ssl_config conf_{}; - mbedtls_x509_crt srvcert_{}; - mbedtls_pk_context pkey_{}; - mbedtls_net_context net_ctx_{}; -}; - -// Media AEAD -// Per-frame voice encryption. Abstracted so the backend is swappable. -class MediaCrypto { - public: - virtual ~MediaCrypto() = default; - // Encrypt plain[0..len) with AAD aad[0..aad_len). Write ciphertext+MAC to out. - // out_cap must be >= len + crypto_aead_chacha20poly1305_ietf_ABYTES (16). - // Returns total bytes written on success, or -1 on error. - virtual long seal(const uint8_t* plain, size_t len, const uint8_t* aad, size_t aad_len, - uint8_t* out, size_t out_cap) = 0; - // Decrypt+authenticate sealed[0..len). len includes the 16-byte MAC. - // Returns number of plaintext bytes written to out, or -1 on auth failure / replay. - virtual long open(const uint8_t* sealed, size_t len, const uint8_t* aad, size_t aad_len, - uint8_t* out, size_t out_cap) = 0; -}; - -// ChaCha20-Poly1305 backend -// Keys are derived from the TLS session via RFC 5705 / mbedTLS exporter. -// Nonce scheme: 4 zero bytes ‖ monotonic-counter(u64 big-endian, 8 bytes). -// Anti-replay: 64-bit sliding window keyed on the received counter. -class SodiumMediaCrypto final : public MediaCrypto { - public: - // ctx_byte: 0x00 = client→server direction, 0x01 = server→client direction. - static std::unique_ptr derive(TlsContext& tls, uint8_t ctx_byte); - - // Convenience: derive the key used to encrypt outgoing frames. - // is_client=true ctx=0x00 (client sends); is_client=false ctx=0x01 (server sends). - static std::unique_ptr derive_send(TlsContext& tls, bool is_client); - // Convenience: derive the key used to decrypt incoming frames. - // is_client=true ctx=0x01 (client recvs); is_client=false ctx=0x00 (server recvs). - static std::unique_ptr derive_recv(TlsContext& tls, bool is_client); - - // Unit-test constructor: supply a raw 32-byte key directly. - explicit SodiumMediaCrypto(const uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES]); - - // Returns the send counter for the NEXT seal() call (use as frame seq). - uint64_t peek_send_counter() const { return send_counter_; } - - // seal(): increments send_counter_; nonce derived from internal counter. - long seal(const uint8_t* plain, size_t len, const uint8_t* aad, size_t aad_len, - uint8_t* out, size_t out_cap) override; - - // open(): reads the full 64-bit counter from aad[8..15] (seq field), checks - // anti-replay, then decrypts. The replay window is advanced ONLY after the AEAD - // tag verifies, so a corrupted/forged packet cannot poison it (RFC 3711 §3.3). - long open(const uint8_t* sealed, size_t len, const uint8_t* aad, size_t aad_len, - uint8_t* out, size_t out_cap) override; - - private: - void build_nonce(uint64_t counter, uint8_t nonce[12]) const; - - std::array key_{}; - - // Send state (used only in seal()). - uint64_t send_counter_{0}; - - // Receive anti-replay state (used only in open()). - // Window: highest accepted counter + bitmask of last 64 accepted counters. - uint64_t recv_highest_{0}; // highest counter seen and accepted - uint64_t recv_window_{0}; // bit i set → (highest - i) was accepted - bool recv_initialized_{false}; // first packet initializes the window -}; - -} // namespace voicecat::crypto - -#endif // VOICECAT_CRYPTO_CRYPTO_H diff --git a/core/src/crypto/tofu_store.cpp b/core/src/crypto/tofu_store.cpp deleted file mode 100644 index 3756579..0000000 --- a/core/src/crypto/tofu_store.cpp +++ /dev/null @@ -1,93 +0,0 @@ -#include "crypto/tofu_store.h" - -#include -#include -#include - -namespace voicecat::crypto { - -TofuStore::TofuStore(std::filesystem::path path) : path_(std::move(path)) { - load(); -} - -TofuResult TofuStore::check_and_pin(const std::string& host, uint16_t port, - const std::array& fingerprint) { - std::lock_guard lk(mu_); - auto key = make_key(host, port); - auto it = pins_.find(key); - if (it == pins_.end()) { - pins_[key] = fingerprint; - save(); - return TofuResult::FirstConnect; - } - return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch; -} - -TofuResult TofuStore::peek(const std::string& host, uint16_t port, - const std::array& fingerprint) const { - std::lock_guard lk(mu_); - auto it = pins_.find(make_key(host, port)); - if (it == pins_.end()) return TofuResult::FirstConnect; - return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch; -} - -void TofuStore::pin(const std::string& host, uint16_t port, - const std::array& fingerprint) { - std::lock_guard lk(mu_); - pins_[make_key(host, port)] = fingerprint; - save(); -} - -void TofuStore::remove(const std::string& host, uint16_t port) { - std::lock_guard lk(mu_); - pins_.erase(make_key(host, port)); - save(); -} - -std::string TofuStore::make_key(const std::string& host, uint16_t port) { - return host + ":" + std::to_string(port); -} - -std::string TofuStore::fp_to_hex(const std::array& fp) { - const char* hex = "0123456789abcdef"; - std::string s; - s.reserve(64); - for (auto b : fp) { s += hex[b >> 4]; s += hex[b & 0xf]; } - return s; -} - -std::array TofuStore::hex_to_fp(const std::string& hex) { - std::array fp{}; - if (hex.size() != 64) return fp; - auto h2n = [](char c) -> uint8_t { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return 0; - }; - for (size_t i = 0; i < 32; ++i) - fp[i] = static_cast((h2n(hex[2*i]) << 4) | h2n(hex[2*i+1])); - return fp; -} - -void TofuStore::load() { - std::ifstream f(path_); - if (!f) return; - std::string line; - while (std::getline(f, line)) { - if (line.empty() || line[0] == '#') continue; - std::istringstream ss(line); - std::string key, hex; - if (ss >> key >> hex && hex.size() == 64) - pins_[key] = hex_to_fp(hex); - } -} - -void TofuStore::save() const { - std::ofstream f(path_, std::ios::trunc); - if (!f) throw std::runtime_error("Cannot write TOFU store: " + path_.string()); - for (auto& [key, fp] : pins_) - f << key << " " << fp_to_hex(fp) << "\n"; -} - -} // namespace voicecat::crypto diff --git a/core/src/crypto/tofu_store.h b/core/src/crypto/tofu_store.h deleted file mode 100644 index 368bc2f..0000000 --- a/core/src/crypto/tofu_store.h +++ /dev/null @@ -1,63 +0,0 @@ -/* - * crypto/tofu_store.h: Trust-On-First-Use pin storage. - * - * File format: one "host:port \n" line per entry. - * Used by clients to remember server fingerprints across reconnects. - */ -#ifndef VOICECAT_CRYPTO_TOFU_STORE_H -#define VOICECAT_CRYPTO_TOFU_STORE_H - -#include -#include -#include -#include -#include - -namespace voicecat::crypto { - -enum class TofuResult { - FirstConnect, // no pin on file; pin has been stored - Matched, // pin matches stored value - Mismatch, // stored pin does not match — possible MITM or server key rotation -}; - -class TofuStore { - public: - explicit TofuStore(std::filesystem::path path); - - // Check the fingerprint for host:port. Stores on first connect. - // Thread-safe (single-writer lock). - // NOTE: kept for compatibility; vc_client's gated-confirmation flow uses peek() - // + pin() instead, since check_and_pin's unconditional first-connect write is wrong for a - // flow where the application must approve the fingerprint before it's trusted/persisted. - TofuResult check_and_pin(const std::string& host, uint16_t port, - const std::array& fingerprint); - - // Read-only — classifies the fingerprint against any existing pin WITHOUT writing to disk. - // Use this before the application has had a chance to approve a first-connect/mismatch. - TofuResult peek(const std::string& host, uint16_t port, - const std::array& fingerprint) const; - - // Persist the pin for host:port. Call only after the caller has accepted a FIRST_CONNECT - // or MISMATCH classification from peek() — accepting a MATCHED result needs no call here. - void pin(const std::string& host, uint16_t port, const std::array& fingerprint); - - // Remove the pin for host:port (e.g. after user explicitly acknowledges a key change). - void remove(const std::string& host, uint16_t port); - - private: - static std::string make_key(const std::string& host, uint16_t port); - static std::string fp_to_hex(const std::array& fp); - static std::array hex_to_fp(const std::string& hex); - - void load(); - void save() const; - - std::filesystem::path path_; - mutable std::mutex mu_; - std::unordered_map> pins_; -}; - -} // namespace voicecat::crypto - -#endif // VOICECAT_CRYPTO_TOFU_STORE_H diff --git a/core/src/net/transport.cpp b/core/src/net/transport.cpp deleted file mode 100644 index 99c594f..0000000 --- a/core/src/net/transport.cpp +++ /dev/null @@ -1,481 +0,0 @@ -#include "net/transport.h" - -#include - -#include "crypto/crypto.h" - -namespace voicecat::net { - - -TcpControlChannel::TcpControlChannel(TcpChannelCallbacks cbs) - : work_guard_(asio::make_work_guard(io_)), - socket_(io_), - strand_(io_.get_executor()), - cbs_(std::move(cbs)) { - net_thread_ = std::thread([this] { run_loop(); }); -} - -TcpControlChannel::~TcpControlChannel() { close(); } - -void TcpControlChannel::run_loop() { io_.run(); } - -void TcpControlChannel::async_connect(const std::string& host, uint16_t port) { - auto resolver = std::make_shared(io_); - resolver->async_resolve( - host, std::to_string(port), - [this, resolver](std::error_code ec, asio::ip::tcp::resolver::results_type eps) { - if (ec) { - if (cbs_.on_connect_error) cbs_.on_connect_error(ec); - return; - } - asio::async_connect(socket_, eps, - [this](std::error_code ec2, const asio::ip::tcp::endpoint&) { - if (ec2) { - if (cbs_.on_connect_error) cbs_.on_connect_error(ec2); - return; - } - connected_.store(true, std::memory_order_release); - if (cbs_.on_connected) cbs_.on_connected(); - start_read(); - }); - }); -} - -void TcpControlChannel::send_frame(std::vector payload) { - std::vector wire; - protocol::FrameCodec::emit(payload, wire); - asio::post(strand_, [this, w = std::move(wire)]() mutable { - send_queue_.push_back(std::move(w)); - if (!sending_) do_send(); - }); -} - -void TcpControlChannel::do_send() { - if (send_queue_.empty()) { sending_ = false; return; } - sending_ = true; - auto& front = send_queue_.front(); - asio::async_write(socket_, - asio::buffer(front), - asio::bind_executor(strand_, - [this](std::error_code ec, std::size_t) { - if (ec) { - connected_.store(false, std::memory_order_release); - if (cbs_.on_error) cbs_.on_error(ec); - return; - } - send_queue_.pop_front(); - do_send(); - })); -} - -void TcpControlChannel::start_read() { - asio::async_read(socket_, asio::buffer(len_buf_, 4), - [this](std::error_code ec, std::size_t n) { handle_length(ec, n); }); -} - -void TcpControlChannel::handle_length(std::error_code ec, std::size_t) { - if (ec) { - connected_.store(false, std::memory_order_release); - if (ec == asio::error::eof || ec == asio::error::connection_reset) { - if (cbs_.on_disconnected) cbs_.on_disconnected(); - } else { - if (cbs_.on_error) cbs_.on_error(ec); - } - return; - } - uint32_t length = - (static_cast(len_buf_[0]) << 24) | - (static_cast(len_buf_[1]) << 16) | - (static_cast(len_buf_[2]) << 8) | - static_cast(len_buf_[3]); - - if (length > protocol::kMaxFrameBytes) { - if (cbs_.on_error) cbs_.on_error(asio::error::message_size); - return; - } - if (length == 0) { - if (cbs_.on_frame) cbs_.on_frame({}); - start_read(); - return; - } - body_buf_.resize(length); - asio::async_read(socket_, asio::buffer(body_buf_), - [this, length](std::error_code ec, std::size_t n) { handle_body(length, ec, n); }); -} - -void TcpControlChannel::handle_body(uint32_t, std::error_code ec, std::size_t) { - if (ec) { - connected_.store(false, std::memory_order_release); - if (ec == asio::error::eof || ec == asio::error::connection_reset) { - if (cbs_.on_disconnected) cbs_.on_disconnected(); - } else { - if (cbs_.on_error) cbs_.on_error(ec); - } - return; - } - if (cbs_.on_frame) cbs_.on_frame(body_buf_); - start_read(); -} - -void TcpControlChannel::close() { - if (closing_.exchange(true)) return; - asio::post(io_, [this] { - std::error_code ignored; - socket_.shutdown(asio::ip::tcp::socket::shutdown_both, ignored); - socket_.close(ignored); - }); - work_guard_.reset(); - if (net_thread_.joinable()) net_thread_.join(); -} - - - -TcpServerConn::TcpServerConn(asio::ip::tcp::socket socket, TcpChannelCallbacks cbs) - : socket_(std::move(socket)), - strand_(asio::make_strand(socket_.get_executor())), - cbs_(std::move(cbs)) {} - -TcpServerConn::TcpServerConn(asio::ip::tcp::socket socket, TcpChannelCallbacks cbs, - std::unique_ptr tls) - : socket_(std::move(socket)), - strand_(asio::make_strand(socket_.get_executor())), - cbs_(std::move(cbs)), - tls_(std::move(tls)) {} - -TcpServerConn::~TcpServerConn() { - close(); - if (tls_thread_.joinable()) { - if (std::this_thread::get_id() == tls_thread_.get_id()) { - tls_thread_.detach(); // being destroyed from our own TLS thread — detach safely - } else { - tls_thread_.join(); - } - } -} - -void TcpServerConn::start() { - if (tls_) { - // Run TLS handshake on a temporary thread so we don't block the io_context. - auto self = shared_from_this(); - std::thread([self] { - std::string err; - int fd = static_cast(self->socket_.native_handle()); - if (!self->tls_->handshake(fd, err)) { - if (!self->closing_.exchange(true)) { - if (self->cbs_.on_error) { - asio::post(self->strand_, [self] { - self->cbs_.on_error( - std::make_error_code(std::errc::connection_reset)); - }); - } - } - return; - } - self->connected_.store(true, std::memory_order_release); - // Export media keying material before the read loop starts. - if (self->cbs_.on_tls_ready) self->cbs_.on_tls_ready(*self->tls_); - // 50 ms timeout so tls_read_loop can drain the send queue between reads. - self->tls_->set_read_timeout(50); - self->tls_thread_ = std::thread([self] { self->tls_read_loop(); }); - }).detach(); - } else { - connected_.store(true, std::memory_order_release); - start_read(); - } -} - -void TcpServerConn::tls_read_loop() { - std::vector buf(16384); - while (!closing_.load(std::memory_order_acquire)) { - tls_drain_sends(); - - int n = tls_->read(buf.data(), buf.size()); - if (crypto::TlsContext::is_timeout_error(n)) continue; - if (n <= 0) break; - - std::vector> frames; - if (!codec_.feed(buf.data(), static_cast(n), frames)) break; - for (auto& frame : frames) { - if (cbs_.on_frame) cbs_.on_frame(std::move(frame)); - } - } - connected_.store(false, std::memory_order_release); - if (cbs_.on_disconnected) cbs_.on_disconnected(); -} - -void TcpServerConn::tls_drain_sends() { - while (true) { - std::vector frame; - { - std::lock_guard lk(tls_send_mutex_); - if (tls_send_queue_.empty()) return; - frame = std::move(tls_send_queue_.front()); - tls_send_queue_.pop_front(); - } - size_t off = 0; - while (off < frame.size()) { - int n = tls_->write(frame.data() + off, frame.size() - off); - if (n <= 0) { closing_.store(true, std::memory_order_release); return; } - off += static_cast(n); - } - } -} - -void TcpServerConn::start_read() { - auto self = shared_from_this(); - asio::async_read(socket_, asio::buffer(len_buf_, 4), - asio::bind_executor(strand_, - [this, self](std::error_code ec, std::size_t n) { handle_length(ec, n); })); -} - -void TcpServerConn::handle_length(std::error_code ec, std::size_t) { - if (ec) { - connected_.store(false, std::memory_order_release); - if (ec == asio::error::eof || ec == asio::error::connection_reset) { - if (cbs_.on_disconnected) cbs_.on_disconnected(); - } else { - if (cbs_.on_error) cbs_.on_error(ec); - } - return; - } - uint32_t length = - (static_cast(len_buf_[0]) << 24) | - (static_cast(len_buf_[1]) << 16) | - (static_cast(len_buf_[2]) << 8) | - static_cast(len_buf_[3]); - - if (length > protocol::kMaxFrameBytes) { - if (cbs_.on_error) cbs_.on_error(asio::error::message_size); - return; - } - if (length == 0) { - if (cbs_.on_frame) cbs_.on_frame({}); - start_read(); - return; - } - body_buf_.resize(length); - auto self = shared_from_this(); - asio::async_read(socket_, asio::buffer(body_buf_), - asio::bind_executor(strand_, - [this, self, length](std::error_code ec, std::size_t n) { - handle_body(length, ec, n); - })); -} - -void TcpServerConn::handle_body(uint32_t, std::error_code ec, std::size_t) { - if (ec) { - connected_.store(false, std::memory_order_release); - if (ec == asio::error::eof || ec == asio::error::connection_reset) { - if (cbs_.on_disconnected) cbs_.on_disconnected(); - } else { - if (cbs_.on_error) cbs_.on_error(ec); - } - return; - } - if (cbs_.on_frame) cbs_.on_frame(body_buf_); - start_read(); -} - -void TcpServerConn::send_frame(std::vector payload) { - std::vector wire; - protocol::FrameCodec::emit(payload, wire); - if (tls_) { - std::lock_guard lk(tls_send_mutex_); - tls_send_queue_.push_back(std::move(wire)); - } else { - auto self = shared_from_this(); - asio::post(strand_, [this, self, w = std::move(wire)]() mutable { - send_queue_.push_back(std::move(w)); - if (!sending_) do_send(); - }); - } -} - -void TcpServerConn::do_send() { - if (send_queue_.empty()) { sending_ = false; return; } - sending_ = true; - auto self = shared_from_this(); - auto& front = send_queue_.front(); - asio::async_write(socket_, - asio::buffer(front), - asio::bind_executor(strand_, - [this, self](std::error_code ec, std::size_t) { - if (ec) { - connected_.store(false, std::memory_order_release); - if (cbs_.on_error) cbs_.on_error(ec); - return; - } - send_queue_.pop_front(); - do_send(); - })); -} - -void TcpServerConn::close() { - if (closing_.exchange(true)) return; - std::error_code ignored; - socket_.shutdown(asio::ip::tcp::socket::shutdown_both, ignored); - socket_.close(ignored); - connected_.store(false, std::memory_order_release); - // In non-TLS mode, the Asio async chain will naturally stop when the socket closes. -} - -void TcpServerConn::wait_closed() { - if (tls_thread_.joinable()) { - if (std::this_thread::get_id() == tls_thread_.get_id()) { - // Being called from our own TLS thread, detach to avoid self-join deadlock. - tls_thread_.detach(); - } else { - tls_thread_.join(); - } - } -} - - - -namespace { -// Prefer IPv6 dual-stack so one listener accepts both IPv6 and IPv4 localhost addresses. -// Fall back to IPv4 when dual-stack binding is unavailable. -asio::ip::tcp::acceptor make_acceptor(asio::io_context& io, uint16_t port) { - asio::ip::tcp::acceptor acc(io); - std::error_code ec; - acc.open(asio::ip::tcp::v6(), ec); - if (!ec) { - acc.set_option(asio::ip::v6_only(false), ec); // dual-stack - acc.set_option(asio::ip::tcp::acceptor::reuse_address(true)); - acc.bind(asio::ip::tcp::endpoint(asio::ip::tcp::v6(), port), ec); - if (!ec) acc.listen(asio::socket_base::max_listen_connections, ec); - } - if (ec) { - if (acc.is_open()) { std::error_code ignored; acc.close(ignored); } - acc.open(asio::ip::tcp::v4()); - acc.set_option(asio::ip::tcp::acceptor::reuse_address(true)); - acc.bind(asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port)); - acc.listen(asio::socket_base::max_listen_connections); - } - return acc; -} -} // namespace - -TcpAcceptor::TcpAcceptor(asio::io_context& io, uint16_t port, ConnFactory factory) - : acceptor_(make_acceptor(io, port)), - factory_(std::move(factory)) {} - -void TcpAcceptor::start() { do_accept(); } - -void TcpAcceptor::stop() { - stopped_ = true; - std::error_code ignored; - acceptor_.close(ignored); - // Close all tracked connections so their TLS read threads exit. The socket close - // happens while the io_context (and its reactor) is still alive, preventing the - // null-reactor use-after-free that manifests on macOS kqueue. - std::vector> to_close; - { - std::lock_guard lk(conns_mu_); - to_close = conns_; - } - for (auto& conn : to_close) conn->close(); -} - -void TcpAcceptor::shutdown() { - stop(); - // Wait for every connection's TLS I/O thread to finish. close() (called by stop()) - // set closing_=true and closed the socket, so tls_read_loop is already exiting or has - // exited; the join is brief. This must complete BEFORE the io_context is destroyed. - std::vector> to_join; - { - std::lock_guard lk(conns_mu_); - to_join = std::move(conns_); - } - for (auto& conn : to_join) { - conn->wait_closed(); - } - // to_join drops here if a thread captured shared_from_this, the TcpServerConn stays - // alive until that thread releases it; the destructor's close() is a no-op (already - // closed) and tls_thread_ is already joined, so no reactor access occurs. -} - -void TcpAcceptor::do_accept() { - if (stopped_) return; - acceptor_.async_accept( - [this](std::error_code ec, asio::ip::tcp::socket socket) { - if (ec) { - if (!stopped_) do_accept(); - return; - } - socket.set_option(asio::ip::tcp::no_delay(true)); - auto conn = factory_(std::move(socket)); - if (conn) { - conn->start(); - // Track so shutdown() can close + join before the io_context is destroyed. - { - std::lock_guard lk(conns_mu_); - conns_.push_back(conn); - } - } - do_accept(); - }); -} - - - -bool UdpMediaChannel::bind(asio::io_context& io, uint16_t port) { - if (bound_.load()) return false; - try { - socket_ = std::make_unique(io); - socket_->open(asio::ip::udp::v4()); - socket_->set_option(asio::socket_base::reuse_address(true)); - socket_->bind(asio::ip::udp::endpoint(asio::ip::udp::v4(), port)); - bound_.store(true, std::memory_order_release); - return true; - } catch (...) { - socket_.reset(); - return false; - } -} - -void UdpMediaChannel::start_recv(FrameCallback cb) { - frame_cb_ = std::move(cb); - do_recv(); -} - -void UdpMediaChannel::do_recv() { - if (!socket_ || closed_.load()) return; - socket_->async_receive_from( - asio::buffer(recv_buf_), sender_ep_, - [this](std::error_code ec, std::size_t n) { - if (ec || closed_.load()) return; - if (frame_cb_ && n > 0) - frame_cb_(recv_buf_.data(), n, sender_ep_); - do_recv(); - }); -} - -void UdpMediaChannel::send_to(const uint8_t* data, size_t len, - asio::ip::udp::endpoint dst) { - if (!socket_ || closed_.load() || len == 0) return; - auto buf = std::make_shared>(data, data + len); - asio::post(socket_->get_executor(), [this, buf, dst]() mutable { - if (closed_.load()) return; - socket_->async_send_to( - asio::buffer(*buf), dst, - [buf](std::error_code, std::size_t) {}); - }); -} - -void UdpMediaChannel::close() { - if (closed_.exchange(true)) return; - if (socket_) { - std::error_code ec; - socket_->cancel(ec); - socket_->close(ec); - } -} - -asio::ip::udp::endpoint UdpMediaChannel::local_endpoint() const { - if (!socket_) return {}; - std::error_code ec; - return socket_->local_endpoint(ec); -} - -} // namespace voicecat::net diff --git a/core/src/net/transport.h b/core/src/net/transport.h deleted file mode 100644 index a057310..0000000 --- a/core/src/net/transport.h +++ /dev/null @@ -1,221 +0,0 @@ -/* - * net/transport.h: TCP control channel + UDP media channel. - * - */ -#ifndef VOICECAT_NET_TRANSPORT_H -#define VOICECAT_NET_TRANSPORT_H - -#include -#include - -#define ASIO_STANDALONE 1 -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "protocol/protocol.h" - -// Forward-declare TlsContext so transport.h does not pull in mbedTLS headers. -namespace voicecat::crypto { class TlsContext; } - -namespace voicecat::net { - -// Callbacks delivered on the net thread. Callers must not block inside them. -struct TcpChannelCallbacks { - std::function on_connected; - std::function on_connect_error; - std::function)> on_frame; // one decoded frame payload - std::function on_error; - std::function on_disconnected; - // Called (on the handshake thread) right after TLS succeeds, before reads begin. - // Use to export keying material while the handshake context is still fresh. - std::function on_tls_ready; -}; - -// Client-side: owns an io_context + dedicated net thread -class TcpControlChannel { - public: - explicit TcpControlChannel(TcpChannelCallbacks cbs); - ~TcpControlChannel(); - - // Async connect; calls on_connected or on_connect_error on the net thread. - void async_connect(const std::string& host, uint16_t port); - - // Queue a framed send (thread-safe; callable from any thread). - void send_frame(std::vector payload); - - // Graceful close; safe to call from any thread. Waits for the net thread to join. - void close(); - - bool connected() const { return connected_.load(std::memory_order_acquire); } - - // Access the io_context so callers can post work back to the net thread. - asio::io_context& io() { return io_; } - - private: - void run_loop(); - void start_read(); - void handle_length(std::error_code ec, std::size_t n); - void handle_body(uint32_t length, std::error_code ec, std::size_t n); - void do_send(); - - asio::io_context io_; - asio::executor_work_guard work_guard_; - asio::ip::tcp::socket socket_; - asio::strand strand_; - std::thread net_thread_; - - TcpChannelCallbacks cbs_; - protocol::FrameCodec codec_; - - uint8_t len_buf_[4]{}; - std::vector body_buf_; - std::deque> send_queue_; - bool sending_{false}; - std::atomic connected_{false}; - std::atomic closing_{false}; -}; - -// Server-side: one per accepted socket, shares the server's io_context -class TcpServerConn : public std::enable_shared_from_this { - public: - // Plain TCP constructor (no TLS — for tests or future plaintext paths). - TcpServerConn(asio::ip::tcp::socket socket, TcpChannelCallbacks cbs); - - // TLS constructor: takes ownership of a TlsContext; start() will run the - // handshake on a temporary thread then switch to a TLS I/O thread. - TcpServerConn(asio::ip::tcp::socket socket, TcpChannelCallbacks cbs, - std::unique_ptr tls); - - ~TcpServerConn(); - - // Begin reading; must be called once after construction (on the io thread). - void start(); - - // Thread-safe send (safe to call from the server's io thread or another strand). - void send_frame(std::vector payload); - - // Close the connection (safe from any thread). - void close(); - - // Block until the TLS I/O thread (if any) has finished. Must be called after close(). - // Safe to call from any thread except the TLS I/O thread itself. - void wait_closed(); - - bool connected() const { return connected_.load(std::memory_order_acquire); } - - private: - // Asio path (no TLS) - void start_read(); - void handle_length(std::error_code ec, std::size_t n); - void handle_body(uint32_t length, std::error_code ec, std::size_t n); - void do_send(); - - // TLS path - void tls_read_loop(); - void tls_drain_sends(); - - asio::ip::tcp::socket socket_; - asio::strand strand_; - TcpChannelCallbacks cbs_; - protocol::FrameCodec codec_; - - uint8_t len_buf_[4]{}; - std::vector body_buf_; - std::deque> send_queue_; - bool sending_{false}; - std::atomic connected_{false}; - std::atomic closing_{false}; - - // TLS members (null in plain-TCP mode) - std::unique_ptr tls_; - std::thread tls_thread_; - std::mutex tls_send_mutex_; - std::deque> tls_send_queue_; -}; - -// Server-side acceptor -// Spawns a TcpServerConn (via factory) for each accepted TCP connection. -class TcpAcceptor { - public: - using ConnFactory = std::function(asio::ip::tcp::socket)>; - - TcpAcceptor(asio::io_context& io, uint16_t port, ConnFactory factory); - - // Start accepting. Call once; re-arms itself automatically. - void start(); - - // Stop accepting and close all tracked connections (safe while io_context is alive). - void stop(); - - // Stop accepting, close all connections, and block until every connection's - // I/O thread has finished. Call BEFORE the io_context is destroyed — the TLS read - // threads do blocking I/O (not async on the io_context) and will touch the reactor - // on socket close if the io_context is already gone (manifests as a null-kqueue-reactor - // segfault on macOS; latent on Windows IOCP / Linux epoll where timing is more forgiving). - void shutdown(); - - // Actual bound port (useful when bind_port=0 lets the OS pick). - uint16_t local_port() const { return static_cast(acceptor_.local_endpoint().port()); } - - private: - void do_accept(); - - asio::ip::tcp::acceptor acceptor_; - ConnFactory factory_; - bool stopped_{false}; - - std::mutex conns_mu_; - std::vector> conns_; -}; - -// UDP media channel -// Thin async UDP socket. send_to() is thread-safe. Recv callbacks fire on the -// io_context's thread (same thread that runs the io_context::run() loop). -class UdpMediaChannel { - public: - using FrameCallback = - std::function; - - UdpMediaChannel() = default; - ~UdpMediaChannel() { close(); } - - UdpMediaChannel(const UdpMediaChannel&) = delete; - UdpMediaChannel& operator=(const UdpMediaChannel&) = delete; - - // Bind to 0.0.0.0:port (0 = OS-assigned). Must be called before start_recv/send_to. - bool bind(asio::io_context& io, uint16_t port = 0); - - // Begin the async recv loop. cb is called on the io_context thread. - void start_recv(FrameCallback cb); - - // Thread-safe fire-and-forget send. Copies data into a heap buffer. - void send_to(const uint8_t* data, size_t len, asio::ip::udp::endpoint dst); - - // Cancel all async ops and close the socket. Safe to call from any thread. - void close(); - - asio::ip::udp::endpoint local_endpoint() const; - bool bound() const { return bound_.load(std::memory_order_acquire); } - - private: - void do_recv(); - - // Socket + recv state live here; only accessed from the io_context thread after bind(). - std::unique_ptr socket_; - asio::ip::udp::endpoint sender_ep_; - std::array recv_buf_{}; - FrameCallback frame_cb_; - std::atomic bound_{false}; - std::atomic closed_{false}; -}; - -} // namespace voicecat::net - -#endif // VOICECAT_NET_TRANSPORT_H diff --git a/core/src/net/voice_frame.h b/core/src/net/voice_frame.h deleted file mode 100644 index 0fc2476..0000000 --- a/core/src/net/voice_frame.h +++ /dev/null @@ -1,118 +0,0 @@ -/* - * net/voice_frame.h: UDP media frame wire format (header-only). - * - * The 14-byte fixed header is also used as AEAD associated data. - * Multi-byte fields are big-endian. The payload (Opus packet) is AEAD-encrypted. - */ -#ifndef VOICECAT_NET_VOICE_FRAME_H -#define VOICECAT_NET_VOICE_FRAME_H - -#include -#include -#include -#include - -namespace voicecat::net { - -// Frame types. -inline constexpr uint8_t kFrameVoice = 1; -inline constexpr uint8_t kFrameKeepalive = 2; -inline constexpr uint8_t kFrameUdpBinding = 3; - -// Flag bits in VoiceFrame::flags. -inline constexpr uint8_t kFlagMarker = 0x01; // start of talkspurt -inline constexpr uint8_t kFlagFecPresent = 0x02; // this frame carries previous-frame FEC -inline constexpr uint8_t kFlagDtx = 0x04; // comfort-noise / DTX silence frame -inline constexpr uint8_t kFlagLast = 0x08; // last frame before stream stop - -// Codec IDs. -inline constexpr uint16_t kCodecOpus = 0; - -// Size of the serialized header (bytes before the payload). -inline constexpr size_t kVoiceHeaderSize = 20; - -/* - * Wire layout (big-endian): - * [0] type u8 - * [1] flags u8 - * [2..3] codec u16 - * [4..7] ssrc u32 - * [8..15] seq u64 (full monotonic send counter — the AEAD nonce counter) - * [16..19] timestamp u32 (sample clock @48 kHz) - * [20+] payload (AEAD-encrypted Opus packet) - * - * The 20-byte header is the AEAD AAD (authenticated, not encrypted). - * The payload region is the AEAD ciphertext + 16-byte Poly1305 MAC. - * - - */ -struct VoiceFrame { - uint8_t type = kFrameVoice; - uint8_t flags = 0; - uint16_t codec = kCodecOpus; - uint32_t ssrc = 0; - uint64_t seq = 0; - uint32_t timestamp = 0; - std::vector payload; // Opus bytes (pre-AEAD on send; post-AEAD on recv) -}; - -// Serialize the 20-byte header into buf[0..19]. buf must be at least kVoiceHeaderSize bytes. -inline void serialize_header(const VoiceFrame& f, uint8_t* buf) { - buf[0] = f.type; - buf[1] = f.flags; - buf[2] = static_cast(f.codec >> 8); - buf[3] = static_cast(f.codec & 0xFF); - buf[4] = static_cast(f.ssrc >> 24); - buf[5] = static_cast(f.ssrc >> 16); - buf[6] = static_cast(f.ssrc >> 8); - buf[7] = static_cast(f.ssrc & 0xFF); - buf[8] = static_cast(f.seq >> 56); - buf[9] = static_cast(f.seq >> 48); - buf[10] = static_cast(f.seq >> 40); - buf[11] = static_cast(f.seq >> 32); - buf[12] = static_cast(f.seq >> 24); - buf[13] = static_cast(f.seq >> 16); - buf[14] = static_cast(f.seq >> 8); - buf[15] = static_cast(f.seq & 0xFF); - buf[16] = static_cast(f.timestamp >> 24); - buf[17] = static_cast(f.timestamp >> 16); - buf[18] = static_cast(f.timestamp >> 8); - buf[19] = static_cast(f.timestamp & 0xFF); -} - -// Parse the 20-byte header from buf. Returns false if len < kVoiceHeaderSize. -inline bool parse_header(const uint8_t* buf, size_t len, VoiceFrame& out) { - if (len < kVoiceHeaderSize) return false; - out.type = buf[0]; - out.flags = buf[1]; - out.codec = static_cast((buf[2] << 8) | buf[3]); - out.ssrc = (static_cast(buf[4]) << 24) | - (static_cast(buf[5]) << 16) | - (static_cast(buf[6]) << 8) | - static_cast(buf[7]); - out.seq = (static_cast(buf[8]) << 56) | - (static_cast(buf[9]) << 48) | - (static_cast(buf[10]) << 40) | - (static_cast(buf[11]) << 32) | - (static_cast(buf[12]) << 24) | - (static_cast(buf[13]) << 16) | - (static_cast(buf[14]) << 8) | - static_cast(buf[15]); - out.timestamp = (static_cast(buf[16]) << 24) | - (static_cast(buf[17]) << 16) | - (static_cast(buf[18]) << 8) | - static_cast(buf[19]); - return true; -} - -// Serialize a full UDP_BINDING packet (type=3, token in payload, no AEAD). -inline std::vector make_udp_binding_packet(const uint8_t* token, size_t token_len) { - std::vector pkt(kVoiceHeaderSize + token_len, 0); - pkt[0] = kFrameUdpBinding; - std::memcpy(pkt.data() + kVoiceHeaderSize, token, token_len); - return pkt; -} - -} // namespace voicecat::net - -#endif // VOICECAT_NET_VOICE_FRAME_H diff --git a/core/src/protocol/envelope.cpp b/core/src/protocol/envelope.cpp deleted file mode 100644 index b76dde7..0000000 --- a/core/src/protocol/envelope.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include "protocol/envelope.h" - -#include "protocol/protocol.h" - -#include -#include - -namespace voicecat::protocol { - -bool encode_envelope(const voicecat::v1::Envelope& env, std::vector& out) { - std::string bytes; - if (!env.SerializeToString(&bytes)) return false; - FrameCodec::emit(reinterpret_cast(bytes.data()), bytes.size(), out); - return true; -} - -bool decode_envelope(const uint8_t* data, size_t len, voicecat::v1::Envelope& out) { - return out.ParseFromArray(data, static_cast(len)); -} - -bool decode_envelope(const std::vector& frame, voicecat::v1::Envelope& out) { - return decode_envelope(frame.data(), frame.size(), out); -} - -uint64_t next_request_id() { - static std::atomic counter{1}; - return counter.fetch_add(1, std::memory_order_relaxed); -} - -} // namespace voicecat::protocol diff --git a/core/src/protocol/envelope.h b/core/src/protocol/envelope.h deleted file mode 100644 index 6469553..0000000 --- a/core/src/protocol/envelope.h +++ /dev/null @@ -1,35 +0,0 @@ -/* - * protocol/envelope.h: thin helpers around the generated protobuf types. - * - * Hides the generated namespace from callers that only need to send/receive - * envelopes without touching proto types directly. All callers that do need - * the proto types can #include the generated header alongside this one. - * - */ -#ifndef VOICECAT_PROTOCOL_ENVELOPE_H -#define VOICECAT_PROTOCOL_ENVELOPE_H - -#include -#include -#include - -// Generated by protobuf_generate(); lives in the build tree. -#include "proto/voicecat.pb.h" - -namespace voicecat::protocol { - -// Serialize env into a framed wire buffer: [big-endian u32 length][payload]. -// Returns false on serialization error. -bool encode_envelope(const voicecat::v1::Envelope& env, std::vector& out); - -// Deserialize a raw payload (no length prefix) into out. -// Returns false on parse error. -bool decode_envelope(const uint8_t* data, size_t len, voicecat::v1::Envelope& out); -bool decode_envelope(const std::vector& frame, voicecat::v1::Envelope& out); - -// Stamp a monotonically increasing request_id (thread-safe, relaxed ordering). -uint64_t next_request_id(); - -} // namespace voicecat::protocol - -#endif // VOICECAT_PROTOCOL_ENVELOPE_H diff --git a/core/src/protocol/protocol.cpp b/core/src/protocol/protocol.cpp deleted file mode 100644 index 4fac63b..0000000 --- a/core/src/protocol/protocol.cpp +++ /dev/null @@ -1,60 +0,0 @@ -#include "protocol/protocol.h" - -#include - -namespace voicecat::protocol { - -// FrameCodec::emit - -void FrameCodec::emit(const uint8_t* payload, size_t len, std::vector& out) { - // Length header: big-endian u32. - auto u32 = static_cast(len); - out.push_back(static_cast((u32 >> 24) & 0xFF)); - out.push_back(static_cast((u32 >> 16) & 0xFF)); - out.push_back(static_cast((u32 >> 8) & 0xFF)); - out.push_back(static_cast( u32 & 0xFF)); - out.insert(out.end(), payload, payload + len); -} - -void FrameCodec::emit(const std::vector& payload, std::vector& out) { - emit(payload.data(), payload.size(), out); -} - -// FrameCodec::feed - -bool FrameCodec::feed(const uint8_t* data, size_t len, - std::vector>& out_frames) { - buf_.insert(buf_.end(), data, data + len); - - while (true) { - if (buf_.size() < kLengthHeaderSize) { - break; // need more bytes for the header - } - - // Decode big-endian u32 length. - uint32_t frame_len = - (static_cast(buf_[0]) << 24) | - (static_cast(buf_[1]) << 16) | - (static_cast(buf_[2]) << 8) | - static_cast(buf_[3]); - - if (frame_len > kMaxFrameBytes) { - buf_.clear(); - return false; // oversized frame — protocol error - } - - size_t total = kLengthHeaderSize + static_cast(frame_len); - if (buf_.size() < total) { - break; // need more bytes for the body - } - - // Extract the complete frame payload. - out_frames.emplace_back(buf_.begin() + kLengthHeaderSize, - buf_.begin() + total); - buf_.erase(buf_.begin(), buf_.begin() + total); - } - - return true; -} - -} // namespace voicecat::protocol diff --git a/core/src/protocol/protocol.h b/core/src/protocol/protocol.h deleted file mode 100644 index 8c68a68..0000000 --- a/core/src/protocol/protocol.h +++ /dev/null @@ -1,48 +0,0 @@ -/* - * protocol/protocol.h: control-plane (de)serialization + routing. - * - * Wire format is a length-prefixed protobuf `Envelope` - * (proto/voicecat.proto). This layer parses frames into Envelopes, correlates - * request_id response, and dispatches to handlers. Media frames do NOT come through here - * (they use the fixed binary header - * - * FrameCodec below is used by both the client (net/transport.h) and the server - * (conn_session.cpp). See protocol/envelope.h for the Envelope-level encode/decode that sits - * on top of this. - */ -#ifndef VOICECAT_PROTOCOL_PROTOCOL_H -#define VOICECAT_PROTOCOL_PROTOCOL_H - -#include -#include -#include - -namespace voicecat::protocol { - -constexpr uint32_t kProtocolVersion = 1; -constexpr uint32_t kMaxFrameBytes = 16u * 1024 * 1024; -constexpr size_t kLengthHeaderSize = 4; // big-endian u32 prefix - -// Reads/writes [u32 big-endian length][payload] frames from a byte stream. -class FrameCodec { - public: - // Append received bytes; pop complete frame payloads into out_frames. - // Returns false on protocol error (oversized frame or framing violation). - bool feed(const uint8_t* data, size_t len, std::vector>& out_frames); - - // Serialize a frame into out: [big-endian u32 length][payload bytes]. - static void emit(const uint8_t* payload, size_t len, std::vector& out); - static void emit(const std::vector& payload, std::vector& out); - - // Number of bytes buffered but not yet forming a complete frame. - size_t pending_bytes() const { return buf_.size(); } - - void reset() { buf_.clear(); } - - private: - std::vector buf_; -}; - -} // namespace voicecat::protocol - -#endif // VOICECAT_PROTOCOL_PROTOCOL_H diff --git a/core/src/session/session.cpp b/core/src/session/session.cpp deleted file mode 100644 index 9308f4c..0000000 --- a/core/src/session/session.cpp +++ /dev/null @@ -1,158 +0,0 @@ -#include "session/session.h" - -#include - -namespace voicecat::session { - -const Channel* SessionModel::find_channel(uint32_t id) const { - for (auto& ch : channels_) if (ch.id == id) return &ch; - return nullptr; -} - -const User* SessionModel::find_user(uint32_t id) const { - for (auto& u : users_) if (u.id == id) return &u; - return nullptr; -} - -std::pair SessionModel::find_user_by_ssrc(uint32_t ssrc) const { - for (auto& u : users_) { - for (auto& s : u.streams) { - if (s.ssrc == ssrc) return {&u, &s}; - } - } - return {nullptr, nullptr}; -} - -namespace { - -void copy_channel_audio(Channel& ch, const voicecat::v1::AudioConfig& a) { - ch.audio_sample_rate = a.sample_rate() ? a.sample_rate() : 48000; - ch.audio_frame_ms = a.frame_ms() ? a.frame_ms() : 20; - ch.audio_mode = static_cast(a.mode()); - ch.audio_bitrate_bps = a.bitrate_bps(); - ch.audio_application = static_cast(a.application()); - ch.audio_fec = a.fec(); - ch.audio_expected_packet_loss = a.expected_packet_loss(); - ch.audio_dtx = a.dtx(); - ch.audio_complexity = a.complexity(); - ch.audio_dred = a.dred(); -} - -std::vector copy_streams( - const google::protobuf::RepeatedPtrField& src) { - std::vector out; - out.reserve(src.size()); - for (const auto& pb : src) { - Stream s; - s.stream_id = pb.stream_id(); - s.ssrc = pb.ssrc(); - s.kind = static_cast(pb.kind()); - s.label = pb.label(); - s.sample_rate = pb.audio().sample_rate() ? pb.audio().sample_rate() : 48000; - s.frame_ms = pb.audio().frame_ms() ? pb.audio().frame_ms() : 20; - s.mode = static_cast(pb.audio().mode()); - s.bitrate_bps = pb.audio().bitrate_bps(); - s.application = static_cast(pb.audio().application()); - s.fec = pb.audio().fec(); - s.expected_packet_loss = pb.audio().expected_packet_loss(); - s.dtx = pb.audio().dtx(); - s.complexity = pb.audio().complexity(); - s.dred = pb.audio().dred(); - out.push_back(std::move(s)); - } - return out; -} -} // namespace - -void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap) { - channels_.clear(); - for (const auto& pb : snap.channels()) { - Channel ch; - ch.id = pb.id(); - ch.parent_id = pb.parent_id(); - ch.name = pb.name(); - ch.topic = pb.topic(); - ch.password_protected = pb.password_protected(); - ch.max_users = pb.max_users(); - ch.sort_order = static_cast(pb.order()); - copy_channel_audio(ch, pb.audio()); - channels_.push_back(std::move(ch)); - } - - users_.clear(); - for (const auto& pb : snap.users()) { - User u; - u.id = pb.id(); - u.nickname = pb.nickname(); - u.is_guest = pb.is_guest(); - u.channel_id = pb.channel_id(); - u.self_mic_muted = pb.self_mic_muted(); - u.self_deafened = pb.self_deafened(); - u.server_muted = pb.server_muted(); - u.server_deafened = pb.server_deafened(); - u.voice_subscribed = pb.voice_subscribed(); - u.streams = copy_streams(pb.streams()); - users_.push_back(std::move(u)); - } -} - -void SessionModel::apply_user_event(const voicecat::v1::UserEvent& ev) { - using Kind = voicecat::v1::UserEvent; - - if (ev.kind() == Kind::JOINED || ev.kind() == Kind::UPDATED) { - const auto& pb = ev.user(); - User u; - u.id = pb.id(); - u.nickname = pb.nickname(); - u.is_guest = pb.is_guest(); - u.channel_id = pb.channel_id(); - u.self_mic_muted = pb.self_mic_muted(); - u.self_deafened = pb.self_deafened(); - u.server_muted = pb.server_muted(); - u.server_deafened = pb.server_deafened(); - u.voice_subscribed = pb.voice_subscribed(); - u.streams = copy_streams(pb.streams()); - - auto it = std::find_if(users_.begin(), users_.end(), - [&](const User& x) { return x.id == u.id; }); - if (it != users_.end()) *it = std::move(u); - else users_.push_back(std::move(u)); - - } else if (ev.kind() == Kind::LEFT) { - uint32_t uid = ev.user().id(); - users_.erase(std::remove_if(users_.begin(), users_.end(), - [uid](const User& x) { return x.id == uid; }), - users_.end()); - } -} - -void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) { - using Kind = voicecat::v1::ChannelEvent; - - if (ev.kind() == Kind::CREATED || ev.kind() == Kind::UPDATED) { - const auto& pb = ev.channel(); - Channel ch; - ch.id = pb.id(); - ch.parent_id = pb.parent_id(); - ch.name = pb.name(); - ch.topic = pb.topic(); - ch.password_protected = pb.password_protected(); - ch.max_users = pb.max_users(); - ch.sort_order = static_cast(pb.order()); - copy_channel_audio(ch, pb.audio()); - - auto it = std::find_if(channels_.begin(), channels_.end(), - [&](const Channel& x) { return x.id == ch.id; }); - if (it != channels_.end()) *it = std::move(ch); - else channels_.push_back(std::move(ch)); - - } else if (ev.kind() == Kind::DELETED) { - // deleted_id, not channel().id() — the proto leaves `channel` unset for deletes - uint32_t cid = ev.deleted_id(); - channels_.erase(std::remove_if(channels_.begin(), channels_.end(), - [cid](const Channel& x) { return x.id == cid; }), - channels_.end()); - } -} - -} // namespace voicecat::session diff --git a/core/src/session/session.h b/core/src/session/session.h deleted file mode 100644 index a272f3c..0000000 --- a/core/src/session/session.h +++ /dev/null @@ -1,100 +0,0 @@ -/* - * session/session.h: client-side mirror of the server's channel/user state. - * - * Populated from ServerStateSnapshot (full snapshot) and incremental UserEvent / - * ChannelEvent messages. Not thread-safe — always called from io_thread_. - */ -#ifndef VOICECAT_SESSION_SESSION_H -#define VOICECAT_SESSION_SESSION_H - -#include -#include -#include -#include - -#include "proto/voicecat.pb.h" - -namespace voicecat::session { - -struct Channel { - uint32_t id{0}; - uint32_t parent_id{0}; - std::string name; - std::string topic; - bool password_protected{false}; - uint32_t max_users{0}; - uint32_t sort_order{0}; - // Authoritative channel AudioConfig mirrors - // voicecat::v1::AudioConfig field-for-field, same flat shape as Stream below. Populated - // from ServerStateSnapshot / ChannelEvent so the channel-edit dialog can read back the - // current config (the vc_channel read struct now carries these too). - uint32_t audio_sample_rate{48000}; - uint32_t audio_frame_ms{20}; - uint32_t audio_mode{0}; // 0 = mono, 1 = stereo - uint32_t audio_bitrate_bps{0}; - uint32_t audio_application{0}; // 0=VOIP, 1=AUDIO, 2=LOWDELAY - bool audio_fec{false}; - uint32_t audio_expected_packet_loss{0}; - bool audio_dtx{false}; - uint32_t audio_complexity{0}; - bool audio_dred{false}; -}; - -struct Stream { - uint32_t stream_id{0}; - uint32_t ssrc{0}; - int kind{0}; - std::string label; - // Full effective AudioConfig as broadcast by the server in - // StreamInfo.audio — mirrors voicecat::v1::AudioConfig field-for-field so per-channel - // tuning (mono/stereo, bitrate, FEC/DTX, application) is observable client-side, not just - // sample_rate/frame_ms. - uint32_t sample_rate{48000}; - uint32_t frame_ms{20}; - uint32_t mode{0}; // 0 = mono, 1 = stereo (ChannelMode) - uint32_t bitrate_bps{0}; - uint32_t application{0}; // 0=VOIP, 1=AUDIO, 2=LOWDELAY (OpusApplication) - bool fec{false}; - uint32_t expected_packet_loss{0}; - bool dtx{false}; - uint32_t complexity{0}; - bool dred{false}; -}; - -struct User { - uint32_t id{0}; - std::string nickname; - bool is_guest{true}; - uint32_t channel_id{0}; - bool self_mic_muted{false}; - bool self_deafened{false}; - bool server_muted{false}; - bool server_deafened{false}; - bool voice_subscribed{false}; - std::vector streams; -}; - -class SessionModel { - public: - const std::vector& channels() const { return channels_; } - const std::vector& users() const { return users_; } - - const Channel* find_channel(uint32_t id) const; - const User* find_user(uint32_t id) const; - - // Find the user that owns a given media ssrc, and the matching Stream entry. - // Returns {nullptr, nullptr} if not found. - std::pair find_user_by_ssrc(uint32_t ssrc) const; - - void apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap); - void apply_user_event(const voicecat::v1::UserEvent& ev); - void apply_channel_event(const voicecat::v1::ChannelEvent& ev); - - private: - std::vector channels_; - std::vector users_; -}; - -} // namespace voicecat::session - -#endif // VOICECAT_SESSION_SESSION_H diff --git a/core/src/voicecat.cpp b/core/src/voicecat.cpp deleted file mode 100644 index 98cba37..0000000 --- a/core/src/voicecat.cpp +++ /dev/null @@ -1,362 +0,0 @@ -/* - * voicecat.cpp: C ABI implementation. - * - * Lifecycle (create/destroy) and trivial accessors are handled directly here; everything else - * delegates to vc_client (core/src/core/client.cpp). - */ -#include "voicecat.h" - -#include - -#include "core/client.h" - -#define VC_STR2(x) #x -#define VC_STR(x) VC_STR2(x) - -extern "C" { - -const char* vc_version_string(void) { - static const char* kVersion = VC_STR(VOICECAT_VERSION_MAJOR) "." VC_STR( - VOICECAT_VERSION_MINOR) "." VC_STR(VOICECAT_VERSION_PATCH); - return kVersion; -} - -const char* vc_result_string(vc_result code) { - switch (code) { - case VC_OK: return "ok"; - case VC_ERR_NOT_IMPLEMENTED: return "not implemented"; - case VC_ERR_INVALID_ARG: return "invalid argument"; - case VC_ERR_NOT_CONNECTED: return "not connected"; - case VC_ERR_ALREADY: return "already in requested state"; - case VC_ERR_AUTH_FAILED: return "authentication failed"; - case VC_ERR_PERMISSION_DENIED: return "permission denied"; - case VC_ERR_TIMEOUT: return "timeout"; - case VC_ERR_IO: return "i/o error"; - case VC_ERR_PROTOCOL: return "protocol error"; - case VC_ERR_CRYPTO: return "crypto error"; - case VC_ERR_AUDIO: return "audio error"; - case VC_ERR_INTERNAL: return "internal error"; - } - return "unknown"; -} - -vc_client* vc_client_create(const vc_config* cfg, vc_callbacks cb) { - if (cfg == nullptr) return nullptr; - return new (std::nothrow) vc_client(*cfg, cb); -} - -void vc_client_destroy(vc_client* c) { delete c; } - -/* Everything below delegates to vc_client (real or stub, per preset above). */ - -vc_result vc_connect(vc_client* c, const char* host, uint16_t port) { - if (c == nullptr || host == nullptr) return VC_ERR_INVALID_ARG; - return c->connect(host, port); -} - -vc_result vc_disconnect(vc_client* c) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->disconnect(); -} - -vc_result vc_authenticate_guest(vc_client* c, const char* nickname) { - if (c == nullptr || nickname == nullptr) return VC_ERR_INVALID_ARG; - return c->authenticate_guest(nickname); -} - -vc_result vc_authenticate_user(vc_client* c, const char* username, const char* password) { - if (c == nullptr || username == nullptr || password == nullptr) return VC_ERR_INVALID_ARG; - return c->authenticate_user(username, password); -} - -vc_result vc_join_channel(vc_client* c, uint32_t channel_id, const char* password) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->join_channel(channel_id, password); -} - -vc_result vc_leave_channel(vc_client* c) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->leave_channel(); -} - -vc_result vc_join_voice(vc_client* c) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->join_voice(); -} - -vc_result vc_leave_voice(vc_client* c) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->leave_voice(); -} - -vc_result vc_stream_start(vc_client* c, const vc_stream_desc* desc, uint32_t* out_stream_id) { - if (c == nullptr || desc == nullptr) return VC_ERR_INVALID_ARG; - return c->stream_start(*desc, out_stream_id); -} - -vc_result vc_stream_stop(vc_client* c, uint32_t stream_id) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->stream_stop(stream_id); -} - -vc_result vc_set_input_device(vc_client* c, uint32_t stream_id, const char* device_id) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_input_device(stream_id, device_id); -} - -vc_result vc_set_input_mode(vc_client* c, vc_input_mode mode) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_input_mode(mode); -} - -vc_result vc_set_vad_threshold(vc_client* c, float threshold) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_vad_threshold(threshold); -} - -vc_result vc_set_push_to_talk(vc_client* c, int active) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_push_to_talk(active != 0); -} - -vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_self_mute(mic_muted != 0, deafened != 0); -} - -vc_result vc_set_output_volume(vc_client* c, float gain) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_output_volume(gain); -} - -vc_result vc_set_input_gain(vc_client* c, float gain) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_input_gain(gain); -} - -vc_result vc_set_input_noise_reduction(vc_client* c, int enable) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_input_noise_reduction(enable != 0); -} - -vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, float gain, - int muted, int noise_reduction) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_remote_stream(user_id, stream_id, gain, muted != 0, noise_reduction != 0); -} - -vc_result vc_get_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, - vc_remote_stream_state* out) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->get_remote_stream(user_id, stream_id, out); -} - -vc_result vc_get_stream_audio_config(vc_client* c, uint32_t user_id, uint32_t stream_id, - vc_audio_config* out) { - if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; - return c->get_stream_audio_config(user_id, stream_id, out); -} - -vc_result vc_test_inject_capture(vc_client* c, uint32_t stream_id, const int16_t* pcm, - size_t samples) { - if (c == nullptr || pcm == nullptr) return VC_ERR_INVALID_ARG; - return c->stream_feed_pcm(stream_id, pcm, samples, 1); -} - -vc_result vc_stream_feed_pcm(vc_client* c, uint32_t stream_id, const int16_t* pcm, - size_t samples_per_channel, uint32_t channels) { - if (c == nullptr || pcm == nullptr) return VC_ERR_INVALID_ARG; - if (channels != 1 && channels != 2) return VC_ERR_INVALID_ARG; - return c->stream_feed_pcm(stream_id, pcm, samples_per_channel, channels); -} - -vc_result vc_set_pcm_sink(vc_client* c, vc_pcm_sink_cb cb, void* user) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_pcm_sink(cb, user); -} - -vc_result vc_set_mixed_output_sink(vc_client* c, vc_mixed_output_cb cb, void* user) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_mixed_output_sink(cb, user); -} - -vc_result vc_set_external_playback(vc_client* c, int enable) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_external_playback(enable != 0); -} - -vc_result vc_set_capture_channels(vc_client* c, uint32_t stream_id, uint32_t channels) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_capture_channels(stream_id, channels); -} - -vc_result vc_send_text(vc_client* c, vc_text_scope scope, uint32_t target_id, - const char* utf8) { - if (c == nullptr || utf8 == nullptr) return VC_ERR_INVALID_ARG; - return c->send_text(scope, target_id, utf8); -} - -vc_result vc_list_devices(vc_client* c, vc_device_kind kind, vc_device_list* out) { - if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; - return c->list_devices(kind, out); -} - -void vc_free_device_list(vc_device_list* list) { - if (list == nullptr || list->items == nullptr) return; - for (size_t i = 0; i < list->count; ++i) { - delete[] list->items[i].id; - delete[] list->items[i].name; - } - delete[] list->items; - list->items = nullptr; - list->count = 0; -} - -vc_result vc_list_channels(vc_client* c, vc_channel_list* out) { - if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; - return c->list_channels(out); -} - -void vc_free_channel_list(vc_channel_list* list) { - if (list == nullptr || list->items == nullptr) return; - for (size_t i = 0; i < list->count; ++i) { - delete[] list->items[i].name; - delete[] list->items[i].topic; - } - delete[] list->items; - list->items = nullptr; - list->count = 0; -} - -vc_result vc_list_users(vc_client* c, vc_user_list* out) { - if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; - return c->list_users(out); -} - -void vc_free_user_list(vc_user_list* list) { - if (list == nullptr || list->items == nullptr) return; - for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].nickname; - delete[] list->items; - list->items = nullptr; - list->count = 0; -} - -vc_result vc_list_user_streams(vc_client* c, uint32_t user_id, vc_stream_summary_list* out) { - if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; - return c->list_user_streams(user_id, out); -} - -void vc_free_stream_summary_list(vc_stream_summary_list* list) { - if (list == nullptr || list->items == nullptr) return; - for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].label; - delete[] list->items; - list->items = nullptr; - list->count = 0; -} - -vc_result vc_confirm_server_identity(vc_client* c, int accept) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->confirm_server_identity(accept != 0); -} - -vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap, - size_t* out_len) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->get_server_identity_display(out_buf, buf_cap, out_len); -} - -vc_result vc_kick_user(vc_client* c, uint32_t user_id, const char* reason) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->kick_user(user_id, reason); -} - -vc_result vc_ban_user(vc_client* c, uint32_t user_id, const char* reason, - uint64_t expires_unix_ms) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->ban_user(user_id, reason, expires_unix_ms); -} - -vc_result vc_set_permission(vc_client* c, uint32_t user_id, const vc_permissions* perms) { - if (c == nullptr || perms == nullptr) return VC_ERR_INVALID_ARG; - return c->set_permission(user_id, perms); -} - -vc_result vc_set_server_mute(vc_client* c, uint32_t user_id, int muted, int deafened) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->set_server_mute(user_id, muted != 0, deafened != 0); -} - -vc_result vc_move_user(vc_client* c, uint32_t user_id, uint32_t channel_id) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->move_user(user_id, channel_id); -} - -vc_result vc_create_channel(vc_client* c, const vc_channel_info* info) { - if (c == nullptr || info == nullptr) return VC_ERR_INVALID_ARG; - return c->create_channel(info); -} - -vc_result vc_edit_channel(vc_client* c, const vc_channel_info* info) { - if (c == nullptr || info == nullptr) return VC_ERR_INVALID_ARG; - return c->edit_channel(info); -} - -vc_result vc_delete_channel(vc_client* c, uint32_t channel_id) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->delete_channel(channel_id); -} - -vc_result vc_create_account(vc_client* c, const char* username, const char* password) { - if (c == nullptr || username == nullptr || password == nullptr) return VC_ERR_INVALID_ARG; - return c->create_account(username, password); -} - -vc_result vc_reset_password(vc_client* c, const char* username, const char* new_password) { - if (c == nullptr || username == nullptr || new_password == nullptr) return VC_ERR_INVALID_ARG; - return c->reset_password(username, new_password); -} - -vc_result vc_delete_account(vc_client* c, const char* username) { - if (c == nullptr || username == nullptr) return VC_ERR_INVALID_ARG; - return c->delete_account(username); -} - -vc_result vc_list_accounts(vc_client* c) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->list_accounts(); -} - -vc_result vc_get_account_list(vc_client* c, vc_account_list* out) { - if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; - return c->get_account_list(out); -} - -void vc_free_account_list(vc_account_list* list) { - if (list == nullptr || list->items == nullptr) return; - for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].username; - delete[] list->items; - list->items = nullptr; - list->count = 0; -} - -vc_result vc_get_permissions(vc_client* c, vc_permissions* out) { - if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; - return c->get_permissions(out); -} - -vc_result vc_audio_suspend(vc_client* c) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->audio_suspend(); -} - -vc_result vc_audio_resume(vc_client* c) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->audio_resume(); -} - -vc_result vc_audio_restart(vc_client* c) { - if (c == nullptr) return VC_ERR_INVALID_ARG; - return c->audio_restart(); -} - -} // extern "C" diff --git a/docs/README.md b/docs/README.md index ab3725f..ca899b6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,37 +2,26 @@ VoiceCat is a self-hosted channel-based voice and text system. Control traffic uses TLS 1.3; media uses authenticated encrypted UDP derived from the TLS session. There is no plaintext -mode and no central service. +mode or central service. -The .NET 10 implementation is the source of truth. Some detailed documents predate the managed -rewrite and are being corrected as code changes touch them. When prose conflicts with current -managed code or tests, follow the managed implementation and fix the document in the same -change. - -## Current documents +Current references: - [architecture.md](architecture.md) — components, ownership, concurrency, and native boundary. -- [protocol.md](protocol.md) — protobuf control messages and fixed UDP media header. -- [security.md](security.md) — TLS, TOFU, media keys, authentication, and threat model. -- [voice.md](voice.md) — streams, Opus, loss handling, jitter, mixing, and screen audio. -- [api-dotnet.md](api-dotnet.md) — managed APIs and ownership contracts. +- [api-dotnet.md](api-dotnet.md) — managed API and ownership contracts. - [building.md](building.md) — development, platform builds, tests, and publishing. -- [deployment.md](deployment.md) — server configuration and packaging. -- [ios-deploy.md](ios-deploy.md) — managed iOS physical-device build and deployment. +- [deployment.md](deployment.md) — server packaging and operations. +- [ios-deploy.md](ios-deploy.md) — physical-device iOS build and deployment. - [tech-stack.md](tech-stack.md) — supported dependencies and licensing. -- [broadcast-ring-format.md](broadcast-ring-format.md) — frozen iOS extension/host ring ABI. -- [roadmap.md](roadmap.md) — only current release gates and intentionally deferred features. +- [broadcast-ring-format.md](broadcast-ring-format.md) — frozen extension/host ring ABI. -The completed C++-to-.NET migration plan was removed. Git history preserves that work without -making every future agent load an obsolete implementation diary. +`proto/voicecat.proto` is the control-plane wire contract. Managed tests are the executable +behavior contract. Keep prose current with those sources rather than documenting historical +implementations. -## Durable rules +Durable rules: -- `proto/voicecat.proto` is the control-plane schema. - Encryption is mandatory. -- No GPL or LGPL dependencies. +- GPL and LGPL dependencies are forbidden. - Real-time audio callbacks never allocate, lock, block, or perform I/O. -- The server relays encoded media; it does not mix or transcode. -- Text is ephemeral in v1. -- Accounts are administrator-provisioned; guests are an operator choice. +- The server relays encoded media; it does not mix or transcode it. - Wire, database, and shared-ring changes are explicitly versioned. diff --git a/docs/api-dotnet.md b/docs/api-dotnet.md index f881b98..9ee83a8 100644 --- a/docs/api-dotnet.md +++ b/docs/api-dotnet.md @@ -132,7 +132,7 @@ erasure of every runtime/library copy. `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 the retired native core or its C ABI. Fixed C signatures +vendored RNNoise. 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. diff --git a/docs/architecture.md b/docs/architecture.md index a370d52..331f118 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,6 +71,3 @@ need direct native entry points. These are platform adapters, not a second core. - SQLite is the server's persistent store; schema changes require explicit migrations. - Client profiles and TOFU pins are local platform data. - The ReplayKit ring layout is separately versioned and frozen. - -Unsupported C++ and Swift applications may remain temporarily during repository cleanup, but -they are not dependencies, compatibility targets, or design authorities. diff --git a/docs/building.md b/docs/building.md index 53de2bf..ae54fef 100644 --- a/docs/building.md +++ b/docs/building.md @@ -1,88 +1,42 @@ # Building and testing -## Prerequisites - -- .NET SDK selected by `dotnet/global.json` -- CMake and a C compiler for the Opus/RNNoise shim -- PowerShell for the cross-platform build scripts -- Xcode plus the pinned .NET macOS/iOS workloads for Apple clients - -Dependencies and NuGet lock files are committed. GPL/LGPL dependencies are forbidden. - -## Managed core, server, CLI, and tests +VoiceCat requires the .NET SDK selected by `global.json`, PowerShell, CMake, and a C compiler. +Apple clients additionally require macOS, Xcode, and the pinned .NET macOS/iOS workloads. From the repository root: -```bash -./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 -./dotnet/check-licenses.ps1 +```powershell +./scripts/build-native.ps1 +dotnet restore VoiceCat.slnx --locked-mode +dotnet build VoiceCat.slnx -c Release --no-restore +dotnet test VoiceCat.slnx -c Release --no-build +./scripts/check-licenses.ps1 ``` -The native script builds `native/media`, fetches checksum-pinned Opus 1.5.2, compiles vendored -RNNoise, and stages the resulting library and notices in `dotnet/artifacts/native`. +`build-native.ps1` builds the narrow Opus/RNNoise shim in `native/media` and stages it under +`artifacts/native`. NuGet dependencies and lock files are committed; GPL/LGPL dependencies are +forbidden and the approved license set is enforced by `scripts/check-licenses.ps1`. -Equivalent direct 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 -``` - -## Run locally - -```bash -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 -``` - -Both commands support `--help`. The CLI also supports deterministic two-process text and tone -checks used by the managed test suite. - -## Windows client +Run the server and CLI locally with: ```powershell -./dotnet/build-native.ps1 -dotnet restore clients/windows/VoiceCat.slnx --locked-mode -dotnet build clients/windows/VoiceCat.slnx -c Release --no-restore -./clients/windows/publish-client.ps1 +dotnet run --project src/VoiceCat.Server -- --data-dir ./voicecat-data +dotnet run --project src/VoiceCat.Cli -- --host 127.0.0.1 --nickname Local --trust-first ``` -The supported app references `VoiceCat.Managed` and the managed core. Published output must -contain `voicecat_media.dll` and must not contain the retired `voicecat.dll`. +Build the Windows client with `clients/windows/VoiceCat.slnx` and publish it with +`clients/windows/publish-client.ps1`. -## Apple clients - -On Apple Silicon with the SDK/workload versions documented in -`clients/apple/dotnet/README.md`: +On macOS, stage the native libraries and build the Apple clients with: ```bash -./dotnet/build-native.ps1 -./dotnet/build-native-ios.sh -dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx -dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug --no-restore +./scripts/build-native.ps1 +./scripts/build-native-ios.sh +dotnet restore clients/apple/VoiceCat.Apple.slnx +dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore ``` -The iOS build invokes the standalone project in `native/apple/broadcast` and embeds its appex. -Use `clients/apple/dotnet/build-ios-device.sh` and `deploy-ios-device.sh` for signed device -builds. Use `clients/apple/dotnet/publish-macos.sh --dry-run` for an ad-hoc validated macOS -bundle; its environment variables enable Developer ID signing and notarization. +See `clients/apple/README.md` and `docs/ios-deploy.md` for signing and device workflows. -## Server publishing - -`dotnet/publish-server.ps1` produces locked self-contained Windows and Linux artifacts in one -command. Pass `-Runtime win-x64` or `-Runtime linux-x64` to publish only one target. -`Dockerfile`, Compose configuration, and systemd packaging use the managed server. Validate a -published binary with its TLS `--health-check`, preferably including `--expect-fingerprint`. - -## CI and release expectations - -CI builds the native shim, managed solution, tests, licenses, and managed Apple clients. The -old C++ implementation is not a conformance target. Release validation additionally includes -real devices, screen readers, sustained calls, signing/notarization, container execution, and -a server soak; see `roadmap.md`. +Publish self-contained server binaries with `scripts/publish-server.ps1`. Generated native, +client, and server artifacts live under `artifacts/`. diff --git a/docs/deployment.md b/docs/deployment.md index d0f244b..7c5695d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,229 +1,35 @@ -# Deployment & Self-Hosting +# Server deployment -## Managed server deployment checkpoint - -The .NET server preserves protocol v2 and the native schema/credentials. Publish a -self-contained executable (no installed .NET runtime required): +Publish a locked, self-contained server from the repository root: ```powershell -# Publishes both win-x64 and linux-x64 by default. -./dotnet/publish-server.ps1 -# Publish just one target when needed. -./dotnet/publish-server.ps1 -Runtime linux-x64 -./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe --help -./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe account add Operator --admin --data-dir ./voicecat-data -./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe --data-dir ./voicecat-data --allow-guests false +./scripts/publish-server.ps1 +./scripts/publish-server.ps1 -Runtime linux-x64 +./artifacts/server/win-x64/VoiceCat.Server.exe --help ``` -Account add/reset use a hidden password prompt, redirected standard input, or -`VOICECAT_ADMIN_PASSWORD`. Passwords are never accepted as command arguments or logged. -Account delete/list work against the same database, including while the server runs. -Provisioning grants administrator access only through the local command's `--admin`; -in-band account creation remains non-admin. Restrict access to the data directory. - -Defaults are `0.0.0.0:8384` TCP+UDP, guest access enabled, 64 connections, 15-second TLS -handshakes, 45-second idle expiry and 15-second sweeps. Override with flags or environment: - -| Flag | Environment variable | -|---|---| -| `--data-dir` | `VOICECAT_DATA_DIR` | -| `--bind` | `VOICECAT_BIND_ADDRESS` | -| `--port` | `VOICECAT_BIND_PORT` | -| `--name` | `VOICECAT_SERVER_NAME` | -| `--allow-guests` | `VOICECAT_ALLOW_GUESTS` | -| `--max-connections` | `VOICECAT_MAX_CONNECTIONS` | -| `--handshake-seconds` | `VOICECAT_HANDSHAKE_TIMEOUT_SECONDS` | -| `--idle-seconds` | `VOICECAT_IDLE_TIMEOUT_SECONDS` | -| `--reaper-seconds` | `VOICECAT_REAPER_INTERVAL_SECONDS` | -| `--auth-burst` | `VOICECAT_AUTH_BURST` | -| `--auth-refill-seconds` | `VOICECAT_AUTH_REFILL_SECONDS` | - -Command arguments override environment values. `--print-config` validates and prints JSON -without creating files; `--print-fingerprint` prints the persisted leaf-certificate SHA-256 -pin. Startup emits one JSON `ready` event with both fingerprints and actual TCP/UDP ports. -Bind accepts IP literals; IPv6 listeners are IPv6-only. Open/forward both protocols. -An exclusive data-directory instance lock prevents duplicate managed server processes. -Ctrl+C and Unix SIGINT/SIGTERM stop all transport tasks; shutdown has a ten-second deadline. -Fatal listener/media/reaper failure exits the host rather than leaving a broken listener. -`--health-check HOST:PORT` performs a real protocol TLS 1.3 handshake and emits JSON. -Automation can add `--expect-fingerprint SHA256` to verify the persisted certificate. - -Password authentication is limited before Argon2 by source address and username across -connections: burst 5, refill one attempt per ten seconds. Starting at three failed attempts, -backoff grows from one to thirty seconds. Success clears backoff but does not restore -tokens. State is bounded to 4096 keys; idle entries retire after ten minutes when full. -Throttle and credential failures share the generic auth error. Limits are process-local. - -The publish script produces Windows x64 and Linux x64 self-contained outputs in one command -by default. It uses separate per-RID lock files so deployment and development restore graphs -remain reproducible. Pass `-Runtime` to publish only one target. -The Linux smoke starts the published ELF, validates TLS health and the pin, checks mode 0700 -data creation, sends SIGTERM and requires a clean exit: - -```bash -sh deploy/linux/smoke.sh dotnet/artifacts/server/linux-x64/VoiceCat.Server -``` - -`deploy/linux/voicecat.service` runs under a systemd dynamic user with a private state -directory, no capabilities and filesystem/kernel hardening. Install a published binary and -the unit from `deploy/linux/` as root, or use the helper there. The default Dockerfile now -builds the managed server with locked packages and runs it as UID 1654 in Microsoft's -chiseled .NET runtime-dependencies image. Compose drops all capabilities, sets the root -filesystem read-only and persists only `/data`. - -For operational endurance against an already running server: +The default endpoint is TCP and UDP port 8384. The server creates its TLS identity and SQLite +database in the data directory. Account passwords are accepted only through a hidden prompt, +redirected standard input, or `VOICECAT_ADMIN_PASSWORD`; they are never command arguments. ```powershell -./dotnet/soak-server.ps1 -HostName 127.0.0.1 -Port 8384 -Minutes 30 -Pairs 4 +./artifacts/server/win-x64/VoiceCat.Server.exe account add Operator --admin --data-dir ./voicecat-data +./artifacts/server/win-x64/VoiceCat.Server.exe --data-dir ./voicecat-data --allow-guests false ``` -Every cycle creates independent managed processes that exchange text and decoded voice. -A short 16-session published-server soak is part of this checkpoint. A release candidate -still needs the long soak on its target host. TOML/reload and signed image publication remain -before broad production rollout. +The checked-in `Dockerfile` builds the managed Linux server. `docker-compose.yml` runs it as a +non-root user with a read-only filesystem, persistent `/data`, and TCP/UDP 8384 exposed. -The product goal: someone looks at this and thinks *"oh, I (or my agent) can stand this up -in a few minutes."* Everything below is in service of that. Three install paths, all -**zero-config and encrypted by default**. - -## 1. The three paths - -### A. Docker (recommended) +For a host installation, `deploy/linux/install.sh` installs a published Linux binary and the +systemd unit. Validate a published binary with: ```bash -docker build -t voicecat:local . -docker run -d --name voicecat \ - -p 8384:8384/tcp \ # control (TLS 1.3) - -p 8384:8384/udp \ # media (encrypted) - -v voicecat-data:/data \ - --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \ - --cap-drop ALL --security-opt no-new-privileges \ - voicecat:local +sh deploy/linux/smoke.sh artifacts/server/linux-x64/VoiceCat.Server ``` -That's the whole thing. On first start it generates its Ed25519 identity + self-signed -cert, creates the SQLite database under `/data`, prints the **server fingerprint** (for -clients to verify), and listens. Control and media share one port number on TCP+UDP to keep -firewall rules trivial. +The server exposes `--health-check host:port` for TLS-aware probes. Before release, run the +container and the concurrent client soak: -A `docker-compose.yml` is provided for people who prefer it, but it isn't required. - -### B. Single static binary - -```bash -# download for your OS, then: -./voicecat-server # uses ./voicecat-data/ , prints fingerprint, runs +```powershell +./scripts/soak-server.ps1 -HostName 127.0.0.1 -Port 8384 -Minutes 30 -Pairs 4 ``` - -The server is a **single statically linked executable** (mbedTLS, libsodium, opus, sqlite, -etc. linked in — all permissive licenses). No runtime, no shared libraries to install, no -package manager. Linux (primary), macOS, and Windows builds. - -### C. From source - -```bash -git clone … && cd voice-cat -cmake --preset server-release # vcpkg fetches & pins all deps; auto-triplet (Linux/macOS/Windows) -cmake --build --preset server-release -./build/server-release/bin/voicecat-server -``` - -One `cmake` invocation; vcpkg (manifest mode) resolves the dependency graph reproducibly. -The `server-release` preset produces an optimized, **stripped** binary (`-s` linker flag) — -smaller executables suitable for distribution. Works on Linux (primary), macOS, and Windows; -the vcpkg triplet is auto-resolved by [`cmake/voicecat-toolchain.cmake`](../cmake/voicecat-toolchain.cmake). -No system packages to chase. - -## 2. Zero-config defaults - -The server runs with **no config file at all**. Sensible defaults: - -| Setting | Default | -|---------|---------| -| Encryption | On, always (not configurable off) | -| TLS cert / identity | Auto-generated on first run, persisted to the data dir | -| Database | Embedded SQLite in the data dir (no external DB) | -| Guests | Enabled (so the very first connect "just works"); easily disabled | -| Ports | `8384` TCP + UDP | -| A default channel | One "Lobby" voice/text channel created on first run | -| Opus policy | 48 kHz, 20 ms frames, mono/VOIP defaults; per-channel overrides allowed | -| Argon2id cost | Auto-tuned to the host on first run | - -Override only what you care about, via env vars or an optional `server.toml`: - -```toml -# server.toml — every key is optional -server_name = "Cats United" -allow_guests = false -bind_port = 8384 -data_dir = "/data" - -[tls] # only if you want a real CA cert; otherwise self-signed -cert_file = "/data/fullchain.pem" -key_file = "/data/privkey.pem" - -[opus.defaults] # default Opus policy for new channels -mode = "mono" -bitrate_bps = 24000 -frame_ms = 20 -fec = true -dtx = true - -[opus.limits] # server-enforced ceilings (bound bandwidth) -max_bitrate_bps = 128000 # channels can't be configured above this -``` - -Every key also has an `VOICECAT_*` env var form, which is what the Docker path uses. - -## 3. Connecting (client side) - -- **Pure direct-connect.** Enter `host:port` (and a nickname or account). There is no central - directory or server browser — you connect to a server you know. -- **Saved server list.** The client keeps a local list of saved servers (host:port, pinned - fingerprint, nickname/credentials per server) so you can store several and pick one to - join. This lives entirely in the client. -- On first connect the client shows the server's **fingerprint** and pins it (TOFU). No - accounts or certs needed to try it; if the operator disabled guests, the client prompts for - the username/password an admin gave you. - -That's the entire flow: run the server, share `host:port` + fingerprint, friends save it and -connect. - -## 3a. Provisioning accounts (admin) - -Accounts are **admin-provisioned** — there is no self-serve registration. Two equivalent ways, -both writing the same SQLite store: - -```bash -# CLI against the server's data dir or a running server -voicecat-admin account add # prompts for / generates a password -voicecat-admin account reset -voicecat-admin account del -voicecat-admin account list -``` - -…or from the **in-app admin interface** (a user with the admin permission), which sends the -privileged `CreateAccount`/`ResetPassword`/`DeleteAccount` control messages over TLS -(protocol.md §3). Guests need no provisioning; they just pick a nickname (if guests are -enabled). - -## 4. Why it stays this easy (design constraints that protect the goal) - -- **No external services.** No separate database, no Redis, no TURN/STUN server, no reverse - proxy required. SQLite is embedded; media is plain UDP. -- **No certificate chore.** Self-signed + Ed25519 TOFU means encryption needs zero operator - action. A domain owner *can* drop in a Let's Encrypt cert, but never *has* to. -- **One port pair.** TCP+UDP on the same number; one firewall/port-forward rule. -- **Static linking + permissive licenses.** The binary has no install-time dependencies and - can be redistributed (including closed-source) without copyleft obligations. -- **Agent-friendly.** The run command is a single line with no interactive prompts, the - server logs its fingerprint and listen address in machine-readable form, and `--help` / - `--print-config` expose everything an automation needs. Health endpoint for liveness checks. - -## 5. Operational niceties (planned, not blocking v1) - -- Signed, multi-architecture image publication. -- Graceful reload of `server.toml` on `SIGHUP`. -- `voicecat-admin` (see §3a) also handles bans and channel admin, talking to the same SQLite - file or a running server. -- Prebuilt images for `linux/amd64` + `linux/arm64` (Raspberry Pi / cheap VPS friendly). diff --git a/docs/ios-deploy.md b/docs/ios-deploy.md index b8dc3de..03c6d0a 100644 --- a/docs/ios-deploy.md +++ b/docs/ios-deploy.md @@ -1,7 +1,7 @@ # Deploying the managed iOS app VoiceCat's supported iOS client is the .NET 10 UIKit application in -`clients/apple/dotnet/VoiceCat.iOS`. The checked-in scripts build its native Opus/RNNoise +`clients/apple/VoiceCat.iOS`. The checked-in scripts build its native Opus/RNNoise dependency, compile and sign the managed app and Swift ReplayKit extension, stage the bundle, install it, and launch it on a paired physical device. @@ -15,7 +15,7 @@ install it, and launch it on a paired physical device. List available devices: ```bash -clients/apple/dotnet/deploy-ios-device.sh --list +clients/apple/deploy-ios-device.sh --list ``` ## Build, install, and launch @@ -25,8 +25,8 @@ build systems, and Xcode needs the team to select the extension profile. ```bash export VOICECAT_DEVELOPMENT_TEAM=FJV8L966W4 -clients/apple/dotnet/build-ios-device.sh --configuration Debug -clients/apple/dotnet/deploy-ios-device.sh \ +clients/apple/build-ios-device.sh --configuration Debug +clients/apple/deploy-ios-device.sh \ --device "Talon’s iPhone" \ --configuration Debug \ --no-build @@ -61,12 +61,12 @@ account can otherwise make provisioning updates fail even though offline signing ### Stale CMake source path -After moving the native shim from `dotnet/native` to `native/media`, an old CMake cache can -report a source-directory mismatch. Move the generated directories aside and rebuild: +An old CMake cache can report a source-directory mismatch. Move the generated directories +aside and rebuild: ```bash -mv dotnet/artifacts/native-build-ios-arm64-cmake /tmp/ -mv dotnet/artifacts/native-build-iossimulator-arm64-cmake /tmp/ +mv artifacts/native-build-ios-arm64-cmake /tmp/ +mv artifacts/native-build-iossimulator-arm64-cmake /tmp/ ``` ### Locked restore reports changed runtime identifiers @@ -76,7 +76,7 @@ them after runtime changes, then verify that dependency versions did not change: ```bash /usr/local/share/dotnet/dotnet restore \ - clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj \ + clients/apple/VoiceCat.iOS/VoiceCat.iOS.csproj \ -p:VoiceCatIosStatic=true \ --force-evaluate ``` diff --git a/docs/protocol.md b/docs/protocol.md deleted file mode 100644 index 21f6853..0000000 --- a/docs/protocol.md +++ /dev/null @@ -1,357 +0,0 @@ -# Control Protocol - -The control plane runs over **TCP, wrapped in TLS 1.3**. It carries everything that is not -real-time media: handshake, authentication, channel/user/presence state, text chat, and -voice *signaling* (announcing that a media stream is starting/stopping). Real-time voice -travels separately over UDP — see [voice.md](voice.md). - -## 1. Framing - -Inside the TLS stream, messages are length-prefixed: - -``` -┌──────────────┬───────────────────────────────────────────────┐ -│ u32 length │ protobuf-encoded Envelope (length bytes) │ -│ (big-endian)│ │ -└──────────────┴───────────────────────────────────────────────┘ -``` - -- `length` is the byte count of the payload that follows (not including the 4 length - bytes). Hard cap (e.g. 16 MiB) to bound memory; oversized frame → protocol error + - disconnect. -- The payload is a single **`Envelope`** protobuf message. We do **not** add our own type - byte; the type is the `oneof` discriminator inside the Envelope, which keeps the framing - trivial and lets protobuf own all forward/backward compatibility. - -TLS already provides record framing, integrity, and ordering; we only add message -boundaries on top of the TLS byte stream. - -## 2. Why Protocol Buffers for control - -- Schema-driven codegen for the supported **C#** implementation → no hand-rolled - parsers, no drift between client and server. -- **Forward/backward compatible by construction**: unknown fields are preserved/ignored, - new fields and new `oneof` arms are additive. This is exactly the "extensible protocol" - requirement. -- Compact enough for a control plane (text/state, not media). We use **`oneof`** envelopes - rather than `Any` so the wire stays tight and the switch is exhaustive. - -> Media frames do **not** use protobuf — they use a fixed binary header (see voice.md), -> because per-packet protobuf overhead and allocation are unacceptable on the RT path. - -## 3. The Envelope - -```proto -syntax = "proto3"; -package voicecat.v1; - -message Envelope { - // Monotonic per-connection id set by the sender of a request; echoed in the - // matching response so async callers can correlate. 0 for unsolicited events. - uint64 request_id = 1; - - oneof body { - // ── Session / handshake ─────────────────────────────── - ClientHello client_hello = 10; - ServerHello server_hello = 11; - AuthRequest auth_request = 12; - AuthResult auth_result = 13; - Disconnect disconnect = 14; - Ping ping = 15; - Pong pong = 16; - - // ── State sync ──────────────────────────────────────── - ServerStateSnapshot server_state = 20; - ChannelEvent channel_event = 21; // created/updated/deleted - UserEvent user_event = 22; // joined/left/updated - SubscribeRequest subscribe = 23; - - // ── Channel operations ──────────────────────────────── - JoinChannelRequest join_channel = 30; - JoinChannelResult join_channel_result= 31; - LeaveChannelRequest leave_channel = 32; - CreateChannelRequest create_channel = 33; - EditChannelRequest edit_channel = 34; - DeleteChannelRequest delete_channel = 35; - MoveUserRequest move_user = 36; - GenericResult generic_result = 37; // ack/err for the above - - // ── Voice signaling (media is on UDP) ───────────────── - StreamAnnounce stream_announce = 40; - StreamAnnounceResult stream_announce_result = 41; - StreamStop stream_stop = 42; - StreamStateUpdate stream_state = 43; // talking/muted indicator - UdpBinding udp_binding = 44; // token to bind the UDP 5-tuple - SubscribeVoiceRequest subscribe_voice = 45; // join the voice plane - UnsubscribeVoiceRequest unsubscribe_voice = 46; // leave the voice plane - VoiceSubscriptionResult voice_subscription_result = 47; // ack with subscribed flag - - // ── Text ────────────────────────────────────────────── - TextMessage text_message = 50; - TextMessageAck text_message_ack = 51; - TypingIndicator typing = 52; - - // ── Moderation / permissions ────────────────────────── - KickRequest kick = 60; - BanRequest ban = 61; - SetPermissionRequest set_permission = 62; - ServerMuteRequest server_mute = 63; - - // ── Admin account management (privileged) ───────────── - // Accounts are admin-provisioned (no self-serve registration in v1). - // These ride the same TLS control channel and require an admin permission. - CreateAccountRequest create_account = 70; - ResetPasswordRequest reset_password = 71; - DeleteAccountRequest delete_account = 72; - ListAccountsRequest list_accounts = 73; - ListAccountsResult list_accounts_result = 74; - - // ── Extension escape hatch ──────────────────────────── - Extension extension = 200; // {string ns; bytes payload;} - } -} -``` - -Reserved tag ranges keep future families from colliding: **10–19** session, **20–29** state, -**30–39** channels, **40–49** voice signaling, **50–59** text, **60–99** moderation, -**100–199** future (e.g. file transfer = 100–109), **200+** extensions. - -## 4. Connection lifecycle - -``` -Client Server - │ TCP connect ───────────────────────────────────▶│ - │ ◀──────────────── TLS 1.3 handshake ────────────▶│ server cert (TOFU/PKI, see security.md) - │ │ - │ ClientHello (proto_version, features[], info) ──▶│ - │ ◀── ServerHello (proto_version, features[], │ feature intersection negotiated here - │ server_info, auth_methods, udp_port) │ - │ │ - │ AuthRequest (guest{nick} | user{name,pass}) ────▶│ password verified w/ Argon2id - │ ◀── AuthResult (ok, session_id, self, perms, │ - │ udp_token) │ - │ │ - │ ◀── ServerStateSnapshot (channel tree, users) ───│ initial sync - │ │ - │ UdpBinding(udp_token) [TCP/TLS] ─────────────────▶│ confirms token, no-ops if mismatched - │ ◀── UdpBinding(ack=true) [TCP/TLS] ───────────────│ - │ │ - │ ===== UDP side (parallel) ===================== │ - │ (media keys derived from TLS exporter — no 2nd │ - │ handshake; see security.md §2) │ - │ UDP_BINDING frame(udp_token) [plaintext] ───────▶│ binds 5-tuple → session_id - │ ── voice frames (AEAD, exported keys) ──────────▶│ - │ │ - │ JoinChannelRequest(id, password?) ──────────────▶│ - │ ◀── JoinChannelResult(ok, members, audio_cfg) ───│ - │ StreamAnnounce(kind=mic, opus_params) ──────────▶│ - │ ◀── StreamAnnounceResult(ok, ssrc) │ - │ ── voice frames flow over UDP ──────────────────▶│ - │ │ - │ Ping / Pong (TCP keepalive) ◀──────────────────▶│ -``` - -Notes: - -- **Version negotiation.** Each side sends `proto_version` (integer) and a `features` - string list. The effective version is `min(client, server)`; the effective feature set - is the intersection. A client that doesn't understand a feature simply never uses it. - The **current `proto_version` is 2**. v2 widened the UDP voice frame `seq` field from - u16 to u64 (voice.md §2) — a wire-format change with no backward compatibility on the - media path, so the server rejects any peer not on v2 rather than min-negotiating down. -- **Auth over TLS.** Passwords cross the wire only inside TLS 1.3 and are verified against - an Argon2id hash at rest (see security.md). `auth_methods` in `ServerHello` advertises - whether `guest` is enabled. -- **UDP token.** `AuthResult.udp_token` is a short-lived opaque token. The client confirms it - over TCP/TLS (`UdpBinding` request/ack) and also sends it as the payload of a plaintext - `UDP_BINDING`-type media frame so the server can bind the UDP 5-tuple to the authenticated - session without trusting the source address. This bootstrap frame is the only UDP message - that carries identity material in the clear; everything after (voice frames) is AEAD-sealed - and routed purely by the bound tuple + media-AEAD session. -- **Snapshot then deltas.** After auth the server pushes a `ServerStateSnapshot` (full - channel tree + visible users), then streams incremental `ChannelEvent`/`UserEvent` - deltas. Clients reconcile by id. - -## 5. Message catalog (selected definitions) - -Representative messages; the full `.proto` is the source of truth in `core/proto/`. - -```proto -message ClientHello { - uint32 proto_version = 1; - repeated string features = 2; // e.g. "opus", "fec", "screen-audio" - string client_name = 3; // "VoiceCat-macOS" - string client_version = 4; - string preferred_locale = 5; -} - -message ServerHello { - uint32 proto_version = 1; - repeated string features = 2; - string server_name = 3; - string server_version = 4; - repeated string auth_methods = 5; // "guest", "password" - uint32 udp_port = 6; - bytes server_identity_fingerprint = 7; // Ed25519 key fp for TOFU display -} - -message AuthRequest { - oneof method { - GuestAuth guest = 1; // { string nickname; } - PasswordAuth password = 2; // { string username; string password; } - } -} - -message AuthResult { - bool ok = 1; - string error = 2; - uint64 session_id = 3; - User self = 4; - Permissions permissions = 5; - bytes udp_token = 6; // bind UDP 5-tuple with this -} - -message Channel { - uint32 id = 1; - uint32 parent_id = 2; // 0 = root - string name = 3; - string topic = 4; - bool password_protected = 5; - uint32 max_users = 6; - ChannelType type = 7; // PERMANENT / TEMPORARY - AudioConfig audio = 8; // per-channel Opus settings (see voice.md) - int32 order = 9; -} - -message User { - uint32 id = 1; - string nickname = 2; - bool is_guest = 3; - uint32 channel_id = 4; - bool self_mic_muted = 5; - bool self_deafened = 6; - bool server_muted = 7; - repeated StreamInfo streams = 8; // active media streams this user publishes - bool server_deafened = 9; // M5: server-imposed deafen -} - -message StreamInfo { - uint32 stream_id = 1; // unique within the user - uint32 ssrc = 2; // media-plane id assigned by server - StreamKind kind = 3; // MIC / SCREEN_AUDIO / AUX_DEVICE - AudioConfig audio = 4; - string label = 5; // "Microphone", "Desktop audio" -} - -message StreamAnnounce { // client → server: "I'm about to publish media" - StreamKind kind = 1; - AudioConfig requested_audio = 2; // server may clamp to channel policy - string label = 3; -} -message StreamAnnounceResult { - bool ok = 1; string error = 2; - uint32 stream_id = 3; uint32 ssrc = 4; - AudioConfig effective_audio = 5; // authoritative params to encode with -} - -message TextMessage { - TextScope scope = 1; // CHANNEL / PRIVATE / SERVER - uint32 target_id = 2; // channel_id or user_id depending on scope - uint32 sender_id = 3; // set by server on relay - string body = 4; // UTF-8, server-bounded length - uint64 sent_at_unix_ms = 5; // server timestamp on relay - string client_msg_id = 6; // client-chosen, echoed in ack (dedup) -} -``` - -> **Text is ephemeral (v1).** The server relays messages live to currently-connected, -> subscribed recipients and **does not persist history** — there is no store and no backfill -> on join. Clients may keep their own local scrollback for the session. Server-side history -> is a deliberate non-feature for now (it can be added later behind a capability flag without -> changing `TextMessage`). - -## 6. Request / response & errors - -- Any message a client expects a direct answer to sets a nonzero **`request_id`**; the - server echoes it in the response (`*Result` or `GenericResult`). Unsolicited - server→client events use `request_id = 0`. -- **`GenericResult { bool ok; uint32 code; string message; }`** is the default - acknowledgement for operations without a richer reply (create/edit/delete channel, move - user, kick, ban, server-mute, set-permission, create/reset/delete account). Error `code`s - are an enumerated, stable list. -- Fatal conditions send **`Disconnect { code; reason }`** then close the TLS connection. - `code ≥ 1` is server-sent (1 = protocol error, 2 = kicked). `code = 0` is client-sent - graceful disconnect (§7): the server broadcasts `UserEvent::LEFT` and closes immediately. -- **The response is for the request; the broadcast is for the state.** A `*Result` only - acknowledges the actor's request (correlation via `request_id`, error text, and any - actor-private payload — e.g. the channel `AudioConfig` in `JoinChannelResult`). The - resulting *state change* is delivered to **every** connected client **including the actor** - via the normal `UserEvent` / `ChannelEvent` / relayed `TextMessage` path. Clients apply - those events to their local model and never re-derive their own state from a `*Result` - (doing so drifts: the actor would miss its own change and a later event for another user - would surface the stale value). - -## 7. Keepalive & timeouts - -- **TCP:** `Ping`/`Pong` every ~15 s; missing 3 consecutive pongs (45 s) → the server's - reaper drops the session. `Pong` echoes the `Ping` nonce so RTT is measurable. The - client sends `Ping` automatically from its io thread; the server answers with `Pong` - in any state. -- **`last_seen` reaper.** Every `ConnSession` tracks `last_seen` — bumped on *any* - inbound TCP frame (not just `Ping`) and on any inbound UDP voice/keepalive frame. A - periodic sweep (`asio::steady_timer`, every 15 s) drops sessions whose `last_seen` is - older than 45 s. Each drop calls `close()`, which broadcasts `UserEvent::LEFT` to - remaining clients — so half-open connections (NAT timeout, wifi loss without RST, - laptop sleep) that never produce a TCP EOF are cleaned up, and peers' audio engines - `remove_stream` and stop PLC. The timeout and sweep interval are configurable via - `server::Config::reaper_timeout_ms` / `reaper_sweep_ms` (set to 0 to disable). - The managed server uses `VoiceServerOptions.IdleTimeout` / `ReaperInterval` with the - same 45-second / 15-second defaults (zero idle timeout disables reaping). It refreshes - activity on parsed control envelopes, authenticated owned-stream voice, and exact - bound-endpoint keepalives; rejected media does not refresh activity. Timing is monotonic. -- **UDP:** a separate lightweight keepalive on the media channel (voice.md §6) keeps NAT - bindings alive and detects media-path failure independently of the control channel. -- **Graceful disconnect.** A client ending its session sends `Disconnect { code = 0; - reason }` before closing the socket. The server calls `close()` on receipt — - broadcasting `UserEvent::LEFT` immediately, without waiting for TCP EOF or the reaper. - The client's `vc_disconnect()` queues this message and waits for the io thread to flush - it before closing the socket. `code = 0` is reserved for client-initiated graceful - disconnect; server-sent fatal `Disconnect` uses `code ≥ 1` (1 = protocol error, - 2 = kicked). -- **Client reconnection is local policy.** The core reports transport loss but does not reconnect. - The iOS client snapshots the server, channel, voice subscription, mute, and deafen state, then - reconnects with exponential backoff capped at 30 seconds. `NWPathMonitor` proactively replaces - a live session when the active interface changes or becomes unavailable, avoiding the TCP - keepalive delay; same-interface refreshes are ignored. A user-initiated disconnect cancels the - retry task and path monitor. After authentication, the client rejoins the prior channel before - restoring voice and local mute/deafen state. - -## 8. Client-local features (no protocol changes) - -Some features are entirely client-side and involve no changes to the wire format: - -- **External PCM feed (`vc_stream_feed_pcm`)** — the caller supplies interleaved int16 PCM - that the core frames, encodes, and sends over the existing UDP media path. From the server - and peers' perspective the stream is indistinguishable from a hardware-captured stream. No - new messages, fields, or tags are needed. -- **PCM tap (`vc_set_pcm_sink`)** — receives decoded per-stream audio before hardware mixing. - Entirely local to the listener; no protocol traffic of any kind. - -These are noted here to prevent future contributors from looking for corresponding protocol -changes: there are none. - -## 9. Extensibility checklist - -When adding a feature later (e.g. **file transfer**), the rules are: - -1. Add new `oneof` arms in the reserved tag range (file transfer = 100–109) — never reuse - or renumber existing tags. -2. Advertise a feature string in `ClientHello`/`ServerHello`; only use the feature if both - peers list it. -3. Prefer extending an existing message with new fields (additive) over inventing a new - message where it fits. -4. For experimental/out-of-tree features, ride inside `Extension { ns; payload }` until it - is promoted to a first-class `oneof` arm. - -This guarantees a v1 client and a v3 server interoperate at the negotiated lowest common -denominator. diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index e812a0a..0000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,33 +0,0 @@ -# Roadmap - -The managed rewrite is functionally complete. Work is now release validation, retirement of -the old implementation, and focused product development. - -## Release gates - -- Windows: published-client smoke test, NVDA navigation, and sustained real calls. -- macOS: VoiceOver matrix, multi-person call, Developer ID signing, notarization, Gatekeeper. -- iOS: physical-device VoiceOver, background/lock, interruption and route recovery, Bluetooth, - ReplayKit on iOS 18–26, and ScreenCaptureKit audio on iOS 27+. -- Server: build and run the container, validate graceful shutdown, and complete a 30-minute or - longer concurrent text/voice soak. - -## Cleanup - -- Remove the retired C++ core, server, CLI, tests, CMake presets, and vcpkg tree. -- Remove the old Swift macOS/iOS applications after retained assets are detached. -- Keep `native/media`, `native/rnnoise`, and `native/apple/broadcast`. -- Remove the old Windows P/Invoke project after shared model types are moved into the managed - compatibility facade. -- Continue reducing historical documentation to current contracts and operating instructions. - -## Deferred product features - -- file transfer -- end-to-end media encryption beyond the server-terminated transport encryption -- persistent server-side text history -- key-based user identities -- multi-node/federated servers -- PushKit/CallKit incoming-call behavior - -These are not blockers for the current release. diff --git a/docs/security.md b/docs/security.md deleted file mode 100644 index 1f9a16a..0000000 --- a/docs/security.md +++ /dev/null @@ -1,195 +0,0 @@ -# Security Model - -Two encrypted transports: **TLS 1.3** on the TCP control channel, and an encrypted **UDP** -media channel. Plus server identity, authentication, accounts at rest, and anti-replay. - -> **Encryption is mandatory — there is no unencrypted mode.** The server has no plaintext -> listener, the client has no "insecure" option, and there is no config flag to turn either -> off. A connection is encrypted or it does not exist. This is a hard product rule, not a -> default. It is also *zero-config* (see §1): the server generates its own key/cert on first -> run, so "secured by default" never costs the operator a setup step. - -## 1. Control channel — TLS 1.3 (settled) - -- TCP control is wrapped in **TLS 1.3** (TLS 1.2 disabled). AEAD cipher suites only - (AES-128-GCM, AES-256-GCM, ChaCha20-Poly1305). X25519 key exchange. -- Library: **mbedTLS 3.6 LTS** — Apache-2.0 (permissive, fine for an eventual closed-source - distribution), TLS 1.3 client+server, and `mbedtls_ssl_export_keying_material()` for the - media path (§2). It also **static-links cleanly into a single self-host binary**, which is - a deliberate choice in service of the easy-deploy goal. (OpenSSL 3.x, also permissive - Apache-2.0, is a drop-in alternative behind the same internal interface.) -- **Zero-config TLS:** on first launch the server auto-generates a self-signed certificate - bound to a freshly generated **Ed25519 identity key** and persists both. The operator does - nothing. Clients pin the identity on first connect (TOFU, §1.1). A server *with* a domain - can drop in a CA cert later, but it is never required to be encrypted. -- All authentication and account material crosses the wire only inside this tunnel. - -### 1.1 Server identity — two modes - -Self-hosting means most servers won't have a CA-signed cert for a hostname. We support -both, advertised in `ServerHello`: - -1. **TOFU (Trust On First Use)** — default for hobby servers. On first connect the client - shows an identity dialog and, if accepted, pins the value locally. Subsequent connects - verify the pin silently; a changed value warns loudly (`MISMATCH`). -2. **PKI** — a server with a domain can use a normal CA-signed cert (e.g. Let's Encrypt); - clients validate the chain conventionally. TOFU pinning still applies on top. - -**What is actually pinned (M4 implementation):** the **TLS leaf certificate's SHA-256 -fingerprint** — verifiable directly from the TLS handshake before any application data is -trusted. The server also declares an Ed25519 identity fingerprint in `ServerHello`, but this -value is **display-only** and is *not* the value that is pinned or verified. Reason: the TLS -cert and the Ed25519 identity key are generated independently with no cryptographic binding -between them, so pinning the self-declared Ed25519 value (sent *inside* the channel being -trust-decided) would be circular — an attacker who impersonates the server at the TLS level -would supply whatever Ed25519 value they like. Pinning the TLS cert fingerprint is the only -value that is genuinely verifiable at the moment of trust decision. - -This is a known limitation of the current design. Closing it properly requires binding the -Ed25519 key into the TLS cert (e.g. as a SubjectAltName or extension), which is a planned -future improvement. Until then, clients display both values but gate on the cert fingerprint. - -**Managed rewrite checkpoint:** `dotnet/` uses nonblocking BouncyCastle TLS 1.3 and -captures directional exporters during handshake completion. Its client requires an -explicit leaf-fingerprint acceptance callback; PKI validation remains unimplemented. -New managed server certificates include the Ed25519 public key in SAN URI -`urn:voicecat:identity:ed25519:`. Existing pre-rewrite credentials -are imported unchanged. Verifying that URI against the declared ServerHello identity -is still deferred to the managed session layer; leaf-certificate TOFU remains the -trust gate. Missing members of a persisted credential set cause startup rejection -rather than automatic identity rotation. See [api-dotnet.md](api-dotnet.md). - -Client certificates are reserved for a future "key-based identity" option (see roadmap) but -are not required in v1. - -## 2. Media channel — UDP encryption (settled: exported-keys + AEAD) - -The UDP media path uses **TLS-exported keys + per-packet AEAD** (an SRTP-style design), -mandatory from the first build. This was chosen over DTLS after weighing two findings: - -> **Finding 1 — DTLS 1.3 (RFC 9147) is not in stable OpenSSL or mbedTLS.** It ships -> production-ready only in **wolfSSL**, which is **GPLv2-or-commercial** — disqualified, -> because the code will eventually be distributed in closed-source form (no GPL/LGPL deps). -> -> **Finding 2 — mbedTLS 3.6 LTS already exposes `mbedtls_ssl_export_keying_material()`** -> (RFC 5705 / RFC 8446 §7.5 exporter). So we can derive media keys from the existing TLS 1.3 -> control session with *zero* extra handshake and *zero* extra dependency. - -### How it works - -1. After the TLS 1.3 control handshake, both sides call the keying-material exporter with - label `"voicecat media v1"` and a one-byte context: `0x00` for client→server, - `0x01` for server→client. Each export yields a 32-byte directional media key. - No second handshake, no certificates on the UDP path — the UDP channel inherits the - authenticated, MITM-resistant TLS session's trust. -2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (managed platform crypto; - platform cryptography with a BouncyCastle fallback in .NET). -3. The full 20-byte header is AEAD **associated data**. The server authenticates/decrypts - inbound media and reseals for each recipient, replacing the sequence with that - recipient's next send counter. It forwards the encoded Opus bytes without decoding audio. - -This keeps the entire crypto surface on two permissive libraries (mbedTLS + libsodium), adds -no handshake latency to voice startup, and is small enough to audit fully. It is abstracted -behind a `MediaCrypto { seal(frame)->bytes; open(bytes)->frame }` interface, so a future -DTLS 1.3 backend could slot in later if a permissive implementation matures — but nothing in -the design depends on that. - -### Per-frame protections - -- **AEAD** (ChaCha20-Poly1305) over each voice frame — confidentiality + integrity. -- **Associated data:** all 20 header bytes remain visible and authenticated; the Opus - payload is encrypted and followed by a 16-byte tag. -- **Nonce discipline:** `nonce = four_zero_bytes ‖ counter_u64_big_endian`. Counters are - per directional session key, shared across its streams. Direction separation comes - from exporter contexts, not nonce bits. Automatic epoch rekeying is not implemented; - the .NET encryptor refuses counter exhaustion and requires a new session. -- **Anti-replay:** a 64-bit sliding-window replay filter keyed on the packet counter (à la - IPsec). The window is **advanced only after the AEAD tag verifies** (RFC 3711 §3.3 order: - replay-check → authenticate → update). The counter is read from the unauthenticated - header, so advancing the high-water mark *before* authentication would let a single - corrupted or forged packet jump it far ahead, after which every legitimate packet is - rejected as "too old" — a permanent denial of the whole stream. Failed-auth packets leave - the window untouched. Replays and out-of-window packets are dropped before decode. - -## 3. UDP session binding - -UDP packets are not individually authenticated to a *user* beyond the transport session. -Binding works as: - -1. `AuthResult.udp_token` (issued over TLS) is a random 16-byte token tied to the - authenticated session. The client confirms it with `UdpBinding` over TLS. -2. Protocol v2 bootstraps UDP with a **plaintext** `UDP_BINDING` packet: the 20-byte - binary header followed by the token. This is not a protobuf or an AEAD voice frame. -3. Server validates the token and binds the **5-tuple → session_id**. The managed server - accepts the first endpoint only; further bootstrap packets cannot replace it. - Endpoint changes require a new authenticated session. The token remains available - for TLS confirmation but cannot establish a second binding. Session removal retires - its endpoint, token and directional keys. Unsupported old releases permitted rebinding - with the same token; this differs in policy, not in the packet format. -4. Thereafter, frames are accepted only on that bound tuple; ssrcs are checked against the - streams the session announced. Source-address spoofing can't hijack a session because the - attacker lacks the media key. The bootstrap token is visible on UDP, so it is not - a substitute for AEAD authentication and SSRC ownership checks. Header-only keepalives - are echoed only for bound endpoints; they provide liveness, not authenticated content. - -## 4. Authentication & accounts (settled: guests + local accounts) - -- **Guests:** toggled by server config (`allow_guests`). A guest picks a nickname and joins; - no persistent identity. Nicknames are non-reserved and may be uniquified by the server. -- **Local accounts:** username + password, **admin-provisioned** (no self-serve registration - in v1). An admin creates/resets/deletes accounts either via the `voicecat-admin` CLI or the - in-app admin interface, which sends the privileged `CreateAccount`/`ResetPassword`/ - `DeleteAccount` messages (protocol.md §3, permission-gated). Stored in **SQLite**; passwords - hashed with **Argon2id** (via libsodium `crypto_pwhash`) using per-install-tuned memory/time - parameters; never stored or logged in plaintext. Verification runs on the worker pool (it's - deliberately slow) to avoid stalling the net thread. -- **Channel passwords:** hashed at rest with **BLAKE2b** (libsodium `crypto_generichash`) plus a per-channel salt. BLAKE2b is used instead of Argon2id here because channel-password checks happen on the net thread during `JoinChannelRequest`; a slow hash would block real-time message processing. The password itself still crosses the wire only inside TLS 1.3. -- **Brute-force defense:** per-IP and per-account rate limiting on auth attempts with - exponential backoff; configurable lockout. Generic `auth_request` failures return a - non-enumerating error ("invalid credentials") to avoid username probing. - -``` -accounts( id INTEGER PK, username TEXT UNIQUE, - pw_argon2id TEXT, -- encoded hash incl. params + salt - created_at, last_login, flags ) -bans( id, subject_type, subject, reason, expires_at, created_at ) -``` - -## 5. Permissions (scaffold for v1, enforced server-side) - -A `Permissions` set is attached to each session at auth time and is the *only* authority — -clients never self-grant. v1 needs a minimal set (join channel, send text, create temporary -channel, kick/move if moderator); the model is a role/flag bitset that the moderation -milestone expands. All privileged operations (`CreateChannel`, `Kick`, `Ban`, -`MoveUser`, server-mute) are checked against it server-side regardless of client UI. - -## 6. Threat model & non-goals - -**In scope:** -- Passive eavesdropping on either transport → defeated by TLS 1.3 (control) and the - exported-key AEAD (media). -- Active MITM on first connect → mitigated by TOFU pin + Ed25519 identity (user must verify - fingerprint out-of-band for the strongest guarantee). -- UDP source spoofing / session hijack → defeated by token binding + media-key secrecy + - anti-replay. -- Password theft at rest → mitigated by Argon2id; in transit → only inside TLS. - -**Explicit non-goals (v1):** -- **End-to-end encryption between users.** The server relays Opus and can see who talks to - whom; with the SFU relay it does *not* decode audio, but the media key is per - client↔server, not per pair. True E2EE (server can't read media) is a possible future - feature, not v1. -- Anonymity / metadata hiding. The server, by design, knows the channel graph. -- DoS resilience at scale beyond basic rate limiting and the bounded-frame guards. - -## 7. Crypto dependency summary - -Two libraries, both permissive (no GPL/LGPL), so a future closed-source distribution stays -clean: - -- **TLS 1.3:** **mbedTLS 3.6 LTS** (Apache-2.0). Control-channel TLS + the keying-material - exporter that seeds the media path. Static-links into a single binary. (OpenSSL 3.x, - Apache-2.0, is an interchangeable alternative.) -- **Primitives & password hashing:** **libsodium** (ISC) — Argon2id (account passwords), - ChaCha20-Poly1305 (the media AEAD), Ed25519 (server identity), X25519, secure RNG. All - non-TLS crypto goes through libsodium so we never hand-roll a primitive. diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 4bf152a..3d91bfb 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -14,7 +14,7 @@ leaf platform projects. | Persistence | Microsoft.Data.Sqlite | Server-local SQLite with explicit migrations | | Tests | xUnit | Unit, socket integration, allocation, CLI, and production-package checks | -NuGet packages and their lock files are committed. `dotnet/check-licenses.ps1` enforces the +NuGet packages and their lock files are committed. `scripts/check-licenses.ps1` enforces the permissive-license policy. ## Native media diff --git a/docs/voice.md b/docs/voice.md deleted file mode 100644 index 9cb744a..0000000 --- a/docs/voice.md +++ /dev/null @@ -1,468 +0,0 @@ -# Voice & Media - -Real-time audio runs over **UDP**, secured per [security.md](security.md). The control -channel (TCP/TLS) handles *signaling* — announcing streams, channel membership, talk state -— while UDP carries only the encoded audio frames. This split keeps media latency low and -independent of TCP head-of-line blocking. - -## 1. The multi-stream model - -A **user** publishes one or more **streams**. Each stream is an independent audio source -with its own encoder, its own `stream_id` (unique per user) and `ssrc` (media-plane id -assigned by the server), and is independently mutable/mutable at the receiver. - -``` - User "Alex" Receiver "Sam" - ┌────────────────────┐ ┌──────────────────────────┐ - │ mic → enc ───┼──ssrc 1001──▶ │ jitter(1001)→dec→┐ │ - │ desktop → enc ───┼──ssrc 1002──▶ │ jitter(1002)→dec→┤ │ - │ 2nd mic → enc ───┼──ssrc 1003──▶ │ jitter(1003)→dec→┴─mix──▶ out - └────────────────────┘ └──────────────────────────┘ -``` - -Stream **kinds** (v1): `MIC`, `SCREEN_AUDIO` (system/desktop audio for listening together), -`AUX_DEVICE` (a second capture device). Receivers can set, per incoming stream: **gain**, -**mute**, and **noise reduction** (see §10) — so Sam can turn down Alex's desktop audio -while keeping the mic, *and* independently apply noise suppression to a third user who has a -loud fan. The mixer sums all active streams from all users in the channel into the local -playback device. All of these receiver-side controls are **local to the listener** and carry -no protocol traffic. - -`SCREEN_AUDIO` capture is platform-specific and covered in §9 — it is supported on Windows, -macOS, **and iOS** (via a ReplayKit broadcast extension). - -## 2. Voice frame format (UDP payload, inside the media AEAD) - -A fixed binary header — no protobuf on the RT path. Multi-byte fields are big-endian. - -The header is **20 bytes** (protocol v2; v1 was 14 bytes with a u16 seq — see note below). - -``` - 0 1 2 3 4 5 6 7 8 ............ 15 -┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬───────────────┐ -│ type │flags │ codec │ ssrc (u32) │ seq (u64) ──▶ │ -├──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴───────────────┤ -│ ◀── seq (u64) ──┤ timestamp (u32 @48k) │ payload ... │ -└──────────────────┴────────────────────────────────────┴───────────────┘ - bytes [8..15] = seq (u64) [16..19] = timestamp (u32) - -type u8 1 = VOICE, 2 = KEEPALIVE, 3 = UDP_BINDING (handshake) -flags u8 bit0 marker (start of talkspurt) · bit1 FEC-present - bit2 DTX/comfort-noise · bit3 last-frame-before-stop -codec u16 0 = OPUS (room for future codecs) -ssrc u32 media-plane stream id. Client sends its own ssrc; the server - validates it against the bound session and relays unchanged. -seq u64 full monotonic send counter. This IS the AEAD nonce counter, so the - receiver derives the nonce directly from it — no rollover guessing. -timestamp u32 RTP-style sample clock @48 kHz; drives the jitter buffer -payload one Opus packet (the encoder's output for one frame) -``` - -> **Why u64 (protocol v2).** v1 carried only the low 16 bits of the counter and the -> receiver zero-extended them to rebuild the AEAD nonce. After 65,536 frames the seq -> wrapped, the reconstructed nonce diverged from the sealing nonce, and **every frame -> failed authentication permanently** (no rollover counter). v2 puts the full 64-bit -> counter on the wire so the nonce is always exact. A v2 server and a v1 client cannot -> interoperate; the `Hello` handshake rejects on `proto_version` mismatch. - -This is intentionally RTP-shaped (familiar semantics: ssrc/seq/timestamp) without RTP's -full machinery. The server authenticates/decrypts each incoming packet and reseals its -encoded Opus bytes for each recipient using that recipient's directional key and send -counter. SSRC, timestamp, flags, and codec pass through; sequence and ciphertext/tag change. -There is no server-side audio decoding or transcoding. - -### Why client-sends-ssrc is safe - -The UDP 5-tuple is bound to an authenticated session (protocol.md §4). The server checks -that the ssrc in each frame belongs to a stream that session announced; spoofed ssrcs are -dropped. So identity is anchored by the session binding + transport encryption, not by -trusting the header. - -## 3. Per-channel audio configuration - -Opus is configured **per channel** and pushed to clients in `JoinChannelResult.audio` / -`StreamAnnounceResult.effective_audio`. All members of a channel encode with mutually -decodable parameters. - -```proto -message AudioConfig { - uint32 codec = 1; // 0 = OPUS - ChannelMode mode = 2; // MONO / STEREO - uint32 sample_rate = 3; // 8000/12000/16000/24000/48000 (48000 recommended) - uint32 bitrate_bps = 4; // e.g. 24000 (speech) … 128000 (music/stereo) - uint32 frame_ms = 5; // 2.5/5/10/20/40/60 (20 default) - OpusApplication application = 6;// VOIP / AUDIO / LOWDELAY - bool fec = 7; // in-band forward error correction - uint32 expected_packet_loss = 8;// %, tunes FEC aggressiveness - bool dtx = 9; // discontinuous transmission (silence suppression) - uint32 complexity = 10; // 0..10 encoder complexity -} -``` - -Guidance baked into defaults / docs: - -- **Sample rate: always run Opus at 48 kHz internally.** Opus resamples internally anyway; - 48 kHz avoids surprises, and the whole audio stack (capture, `vc_stream_feed_pcm`, mixing, - playback) runs at 48 kHz. The per-channel `sample_rate` field is **channel-authoritative** - (not a client request) and does *not* change the codec/PCM clock — it caps the encoder's - audio bandwidth via `OPUS_SET_MAX_BANDWIDTH` (8000 → narrowband ~4 kHz, 16000 → wideband - ~8 kHz, 24000 → super-wideband ~12 kHz, 48000 → full ~20 kHz). This lets a low-bitrate room - shed out-of-band content while every endpoint keeps a single 48 kHz clock. Default **48000** - (full band). See `OpusEncoder::init` and `vc_client::opus_params_from_audio_config`. -- **Frame size: 20 ms default.** Smaller (10 ms) lowers latency at the cost of more - per-packet overhead and CPU; larger (40/60 ms) improves efficiency and loss resilience at - the cost of latency. Expose it per channel for "low-latency talk" vs "stable music" rooms. - The capture engine runs on a fixed 48 kHz / 20 ms clock (960-sample frames), so the send - path **reframes** each captured/fed block to the channel's `frame_ms` before encoding - (accumulating two 960-frames for a 40 ms channel, splitting each into two 480-frames for a - 10 ms channel, etc.). This keeps the hardware/`vc_stream_feed_pcm` contract a single 48 kHz - clock regardless of the channel's window — see `vc_client::on_capture_frame`. - The device/mixer quantum is not the Opus packet duration: managed send streams reframe it - into the channel's 5/10/20/40/60 ms packets, and receive streams decode at that duration - before slicing decoded PCM back into 20 ms mixer blocks. -- **Mode/bitrate:** speech channels → `MONO`, `VOIP`, 24–32 kbps, DTX on, FEC on. - Music/screen-audio channels → `STEREO`, `AUDIO`, 96–128 kbps, DTX off, FEC optional. -- **`application`:** `VOIP` for talk, `AUDIO` for music/screen-share, `LOWDELAY` for - monitoring use cases. - -## 4. Packet-loss resilience (Opus 1.6) - -Layered, all configurable per channel: - -1. **In-band FEC** — the encoder embeds a low-bitrate copy of the current frame in the - *next* packet (`OPUS_SET_INBAND_FEC`, redundancy scaled by `expected_packet_loss`). On a - loss, the receiver decodes that copy out of the next already-buffered packet with - `opus_decode(..., decode_fec=1)` — costing one frame of latency on recovery. Gated on the - per-stream `fec` flag; if the next packet carries no redundancy libopus yields PLC output, - so it is at worst a no-op relative to (2). -2. **PLC (packet loss concealment)** — decoder synthesizes a plausible frame for an - unrecovered loss; always on, free. The terminal fallback when neither DRED nor FEC applies. -3. **DTX** — sender stops transmitting during silence and sends sparse comfort-noise - updates; cuts bandwidth and is bandwidth-friendly on busy channels. -4. **DRED (Deep REDundancy, per-channel toggle)** — Opus 1.6's ML redundancy: the encoder - embeds 20 ms of acoustic features in every packet (`bool dred` in `AudioConfig`, off by - default). When a packet is lost, the receiver peeks at the next already-buffered packet, - parses its DRED extension (`opus_dred_parse`), and reconstructs the lost frame with - `opus_decoder_dred_decode` — producing significantly better audio than PLC comfort noise - for single-frame gaps. Heavier CPU on the encoder (~5–10 % at 24 kbps); minimal overhead - on the decoder (parse is a fast header check on non-DRED packets). - -When a frame is lost, `AudioEngine::on_playback` tries these recovery paths in quality order, -falling through on failure: **DRED → in-band FEC → PLC**. DRED and FEC both need the next -packet already buffered (one frame of look-ahead); when it has not arrived yet, recovery falls -straight through to PLC. - -## 5. Jitter buffer - -Each receiver keeps an **adaptive jitter buffer per ssrc** with **bounded-depth playout** -(`dotnet/src/VoiceCat.Audio/ReceiveStream.cs`). - -- Frames are inserted by `timestamp`; playback reads in order at the device callback rate. -- **The playout clock is always bounded against the stream's *leading edge* (newest buffered - frame), never re-synced to the oldest.** The managed playout clock is isolated from hardware - drift by the adaptive PCM ring, - while the sender omits VAD/PTT/DTX silence from its timestamps, so the two diverge across gaps - and late joins. Two corrections keep latency bounded: - - **(Re)seed to the leading edge** on first frame, on a talkspurt `marker`, or when the clock - has run past the newest frame (starved after silence). Playout retains the adaptive target; - when DRED/FEC is enabled that includes one codec frame of recovery look-ahead. - - **Frame-skip catch-up:** when the backlog grows past `target + hysteresis` (clock drift, - bursty arrival, reordering), fast-forward the clock to leave `target` buffered and drop the - now-stale frames. This is the downward force that prevents latency from ratcheting upward. -- `target` is an EWMA of arrival-gap variation measured in 48 kHz sample time. DRED or FEC - reserves one complete channel Opus frame of look-ahead, variation can raise the target to - 120 ms, and packet history is bounded to 500 ms. Limits are durations rather than packet - counts, so 5 ms and 60 ms channels receive the same policy. -- Late frames past the playout point are dropped; gaps are filled by DRED (if the next frame - arrived) or PLC. -- The `marker` flag (start of talkspurt) — set by the sender on the first frame after a - transmission gap — lets the buffer reseed cleanly after silence/DTX without accumulating drift. -- One late device callback does not end a talkspurt. Capture resets only after 200 ms of - continuous starvation, avoiding a marker/rebuffer cascade from an isolated scheduling miss. -- Diagnostics per stream: `packets_lost`, `duplicates`, `underruns`, `target_depth_ms`. - -``` - incoming (out of order) ──▶ [ reorder by ts | adaptive depth ] ──▶ Opus decode ──▶ mixer - ▲ - jitter estimate feeds depth -``` - -Capture and playback use an allocation-free adaptive PCM ring between the managed 20 ms clock -and the hardware clock. Linear-interpolation correction, limited to ±0.5%, holds the ring near -its target instead of periodically dropping a block or rendering silence as device clocks drift. -The local **Audio buffering** preset is 20, 40 (default), or 60 ms and is persisted per client. - -## 6. UDP keepalive & NAT - -- A `KEEPALIVE` (type 2) frame flows both directions on the media channel every ~5 s to - hold NAT bindings and measure media-path RTT/loss independent of TCP. The frame is - plaintext (20-byte header, no payload, no AEAD) — the server identifies the sender by - its already-verified UDP endpoint (established during the `UdpBinding` handshake). On - receipt the server bumps the sender's `last_seen` (so media activity defers the TCP - reaper independently of control-channel traffic) and echoes the frame back so the - client can measure media-path RTT. -- If the media path dies but TCP is alive, the client surfaces a "voice disconnected" - state and attempts UDP re-binding (re-derive media keys + fresh `UdpBinding`) without dropping - the control session. -- No ICE/STUN/TURN. The expectation matches TeamSpeak/Mumble: the **server** is reachable - (public IP or port-forward); **clients** sit behind NAT and initiate, so their bindings - are created by their outbound first packet. - -## 7. Talk-state signaling - -"Who is talking" can be derived two ways; we use both: - -- **Implicit:** presence of recent voice frames for an ssrc → that stream is "active". The - receiver drives talk indicators from the jitter buffer, so they're accurate and need no - extra messages. -- **Explicit (optional):** `StreamStateUpdate` on TCP for coarse UI state (muted, hold) and - for users not currently subscribed to the media. Server-side mute/deafen is authoritative - and always signaled on TCP. - -## 8. Capture/playback pipeline (inside the core) - -``` - mic device ─(miniaudio capture, 48k, mono)→ resample? → send-side VAD/PTT gate - → Opus encode → frame header → AEAD → UDP send - - screen audio ─(WASAPI loopback, 48k, mono or stereo per channel mode)→ Opus encode - → frame header → AEAD → UDP send - - UDP recv → AEAD open → parse header → jitter(ssrc) → Opus decode - → per-stream recv-side NS (optional, per user) → per-stream gain/mute - → mixer (sum all ssrc, stereo; mono streams upmixed L=R) → (miniaudio playback, - 48k, stereo) → device -``` - -- Capture and playback run on miniaudio's real-time callbacks (WASAPI / CoreAudio / ALSA). - Playback is genuinely stereo end-to-end. **Mic capture** is mono by default; **stereo mic - capture** is supported via `vc_set_capture_channels(stream_id, 2)` — when enabled, the - capture device opens in stereo (interleaved L/R). All native clients expose this as a - per-user toggle (iOS in Settings; the Windows and macOS desktop clients via a "Stereo - microphone" checkbox in Audio settings — a live toggle there restarts the capture device via - `vc_audio_restart` so it takes effect immediately). Whether stereo actually reaches the wire - depends on the **channel's** Opus mode, which decides the encoder's channel count — the mic's - channel count and the channel's mode are independent knobs: - - **stereo mic + stereo channel** → real interleaved L/R is encoded directly (no upmix). - - **mono mic + stereo channel** → the mono frame is upmixed L=R so the Opus bitstream is - still spec-correct stereo. - - **stereo mic + mono channel** → the interleaved L/R is folded to mono before the mono - encoder. (Handing interleaved pairs straight to a mono `opus_encode` would make it read 2× - the samples it should — wrong pitch / garbage — so the fold keeps the toggle safe on any - channel.) - **Screen-audio (`SCREEN_AUDIO`) loopback** captures - in the channel's mode — stereo when the channel is stereo (real interleaved L/R, no - downmix), mono when the channel is mono — so a stereo music/screen-share channel gets - genuine stereo end-to-end. See §9 for the platform-specific loopback mechanism. -- **iOS audio — one path, always external.** On iOS the core **never opens a miniaudio device**: - a single `AVAudioEngine` (`IOSAudioEngine`) drives *both* directions, and the core runs fully - external for the whole connection. This is the single most important property of the iOS audio - stack — there is no second (miniaudio) path to switch to, so a preset/route change cannot leave - one direction dropped. The single ordering rule is: `vc_set_external_playback(1)` is set **once - at connect** (before the session is activated or any remote stream arrives), and every MIC - stream is started with `vc_stream_desc.external_feed=1`. - - **core → speaker:** the core's mixer-timer thread decodes+mixes on a ~20 ms cadence and - delivers the FINAL mixed PCM via `vc_set_mixed_output_sink`; an `AVAudioSourceNode` pulls it - from a lock-free ring and renders it. This runs the whole time we are connected, so remote - audio plays even before the user joins voice (kills the "can't hear anyone" race). - - **mic → core:** when the mic is active a tap on the engine's input node converts to 48 kHz - int16 (`vc_set_capture_channels` decides mono/stereo) and calls `vc_stream_feed_pcm`. -- **iOS routing** is still driven from Swift via `AVAudioSession` by the `IOSAudioRouter` - singleton — miniaudio never touches `AVAudioSession` on iOS. Input port selection - (`availableInputs`), built-in mic orientation (`setPreferredDataSource`: front/back/top/bottom), - polar patterns (`setPreferredPolarPattern`: omni/cardioid/subcardioid/bidirectional), mic - processing mode (Standard vs `.measurement` Raw), Bluetooth mode (`.allowBluetoothHFP` HFP voice - vs `.allowBluetoothA2DP` stereo output vs neither), and stereo capture (`.stereo` polar pattern - + `setPreferredInput` + `setInputDataSource` → `vc_set_capture_channels`) are all set from - Swift. Any preset / route / interruption change funnels through one deterministic, Swift-only - rebuild: `IOSAudioEngine` stops, `IOSAudioRouter.applyConfiguration()` re-applies the - `AVAudioSession`, the graph is rebuilt against the new route, and the engine restarts. No - `vc_audio_restart`/`vc_audio_suspend` dance is needed for routing (the core has no hardware - devices to reopen) — this is the spirit of TeamTalk5's "close then re-init sound devices", but - entirely inside the Swift engine. -- **iOS voice processing (AEC/NS/AGC) — native VPIO.** Real iOS echo cancellation, noise - suppression and AGC come ONLY from Apple's **Voice-Processing I/O audio unit (VPIO)**, which - `inputNode.setVoiceProcessingEnabled(true)` enables; for it to cancel echo it must own BOTH the - mic capture and the playback — which the unified engine already does. VPIO forces **mono**, so - it is engaged only when the active config wants it (`IOSAudioRouter.currentConfigUsesVoiceProcessing`: - mono + standard + non-A2DP + the user's master toggle). iOS exposes no per-stage VPIO control, - so the Advanced UI offers exactly two switches: a master **Voice Processing** (AEC + NS bundled) - and **AGC** (`isVoiceProcessingAGCEnabled`). -- **iOS presets** (`IOSAudioRouter.AudioPreset`): **Voice Chat** (VPIO mono, system output incl. - HFP/wired), **Stereo Mic** (internal stereo built-in mic regardless of output, A2DP-capable, no - VPIO), **Mono Mic** (internal mono built-in mic regardless of output, A2DP-capable, no VPIO), - and **Advanced** (every knob manual). A2DP output requires an internal-mic preset (the Bluetooth - device is output-only); the Stereo/Mono Mic presets fall back to the built-in speaker when no - external output is connected (`applyA2dpSpeakerFallback`). -- **iOS implementation invariants:** - - External playback is enabled before connecting because authentication can start the audio - engine before the UI receives another turn. - - AVAudioEngine callbacks may contain multiple codec frames. The mic path writes them to an - SPSC ring and releases complete 20 ms frames at a steady cadence; it never consumes a partial - frame, and the pacer is recreated when mono/stereo capture changes. - - `AVAudioSession` setters can synchronously emit route-change notifications. Configuration is - re-entrancy guarded, and recovery ignores `.categoryChange`, `.routeConfigurationChange`, and - `.override` because those reasons are generated by the app's own routing calls. External route - changes and engine-configuration notifications still rebuild the graph. - - Stereo capture anchors the built-in mic's stereo data source. It does not call - `setPreferredInputNumberOfChannels(2)`, which can disrupt A2DP output; the core receives the - channel count through `vc_set_capture_channels`. - - The A2DP speaker fallback caches its last output override. Reapplying the same override would - emit another `.override` notification and recursively trigger recovery. -- **DSP engine: see §11.** The original plan was `webrtc-audio-processing` (AEC + NS + AGC + - VAD in one tuned module, BSD-licensed) — but it has no working Windows/MSVC build upstream - (confirmed via its own issue tracker: GCC-only Meson build, MinGW support unfinished, hard - `abseil-cpp` dependency, Linux-tested only — - [gitlab.freedesktop.org/pulseaudio/webrtc-audio-processing#1](https://gitlab.freedesktop.org/pulseaudio/webrtc-audio-processing/-/issues/1)). - v1 ships a lightweight, dependency-free energy/RMS VAD instead (§11); there is **no AEC, NS, - or AGC implementation at all yet** — not just a deferred VAD, the whole APM is unbuilt. Real - `webrtc-audio-processing` stays a tracked future swap, behind the same `ApmProcessor` - interface (`core/src/audio/apm_processor.h`), revisit if/when a Linux build target exists or - upstream Windows support matures. -- The mixer sums decoded streams; clipping is handled by soft limiting on the master bus. - -## 10. Noise reduction — two-sided - -Noise reduction can be applied **at the sender, at the listener, or both** — they are -independent. - -- **Sender-side** (the talker's choice): the publishing client runs noise suppression on its - mic before the input gain and the VAD/PTT gate, controlled by that user's own settings - (`vc_set_input_noise_reduction`). This cleans the signal for *everyone* in one pass and helps - bitrate/VAD. MIC stream only. -- **Listener-side, per user** (the listener's choice): on the receive path, *after* decoding - each stream and *before* mixing, the listener can enable an **additional** NS pass on a - **specific** sender's stream (`vc_set_remote_stream(..., noise_reduction)`). So even if Alex - chose not to denoise his mic, Sam can locally suppress Alex's background noise without - affecting how anyone else hears Alex. - -**Backend: RNNoise** (vendored in [`native/rnnoise/`](../native/rnnoise), BSD-3 + CC0). -The original plan was WebRTC's APM, but `webrtc-audio-processing` has no working Windows/MSVC -build (see §8). RNNoise is a small, dependency-free C library — a hybrid DSP/RNN speech denoiser -that runs ~60× faster than real time. Both NR paths share one `ApmProcessor` implementation -(`RnnoiseProcessor`, `core/src/audio/apm_processor.cpp`), selected by `ApmProcessor::create()` -when the core is built with `VOICECAT_HAS_NS` (a no-op `ApmPassthrough` otherwise). Allocation -happens at construction; `process_capture()` runs lock-free on the RT thread (architecture.md §3). - -RNNoise is a **mono, 48 kHz, 480-sample (10 ms)** denoiser. Our engine clock is fixed at 48 kHz -and every Opus frame size (480/960/1920/2880) is a multiple of 480, so frames are processed as -whole 480-sample chunks with no resampling. Because it's mono-only: -- **Send-side:** a stereo mic is downmixed to mono **only when NR is enabled** — with NR off a - stereo mic keeps full stereo (we never collapse mic quality unless asked). -- **Receive-side:** NR applies to **voice (MIC) streams only**, gated on the stream *kind* — not - on its channel count, since a stereo mic with send-side NR off now arrives as stereo voice. - When enabled on such a stream the decoded stereo frame is folded to mono, denoised, and - duplicated back across both channels (symmetric with the send-side downmix), so that stream - plays as mono while NR is on. A **screen-audio share is never voice and is left untouched** — - denoising music/video with a speech denoiser would mangle it. - -Implementation: a per-`ssrc` NS instance (`RemoteStream::recv_ns`) on the receive path, -instantiated lazily only for streams the listener has flagged; the send-side instance -(`vc_client::mic_ns_`) is built once with the MIC stream and gated by an atomic flag so toggling -never allocates on the capture callback. State lives entirely on the local machine; toggling -either is a local UI action with **no protocol message** and no effect on other users. Because -each receive stream is decoded independently before the mixer (voice.md §1), per-user receive -NS is a clean drop-in on that per-stream stage. - -All three receive-side controls (gain, mute, NR) are queryable via `vc_get_remote_stream` — -the counterpart to `vc_set_remote_stream` — so a UI can reopen its per-stream mix controls at -the listener's actual current settings (defaults: gain 1.0, unmuted, NR off). Like the setter, -it carries no protocol traffic. - -## 11. Input activation — VAD and PTT (client-configurable) - -Whether the mic transmits is decided locally by the **input gate**, and the client supports -**both** modes, switchable per client (`vc_set_input_mode`): - -- **Voice activation (VAD):** v1 implements this as a lightweight, dependency-free - energy/RMS-threshold VAD (`EnergyVadProcessor`, `core/src/audio/apm_processor.cpp`) — no - external DSP dependency, since real `webrtc-audio-processing` has no working Windows/MSVC - build (see §8). It opens the gate when a frame's RMS exceeds a configurable threshold - (default ~0.025, normalized to int16 range), with a configurable hang-time (default 300 ms, - matching the talk-indicator hangover so "talking" and "gate open" agree) to avoid clipping - word tails. DTX naturally complements this — when the gate is closed nothing (or only - comfort noise) is sent. This implementation has **no AEC** — a real limitation versus the - originally-planned APM, not just a deferred VAD. -- **Push-to-talk (PTT):** `vc_set_push_to_talk(active)` opens/closes the gate directly. The UI - exposes a configurable keybind; the core just receives gate open/close. - -Gating applies to the **MIC stream only** — `SCREEN_AUDIO`/`AUX_DEVICE` always bypass it -(gating a desktop-audio share on the user's own voice activity would silently drop shared -music/video audio whenever the user isn't talking, which defeats the feature). - -This is purely a send-side, client-local concern — it gates what gets encoded and sent. It -needs **no protocol support**; remote talk indicators are still derived from the presence of -received frames (§7), so they work identically under VAD or PTT. - -## 9. System / screen audio capture (`SCREEN_AUDIO`) - -"Listen together" needs to capture the audio another app is playing. The capture mechanism -differs per OS, but it always feeds the **same** Opus-encode → media-AEAD → UDP path as a -normal stream; only the *source* is platform-specific. - -| Platform | Mechanism | Notes | -|----------|-----------|-------| -| **Windows** | **WASAPI loopback** (whole-device, via miniaudio) **or WASAPI process loopback** (`AUDIOCLIENT_ACTIVATION_PARAMS`, Win10 2004+) for per-app / self-exclude | **Implemented.** Default *entire desktop* uses miniaudio's whole-device loopback in the channel's mode — stereo (interleaved L/R) when the channel is stereo, mono when mono — so a stereo channel gets genuine stereo end-to-end (no downmix). It inherently captures this app's own incoming voice mix (self-echo). The **per-app modes and the "exclude VoiceCat's own audio" option** instead drive `ProcessLoopbackCapture` (process-specific INCLUDE/EXCLUDE) through the external-feed mixer (`vc_stream_feed_pcm`, `external_feed=1`), which avoids self-echo and supports true "everything except". See below. | -| **macOS** | **ScreenCaptureKit** system-audio capture (macOS 13+) | **Implemented** (`clients/apple/macOS/VoiceCatMac/Audio/ScreenAudioCapture.swift`). OS requires screen-recording permission; capture happens in the main app. An `SCStream` with `capturesAudio` + `excludesCurrentProcessAudio` delivers audio `CMSampleBuffer`s; Swift converts Float32 → int16 (in the channel's mono/stereo mode) and calls `vc_stream_feed_pcm` — no miniaudio loopback device involved (`VOICECAT_HAS_LOOPBACK` is Windows-only). **Supports per-app audio selection** — see below. | -| **iOS** | **ReplayKit Broadcast Upload Extension** (the Discord mechanism) | **Implemented.** See below — separate process, App Group, ~50 MB cap (fine for audio-only). ReplayKit only ever delivers the *mixed* system stream as `.audioApp`, so **per-app filtering / VoiceOver exclusion is not possible on iOS** (it has no per-app granularity, unlike ScreenCaptureKit). | - -### macOS detail — per-app audio selection - -ScreenCaptureKit filters audio at the **application** level, so before sharing starts the user -picks a scope in `ScreenSharePickerSheet` (`clients/apple/macOS/VoiceCatMac/Sheets/`): - -- **Everything** — whole display, the original behaviour (`SCContentFilter(display:excludingWindows:)`). -- **Only selected apps** — capture just the ticked apps (`init(display:including:exceptingWindows:)`). -- **All except selected apps** — capture everything but the ticked apps - (`init(display:excludingApplications:exceptingWindows:)`). - -A dedicated **"Exclude screen reader (VoiceOver) audio"** toggle merges the screen-reader -process(es) into the exclude set (`ScreenAudioCapture.screenReaderBundleIDs` — VoiceOver plus -the speech-synthesis daemon that actually renders the spoken audio). The chosen -`ScreenAudioSelection` is passed into `ScreenAudioCapture`, which builds the matching -`SCContentFilter`. iOS/ReplayKit has no equivalent control (see the table note above). - -### Windows detail — per-app audio selection and self-echo - -`AppAudioPickerDialog` (`clients/windows/VoiceCat.App/Forms/`) offers the same shape as macOS: - -- **Entire desktop** — whole-device miniaudio loopback handled by the core (default path). -- **Only selected apps** — one `ProcessLoopbackCapture` in **INCLUDE** mode per ticked app, - mixed by `ProcessAudioMixer` and fed via `vc_stream_feed_pcm`. -- **All apps except selected** — a **single** `ProcessLoopbackCapture` in **EXCLUDE** mode of - the chosen process tree. WASAPI's `AUDIOCLIENT_PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE` - captures the whole render mix minus that tree *dynamically* (apps launched after sharing - starts are included automatically). The activation params take a **single** target PID, so - exclude is restricted to **one** app — the picker enforces single-selection in this mode. - -An **"Exclude VoiceCat's own audio (prevents echo)"** checkbox (default on, enabled for -*entire desktop*) routes the desktop capture through the same EXCLUDE path targeting -VoiceCat's **own** process id (`Environment.ProcessId`) — i.e. "entire desktop except this -app" — which removes the self-echo loop the whole-device path otherwise has. The per-app -INCLUDE modes already never capture this app's tree, so they have no self-echo to remove. - -### iOS detail - -The extension **captures**, the host app **sends**. Unlike a self-connecting extension, this -keeps a **single session** — the screen-audio share appears as a second stream of the *same* -user (exactly like macOS/Windows), and no credentials are ever persisted to disk. - -- The user starts a broadcast from Control Center's screen-record button; we surface it via - `RPSystemBroadcastPickerView` from inside the app (`VoiceControlsView`) for one-tap start. -- The **Broadcast Upload Extension** (`native/apple/broadcast/SampleHandler.swift`) - receives `RPSampleBufferType.audioApp` (system/app audio), `.audioMic`, and `.video`. We - consume **`.audioApp`** only and drop video + mic — video is what blows the **~50 MB** - extension memory budget, so an audio-only consumer stays comfortably inside it. The extension - does **not** link the managed host or native media shim. -- The extension converts each chunk to the core's canonical format (48 kHz int16 stereo, via - `AVAudioConverter`) and writes it into a lock-free single-producer/single-consumer ring in a - shared **App Group** mmap'd file (`native/apple/broadcast/BroadcastAudioRing.swift`). It - posts Darwin notifications on start/stop so the host reacts promptly. -- The **host app** owns the stream: its `BroadcastAudioPump` announces the `SCREEN_AUDIO` - stream over the control channel (`StreamAnnounce`), drains the ring, and calls - `vc_stream_feed_pcm` (the external PCM feed API — see architecture.md §4) to drive the Opus - encode + AEAD + send path. It downmixes to mono when the channel's effective config is mono. -- Mic + voice also run in the host app. When the broadcast stops (`broadcastFinished`), the - extension clears the ring's active flag (and posts a Darwin notification); the host stops - feeding and emits `StreamStop`. The host must be alive to relay — always true while in a - call (the app declares the `audio` background mode). diff --git a/dotnet-tools.json b/dotnet-tools.json deleted file mode 100644 index 9e5fb58..0000000 --- a/dotnet-tools.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "powershell": { - "version": "7.6.6", - "commands": [ - "pwsh" - ], - "rollForward": false - } - } -} \ No newline at end of file diff --git a/dotnet/README.md b/dotnet/README.md deleted file mode 100644 index 732e583..0000000 --- a/dotnet/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# VoiceCat .NET implementation - -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. - -## Build and test - -Stage the small native Opus/RNNoise library, then build the managed solution: - -```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 -./dotnet/check-licenses.ps1 -``` - -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.Server -- --data-dir ./voicecat-data -dotnet run --project dotnet/src/VoiceCat.Cli -- \ - --host 127.0.0.1 --port 8384 --nickname Alice --trust-first -``` - -Use `--help` on either executable for current options. Server publishing is handled by -`dotnet/publish-server.ps1` and the platform packaging files under `packaging/`. - -## Compatibility policy - -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. diff --git a/dotnet/global.json b/global.json similarity index 100% rename from dotnet/global.json rename to global.json diff --git a/scripts/asc_api.py b/scripts/asc_api.py deleted file mode 100755 index e0ee20e..0000000 --- a/scripts/asc_api.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env python3 -"""asc_api.py — minimal App Store Connect API helper for ad-hoc device registration. - -Used by scripts/dist-ios-adhoc.sh to register friends' device UDIDs before building an -ad-hoc IPA. Talks to the App Store Connect API directly: signs an ES256 JWT with your -Team Key .p8 (via the `cryptography` package — no PyJWT/requests needed) and calls the -REST endpoints with urllib from the standard library. - -Subcommands: - register register one device UDID (idempotent — an already-registered UDID is OK) - list list all registered devices (and the count, against the 100/year cap) - -Auth is the same for both, supplied via flags (the wrapper script passes them from the -ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH env vars): - --key-id the Key ID of the App Store Connect API key - --issuer-id the Issuer ID (Users and Access -> Integrations) - --key path to the AuthKey_XXXXXXXXXX.p8 file - -Examples: - python3 scripts/asc_api.py list \ - --key-id ABC123 --issuer-id 11111111-2222-... --key ~/.appstoreconnect/AuthKey_ABC123.p8 - python3 scripts/asc_api.py register --udid 00008110-0011... --name "My iPhone" \ - --key-id ABC123 --issuer-id 11111111-2222-... --key ~/.appstoreconnect/AuthKey_ABC123.p8 - -The .p8 is created at App Store Connect -> Users and Access -> Integrations -> -App Store Connect API -> Team Keys, with Admin or App Manager access. Keep it out of the -repo. -""" - -import argparse -import base64 -import json -import sys -import time -import urllib.error -import urllib.request - -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec, utils - -API_BASE = "https://api.appstoreconnect.apple.com" - - -def _b64url(data: bytes) -> str: - """base64url without padding, as JWT requires.""" - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") - - -def make_jwt(key_id: str, issuer_id: str, key_path: str) -> str: - """Build a short-lived ES256 JWT for the App Store Connect API.""" - with open(key_path, "rb") as fh: - private_key = serialization.load_pem_private_key(fh.read(), password=None) - if not isinstance(private_key, ec.EllipticCurvePrivateKey): - sys.exit(f"error: {key_path} is not an EC private key (.p8 from App Store Connect)") - - now = int(time.time()) - header = {"alg": "ES256", "kid": key_id, "typ": "JWT"} - # exp must be <= 20 minutes out; 10 minutes is comfortable. - payload = {"iss": issuer_id, "iat": now, "exp": now + 600, "aud": "appstoreconnect-v1"} - - signing_input = f"{_b64url(json.dumps(header).encode())}.{_b64url(json.dumps(payload).encode())}" - der_sig = private_key.sign(signing_input.encode("ascii"), ec.ECDSA(hashes.SHA256())) - # JWS wants raw r||s (two 32-byte big-endian ints), not the ASN.1/DER openssl emits. - r, s = utils.decode_dss_signature(der_sig) - raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") - return f"{signing_input}.{_b64url(raw_sig)}" - - -def _request(method: str, path: str, token: str, body: dict | None = None): - """Perform an authenticated API call. Returns (status_code, parsed_json|None).""" - url = path if path.startswith("http") else f"{API_BASE}{path}" - data = json.dumps(body).encode() if body is not None else None - req = urllib.request.Request(url, data=data, method=method) - req.add_header("Authorization", f"Bearer {token}") - if data is not None: - req.add_header("Content-Type", "application/json") - try: - with urllib.request.urlopen(req) as resp: - raw = resp.read() - return resp.status, (json.loads(raw) if raw else None) - except urllib.error.HTTPError as exc: - raw = exc.read() - try: - parsed = json.loads(raw) if raw else None - except json.JSONDecodeError: - parsed = {"_raw": raw.decode("utf-8", "replace")} - return exc.code, parsed - - -def _errors_text(payload) -> str: - if isinstance(payload, dict) and payload.get("errors"): - return "; ".join( - f"{e.get('title', '')}: {e.get('detail', '')}".strip(": ") - for e in payload["errors"] - ) - return json.dumps(payload) - - -def cmd_register(args, token: str) -> int: - body = { - "data": { - "type": "devices", - "attributes": { - "name": args.name or args.udid, - "platform": "IOS", - "udid": args.udid, - }, - } - } - status, payload = _request("POST", "/v1/devices", token, body) - if status in (200, 201): - print(f" registered: {args.udid} ({args.name or args.udid})") - return 0 - - # A UDID that already exists comes back as a 409 conflict, or a 422 with an error - # detail mentioning the device already exists. Either way it's fine — idempotent. - text = _errors_text(payload) - if status == 409 or "already exist" in text.lower() or "already been taken" in text.lower(): - print(f" already registered: {args.udid}") - return 0 - - print(f"error: failed to register {args.udid} (HTTP {status}): {text}", file=sys.stderr) - return 1 - - -def cmd_list(args, token: str) -> int: - path = "/v1/devices?limit=200&sort=name" - rows = [] - while path: - status, payload = _request("GET", path, token) - if status != 200: - print(f"error: list failed (HTTP {status}): {_errors_text(payload)}", file=sys.stderr) - return 1 - for d in payload.get("data", []): - a = d.get("attributes", {}) - rows.append((a.get("platform", "?"), a.get("status", "?"), - a.get("udid", "?"), a.get("name", ""))) - path = (payload.get("links") or {}).get("next") - - ios = [r for r in rows if r[0] == "IOS"] - for platform, dev_status, udid, name in rows: - print(f" [{platform:7}] {dev_status:8} {udid} {name}") - print(f"\n {len(rows)} device(s) total, {len(ios)} iOS (cap is 100 iOS/membership year)") - return 0 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - sub = parser.add_subparsers(dest="command", required=True) - - def add_auth(p): - p.add_argument("--key-id", required=True) - p.add_argument("--issuer-id", required=True) - p.add_argument("--key", required=True, help="path to AuthKey_*.p8") - - p_reg = sub.add_parser("register", help="register one device UDID (idempotent)") - add_auth(p_reg) - p_reg.add_argument("--udid", required=True) - p_reg.add_argument("--name", default=None) - - p_list = sub.add_parser("list", help="list registered devices") - add_auth(p_list) - - args = parser.parse_args() - token = make_jwt(args.key_id, args.issuer_id, args.key) - if args.command == "register": - return cmd_register(args, token) - if args.command == "list": - return cmd_list(args, token) - return 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/build-all.sh b/scripts/build-all.sh deleted file mode 100755 index 0941421..0000000 --- a/scripts/build-all.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -# -# build-all.sh — build every artifact this host can produce and stage it under -# dist/. Convenience wrapper over the per-artifact scripts in scripts/. -# -# On macOS: server + libs (macOS slice + xcframework) + macos-client (.app) -# optionally: iOS client for simulator (pass --ios-client) -# On Windows: server + libs (windows DLL) + windows-client (.exe folder) -# On Linux: server only (no native client presets target Linux) -# -# Usage: -# scripts/build-all.sh # build everything host can, into ./dist -# scripts/build-all.sh --dist /out -# scripts/build-all.sh --no-configure # passed through to each step -# scripts/build-all.sh --skip-client # skip the GUI client(s) -# scripts/build-all.sh --skip-libs # skip the library artifacts -# scripts/build-all.sh --skip-server # skip the server -# scripts/build-all.sh --ios-client # also build iOS simulator client (macOS only) -# scripts/build-all.sh -h|--help -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VC_SCRIPT_NAME="build-all" -source "$SCRIPT_DIR/common.sh" - -PASS_ARGS=() -SKIP_CLIENT=false -SKIP_LIBS=false -SKIP_SERVER=false -BUILD_IOS_CLIENT=false -while [[ $# -gt 0 ]]; do - case "$1" in - --dist) PASS_ARGS+=( --dist "$2" ); vc_set_dist "$2"; shift 2 ;; - --no-configure) PASS_ARGS+=( --no-configure ); shift ;; - --skip-client) SKIP_CLIENT=true; shift ;; - --skip-libs) SKIP_LIBS=true; shift ;; - --skip-server) SKIP_SERVER=true; shift ;; - --ios-client) BUILD_IOS_CLIENT=true; shift ;; - -h|--help) vc_print_help "$0"; exit 0 ;; - *) vc_die "unknown arg: $1 (try --help)" ;; - esac -done - -HOST="$(vc_host_os)" -vc_log "host: $HOST dist: $VC_DIST_DIR" - -run() { - local name="$1"; shift - vc_step "→ $name $*" - "$SCRIPT_DIR/$name" "$@" "${PASS_ARGS[@]}" -} - -if ! $SKIP_SERVER; then - run build-server.sh -fi - -if ! $SKIP_LIBS; then - if [[ "$HOST" == "macos" ]]; then - run build-libs.sh --platform macos - elif [[ "$HOST" == "windows" ]]; then - run build-libs.sh --platform windows - fi -fi - -if ! $SKIP_CLIENT; then - if [[ "$HOST" == "macos" ]]; then - run build-macos-client.sh - $BUILD_IOS_CLIENT && run build-ios-client.sh - elif [[ "$HOST" == "windows" ]]; then - run build-windows-client.sh - fi -fi - -vc_ok "all done -> $VC_DIST_DIR" -vc_log "contents:" -( cd "$VC_DIST_DIR" && find . -maxdepth 3 | sort | sed 's/^/ /' ) || true diff --git a/scripts/build-ios-client.sh b/scripts/build-ios-client.sh deleted file mode 100755 index ecf24ef..0000000 --- a/scripts/build-ios-client.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash -# -# build-ios-client.sh — build the iOS SwiftUI client (VoiceCatiOS.app) for the -# iOS simulator and stage it into dist/ios-client/. -# -# Two steps: -# 1. Build VoiceCatCore.xcframework with all 3 slices (macOS + iOS device + -# iOS simulator) via clients/apple/scripts/build-xcframework.sh --all. -# The XCFramework must include the ios-arm64-simulator slice so the Swift -# Package binary target resolves when xcodebuild compiles the iOS app. -# 2. xcodebuild the VoiceCatiOS target against the iOS Simulator SDK, with -# SYMROOT and OBJROOT pointed at the same directory so the Swift Package -# and the app target find each other's build products. -# -# Usage: -# scripts/build-ios-client.sh # Debug sim build into ./dist -# scripts/build-ios-client.sh --dist /out -# scripts/build-ios-client.sh --no-configure # skip cmake configure (xcframework step) -# scripts/build-ios-client.sh --config Debug # Xcode config (default Debug) -# scripts/build-ios-client.sh -h|--help -# -# Output: -# clients/apple/iOS/build/Debug-iphonesimulator/VoiceCatiOS.app -# dist/ios-client/VoiceCatiOS.app -# -# To install and launch on a simulator after building, use: -# scripts/run-ios-simulator.sh -# -# macOS only. Requires VCPKG_ROOT and Xcode (with iOS Simulator runtime installed). -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VC_SCRIPT_NAME="build-ios-client" -source "$SCRIPT_DIR/common.sh" - -DO_CONFIGURE=true -XCODE_CONFIG="Debug" -while [[ $# -gt 0 ]]; do - case "$1" in - --dist) vc_set_dist "$2"; shift 2 ;; - --no-configure) DO_CONFIGURE=false; shift ;; - --config) XCODE_CONFIG="$2"; shift 2 ;; - -h|--help) vc_print_help "$0"; exit 0 ;; - *) vc_die "unknown arg: $1 (try --help)" ;; - esac -done - -vc_require_macos -vc_log "dist dir: $VC_DIST_DIR config: $XCODE_CONFIG" - -# ── Step 1: XCFramework (all slices) ────────────────────────────────────────── -# The iOS simulator build requires the ios-arm64-simulator slice, so we always -# build all three slices. --no-configure skips cmake configure but still runs -# cmake --build (which is fast with a warm build cache). -vc_step "build VoiceCatCore.xcframework (all slices: macOS + iOS device + iOS sim)" -XCFW_ARGS=( --all ) -$DO_CONFIGURE || XCFW_ARGS+=( --no-configure ) -"$VC_REPO_ROOT/clients/apple/scripts/build-xcframework.sh" "${XCFW_ARGS[@]}" -XCFW="$VC_REPO_ROOT/clients/apple/VoiceCatCore.xcframework" -[[ -d "$XCFW" ]] || vc_die "xcframework not found at $XCFW" -vc_ok "xcframework ready -> $XCFW" - -# ── Step 2: xcodebuild for iOS Simulator ────────────────────────────────────── -# We use -target (not -scheme + -destination) to avoid the requirement of a -# matching simulator runtime in xcrun simctl. When using -target, xcodebuild -# skips destination resolution and builds directly against the requested SDK. -# -# SYMROOT and OBJROOT are both pinned to the same directory so the Swift Package -# (VoiceCatCore) and the app target (VoiceCatiOS) resolve each other's products. -# Without this, the package builds to clients/apple/build/ while the app target -# looks in clients/apple/iOS/build/ — the module import fails with "unable to -# resolve module dependency: 'VoiceCatCore'". -# -# CODE_SIGNING_ALLOWED=NO avoids provisioning-profile errors for local sim builds. -XCODEPROJ="$VC_REPO_ROOT/clients/apple/iOS/VoiceCatiOS.xcodeproj" -BUILD_DIR="$VC_REPO_ROOT/clients/apple/iOS/build" -SIM_SDK_VER="$(xcrun --sdk iphonesimulator --show-sdk-version 2>/dev/null)" -SIM_SDK="iphonesimulator${SIM_SDK_VER}" -vc_step "xcodebuild VoiceCatiOS ($XCODE_CONFIG / $SIM_SDK)" -xcodebuild \ - -project "$XCODEPROJ" \ - -target VoiceCatiOS \ - -sdk "$SIM_SDK" \ - -configuration "$XCODE_CONFIG" \ - CODE_SIGNING_ALLOWED=NO \ - ARCHS=arm64 \ - ONLY_ACTIVE_ARCH=YES \ - SYMROOT="$BUILD_DIR" \ - OBJROOT="$BUILD_DIR" \ - build - -APP="$BUILD_DIR/${XCODE_CONFIG}-iphonesimulator/VoiceCatiOS.app" -[[ -d "$APP" ]] || vc_die "VoiceCatiOS.app not found at $APP" -vc_ok "built -> $APP" - -# ── Stage ───────────────────────────────────────────────────────────────────── -vc_step "stage -> $VC_DIST_DIR/ios-client" -STAGE="$(vc_dist_subdir ios-client)" -cp -R "$APP" "$STAGE/" -vc_ok "VoiceCatiOS.app -> $STAGE/ ($(du -sh "$APP" | cut -f1))" -vc_ok "done -> $STAGE" diff --git a/scripts/build-libs.sh b/scripts/build-libs.sh deleted file mode 100755 index 5001a0f..0000000 --- a/scripts/build-libs.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env bash -# -# build-libs.sh — build libvoicecat for each supported client platform and -# stage the artifacts under dist/lib/. -# -# Artifacts per platform: -# macos → dist/lib/macos/ libvoicecat.a + libvoicecat-fat.a + voicecat.h + module.modulemap -# ios → dist/lib/ios-device/ (arm64-ios slice) -# ios-sim → dist/lib/ios-sim/ (arm64-ios-sim slice) -# windows → dist/lib/windows/ voicecat.dll + voicecat.h -# xcframework → dist/lib/VoiceCatCore.xcframework/ (stitched Apple slices) -# -# The Apple slices + XCFramework are produced by clients/apple/scripts/ -# build-xcframework.sh (the validated flow); this script drives it and stages -# the outputs. The Windows DLL comes from the `windows-client` preset. -# -# Usage: -# scripts/build-libs.sh # host default (macos on macOS, windows on Windows) -# scripts/build-libs.sh --platform macos -# scripts/build-libs.sh --platform ios -# scripts/build-libs.sh --platform ios-sim -# scripts/build-libs.sh --platform windows -# scripts/build-libs.sh --all-apple # macos + ios + ios-sim + xcframework (macOS host) -# scripts/build-libs.sh --all # everything buildable on this host -# scripts/build-libs.sh --dist /out --no-configure -# scripts/build-libs.sh -h|--help -# -# Platform availability: macos / ios / ios-sim require a macOS host; windows -# requires a Windows host. Requires VCPKG_ROOT. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VC_SCRIPT_NAME="build-libs" -source "$SCRIPT_DIR/common.sh" - -PLATFORMS=() -DO_CONFIGURE=true -while [[ $# -gt 0 ]]; do - case "$1" in - --dist) vc_set_dist "$2"; shift 2 ;; - --no-configure) DO_CONFIGURE=false; shift ;; - --platform) PLATFORMS+=( "$2" ); shift 2 ;; - --all-apple) PLATFORMs_all_apple=1; shift ;; - --all) PLATFORMS=( all-host ); shift ;; - -h|--help) vc_print_help "$0"; exit 0 ;; - *) vc_die "unknown arg: $1 (try --help)" ;; - esac -done - -HOST="$(vc_host_os)" - -# --all-apple expands to the three Apple platforms. -if [[ -n "${PLATFORMs_all_apple:-}" ]]; then - PLATFORMS=( macos ios ios-sim ) -fi - -# Default: host-appropriate single platform. -if [[ ${#PLATFORMS[@]} -eq 0 ]]; then - case "$HOST" in - macos) PLATFORMS=( macos ) ;; - windows) PLATFORMS=( windows ) ;; - *) vc_die "no default library platform for host '$HOST'. Pass --platform explicitly." ;; - esac -fi - -# Expand the meta-target. -if [[ " ${PLATFORMS[*]} " == *" all-host "* ]]; then - PLATFORMS=() - case "$HOST" in - macos) PLATFORMS=( macos ios ios-sim ) ;; - windows) PLATFORMS=( windows ) ;; - *) vc_die "--all: nothing buildable on host '$HOST'." ;; - esac -fi - -vc_log "dist dir: $VC_DIST_DIR" -vc_log "platforms: ${PLATFORMS[*]}" - -# Validate platform availability against host. -for p in "${PLATFORMS[@]}"; do - case "$p" in - macos|ios|ios-sim) [[ "$HOST" == "macos" ]] || vc_die "platform '$p' requires a macOS host." ;; - windows) [[ "$HOST" == "windows" ]] || vc_die "platform '$p' requires a Windows host." ;; - *) vc_die "unknown platform '$p' (try --help)" ;; - esac -done - -HAVE_APPLE=false -HAVE_WINDOWS=false -for p in "${PLATFORMS[@]}"; do - case "$p" in - macos|ios|ios-sim) HAVE_APPLE=true ;; - windows) HAVE_WINDOWS=true ;; - esac -done - -# ── Apple slices + XCFramework ────────────────────────────────────────────────── -# Stage one slice's .a + headers + module map into dist/lib/. -stage_apple_slice() { - local preset="$1" sub="$2" - local lib="$VC_REPO_ROOT/build/$preset/lib/libvoicecat.a" - local fat="$VC_REPO_ROOT/build/$preset/lib/libvoicecat-fat.a" - local out; out="$(vc_dist_subdir "lib/$sub")" - [[ -f "$lib" ]] || vc_die "expected $lib not found (did the xcframework build run?)" - cp "$lib" "$out/" - [[ -f "$fat" ]] && cp "$fat" "$out/" || true - cp "$VC_REPO_ROOT/core/include/voicecat.h" "$out/" - cat > "$out/module.modulemap" <<'MM' -module VoiceCatC { - header "voicecat.h" - export * -} -MM - vc_ok "$sub: libvoicecat.a ($(vc_file_size "$lib") bytes) -> $out" -} - -if $HAVE_APPLE; then - vc_resolve_vcpkg_root apple-dev - vc_step "build Apple slices via build-xcframework.sh" - - ALL_THREE=false - if [[ " ${PLATFORMS[*]} " == *" macos "* && " ${PLATFORMS[*]} " == *" ios "* && " ${PLATFORMS[*]} " == *" ios-sim "* ]]; then - ALL_THREE=true - fi - - xcfw_invoke() { - local args=() - $DO_CONFIGURE || args+=( --no-configure ) - args+=( "$@" ) - "$VC_REPO_ROOT/clients/apple/scripts/build-xcframework.sh" "${args[@]}" - } - - if $ALL_THREE; then - xcfw_invoke --all - stage_apple_slice apple-dev macos - stage_apple_slice apple-ios ios-device - stage_apple_slice apple-ios-sim ios-sim - else - for p in "${PLATFORMS[@]}"; do - case "$p" in - macos) xcfw_invoke --preset apple-dev; stage_apple_slice apple-dev macos ;; - ios) xcfw_invoke --preset apple-ios; stage_apple_slice apple-ios ios-device ;; - ios-sim) xcfw_invoke --preset apple-ios-sim; stage_apple_slice apple-ios-sim ios-sim ;; - esac - done - fi - - # Stage the stitched XCFramework (whatever slices the last call produced). - XCFW="$VC_REPO_ROOT/clients/apple/VoiceCatCore.xcframework" - [[ -d "$XCFW" ]] || vc_die "xcframework not found at $XCFW" - LIB_DIR="$VC_DIST_DIR/lib" - mkdir -p "$LIB_DIR" - rm -rf "$LIB_DIR/VoiceCatCore.xcframework" - cp -R "$XCFW" "$LIB_DIR/" - vc_ok "VoiceCatCore.xcframework -> $LIB_DIR/" -fi - -# ── Windows DLL ───────────────────────────────────────────────────────────────── -if $HAVE_WINDOWS; then - vc_resolve_vcpkg_root windows-client - vc_step "build Windows DLL (preset: windows-client)" - if $DO_CONFIGURE; then - cmake --preset windows-client - fi - cmake --build --preset windows-client - DLL="$VC_REPO_ROOT/build/windows-client/bin/voicecat.dll" - [[ -f "$DLL" ]] || vc_die "expected $DLL not found" - OUT="$(vc_dist_subdir lib/windows)" - cp "$DLL" "$OUT/" - cp "$VC_REPO_ROOT/core/include/voicecat.h" "$OUT/" - vc_ok "windows: voicecat.dll ($(vc_file_size "$DLL") bytes) -> $OUT" -fi - -vc_ok "done -> $VC_DIST_DIR/lib" diff --git a/scripts/build-linux-binaries.sh b/scripts/build-linux-binaries.sh deleted file mode 100644 index 528bbaa..0000000 --- a/scripts/build-linux-binaries.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -# build-linux-binaries.sh — build stripped Linux server binaries locally using Docker. -# -# PRIMARY path: push to main (or trigger manually from the GitHub Actions tab) and -# download the artifacts from .github/workflows/build-linux.yml — no local disk -# pressure, both amd64 and arm64 handled in the cloud. -# -# LOCAL path (this script): uses Docker + buildx with BuildKit cache. Requires -# ~10–15 GB of free disk for the vcpkg build cache. Fine on a Linux dev machine; -# on Windows/macOS prefer the GitHub Actions path to avoid filling your Docker VM disk. -# -# Usage: -# ./scripts/build-linux-binaries.sh # build both arches -# ./scripts/build-linux-binaries.sh amd64 # build one arch only -# ./scripts/build-linux-binaries.sh arm64 -# -# Output: -# dist/linux-amd64/{voicecat-server,voicecat-admin} -# dist/linux-arm64/{voicecat-server,voicecat-admin} - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -DIST_DIR="${REPO_ROOT}/dist" - -# ── arg parsing ──────────────────────────────────────────────────────────────── -case "${1:-both}" in - amd64) ARCHS=("amd64") ;; - arm64) ARCHS=("arm64") ;; - both) ARCHS=("amd64" "arm64") ;; - *) echo "Usage: $0 [amd64|arm64|both]" >&2; exit 1 ;; -esac - -# ── pre-flight ───────────────────────────────────────────────────────────────── -if ! command -v docker &>/dev/null; then - echo "Error: docker not found." >&2 - echo "Install Docker Desktop: https://docs.docker.com/get-docker/" >&2 - echo "Or use GitHub Actions (push to main and download artifacts)." >&2 - exit 1 -fi - -if ! docker buildx version &>/dev/null; then - echo "Error: docker buildx not available." >&2 - echo "Docker Desktop ships with buildx. On Linux: docker buildx install" >&2 - exit 1 -fi - -echo "Note: first run compiles vcpkg packages from source (~10-15 GB build cache)." -echo "On Windows/macOS, consider using GitHub Actions instead (Actions → Build Linux Binaries → Run workflow)." -echo "" - -# ── ensure a builder with multi-platform support ─────────────────────────────── -BUILDER="voicecat-builder" -if ! docker buildx inspect "${BUILDER}" &>/dev/null; then - echo "→ Creating buildx builder '${BUILDER}' (docker-container driver)..." - docker buildx create --name "${BUILDER}" --driver docker-container --bootstrap -fi -docker buildx use "${BUILDER}" - -# ── build each arch ──────────────────────────────────────────────────────────── -for ARCH in "${ARCHS[@]}"; do - PLATFORM="linux/${ARCH}" - OUT_DIR="${DIST_DIR}/linux-${ARCH}" - mkdir -p "${OUT_DIR}" - - echo "══ Building ${PLATFORM} ══════════════════════════════════════════════════" - if [[ "${ARCH}" == "arm64" ]] && [[ "$(uname -m)" != "aarch64" ]]; then - echo " (Running under QEMU on a non-arm64 host — will be slow)" - fi - - docker buildx build \ - --platform "${PLATFORM}" \ - --target export \ - --output "type=local,dest=${OUT_DIR}" \ - --progress plain \ - "${REPO_ROOT}" - - echo "→ ${PLATFORM} binaries:" - ls -lh "${OUT_DIR}/" - echo "" -done - -echo "════════════════════════════════════════════════════════════════════════════" -echo "Done. Binaries in dist/:" -for ARCH in "${ARCHS[@]}"; do - ls -lh "${DIST_DIR}/linux-${ARCH}/" -done diff --git a/scripts/build-macos-client.sh b/scripts/build-macos-client.sh deleted file mode 100755 index f724b03..0000000 --- a/scripts/build-macos-client.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# -# build-macos-client.sh — build the macOS AppKit client (VoiceCatMac.app) and -# stage it into dist/macos-client/. -# -# Two steps: -# 1. Build VoiceCatCore.xcframework (macOS slice) by invoking the existing -# clients/apple/scripts/build-xcframework.sh — that builds libvoicecat.a -# (apple-dev preset), merges vcpkg deps into a fat .a, and stitches the -# XCFramework the Swift Package consumes. -# 2. xcodebuild the VoiceCatMac scheme into a known derivedDataPath, then -# copy VoiceCatMac.app into dist/macos-client/. -# -# Usage: -# scripts/build-macos-client.sh # build + stage into ./dist/macos-client -# scripts/build-macos-client.sh --dist /out -# scripts/build-macos-client.sh --no-configure # skip cmake configure (xcframework step) -# scripts/build-macos-client.sh --config Debug # Xcode build config (default Release) -# scripts/build-macos-client.sh -h|--help -# -# macOS only. Requires VCPKG_ROOT and Xcode. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VC_SCRIPT_NAME="build-macos-client" -source "$SCRIPT_DIR/common.sh" - -DO_CONFIGURE=true -XCODE_CONFIG="Release" -while [[ $# -gt 0 ]]; do - case "$1" in - --dist) vc_set_dist "$2"; shift 2 ;; - --no-configure) DO_CONFIGURE=false; shift ;; - --config) XCODE_CONFIG="$2"; shift 2 ;; - -h|--help) vc_print_help "$0"; exit 0 ;; - *) vc_die "unknown arg: $1 (try --help)" ;; - esac -done - -vc_require_macos -vc_log "dist dir: $VC_DIST_DIR" - -vc_step "build VoiceCatCore.xcframework (macOS slice)" -XCFW_ARGS=() -$DO_CONFIGURE || XCFW_ARGS+=( --no-configure ) -"$VC_REPO_ROOT/clients/apple/scripts/build-xcframework.sh" "${XCFW_ARGS[@]}" -XCFW="$VC_REPO_ROOT/clients/apple/VoiceCatCore.xcframework" -[[ -d "$XCFW" ]] || vc_die "xcframework not found at $XCFW" -vc_ok "xcframework ready -> $XCFW" - -vc_step "xcodebuild VoiceCatMac ($XCODE_CONFIG)" -XCODEPROJ="$VC_REPO_ROOT/clients/apple/macOS/VoiceCatMac.xcodeproj" -DERIVED="$VC_REPO_ROOT/build/macos-app" -xcodebuild -project "$XCODEPROJ" -scheme VoiceCatMac \ - -configuration "$XCODE_CONFIG" -derivedDataPath "$DERIVED" build - -APP="$DERIVED/Build/Products/$XCODE_CONFIG/VoiceCatMac.app" -[[ -d "$APP" ]] || vc_die "VoiceCatMac.app not found at $APP" -vc_ok "built -> $APP" - -vc_step "stage -> $VC_DIST_DIR/macos-client" -STAGE="$(vc_dist_subdir macos-client)" -cp -R "$APP" "$STAGE/" -vc_ok "VoiceCatMac.app -> $STAGE/ ($(du -sh "$APP" | cut -f1))" -vc_ok "done -> $STAGE" diff --git a/dotnet/build-native-ios.sh b/scripts/build-native-ios.sh similarity index 81% rename from dotnet/build-native-ios.sh rename to scripts/build-native-ios.sh index 014c652..19f99a6 100755 --- a/dotnet/build-native-ios.sh +++ b/scripts/build-native-ios.sh @@ -9,7 +9,7 @@ build_one() { sdk_path=$(xcrun --sdk "$sdk" --show-sdk-path) local compiler compiler=$(xcrun --sdk "$sdk" --find clang) - local build_dir="$script_dir/artifacts/native-build-$rid-cmake" + local build_dir="$script_dir/../artifacts/native-build-$rid-cmake" cmake -S "$script_dir/../native/media" -B "$build_dir" \ -DCMAKE_SYSTEM_NAME=iOS \ -DCMAKE_C_COMPILER="$compiler" \ @@ -20,8 +20,8 @@ build_one() { -DVOICECAT_BUNDLED_OPUS=ON cmake --build "$build_dir" --config Release --target voicecat_media --parallel 2 cmake --install "$build_dir" --config Release \ - --component DotnetMedia --prefix "$script_dir/artifacts/native" - local staged="$script_dir/artifacts/native/runtimes/$rid/native/libvoicecat_media.a" + --component DotnetMedia --prefix "$script_dir/../artifacts/native" + local staged="$script_dir/../artifacts/native/runtimes/$rid/native/libvoicecat_media.a" local opus="$build_dir/_deps/opus-build/libopus.a" local rnnoise="$build_dir/librnnoise.a" local combined="$staged.combined" diff --git a/dotnet/build-native.ps1 b/scripts/build-native.ps1 similarity index 86% rename from dotnet/build-native.ps1 rename to scripts/build-native.ps1 index 50f99cd..4dfa215 100644 --- a/dotnet/build-native.ps1 +++ b/scripts/build-native.ps1 @@ -1,5 +1,5 @@ param( - [string]$BuildDirectory = "$PSScriptRoot/artifacts/native-build", + [string]$BuildDirectory = "$PSScriptRoot/../artifacts/native-build", [string]$RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier, [string]$Generator, [string]$CCompiler @@ -14,5 +14,5 @@ if ($CCompiler) { $configure += "-DCMAKE_C_COMPILER=$CCompiler" } 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" +& cmake --install $BuildDirectory --config Release --component DotnetMedia --prefix "$PSScriptRoot/../artifacts/native" if ($LASTEXITCODE) { throw "Native staging failed: $LASTEXITCODE" } diff --git a/scripts/build-server.sh b/scripts/build-server.sh deleted file mode 100755 index 4715398..0000000 --- a/scripts/build-server.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# -# build-server.sh — build the production-shaped voicecat-server (+ voicecat-admin) -# and stage the binaries into dist/server/. -# -# Uses the `server-release` CMake preset (Release, stripped, no tests — see -# docs/building.md §5). Works on Windows, Linux, and macOS; the vcpkg triplet -# is auto-resolved by cmake/voicecat-toolchain.cmake. -# -# Usage: -# scripts/build-server.sh # build + stage into ./dist/server -# scripts/build-server.sh --dist /out # stage into /out/server -# scripts/build-server.sh --no-configure # skip cmake configure, just rebuild + stage -# scripts/build-server.sh -h|--help # show this help -# -# Override the default dist dir (/dist) with --dist or VOICECAT_DIST_DIR. -# Requires VCPKG_ROOT (or a pre-configured build/server-release cache). -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VC_SCRIPT_NAME="build-server" -source "$SCRIPT_DIR/common.sh" - -DO_CONFIGURE=true -while [[ $# -gt 0 ]]; do - case "$1" in - --dist) vc_set_dist "$2"; shift 2 ;; - --no-configure) DO_CONFIGURE=false; shift ;; - -h|--help) vc_print_help "$0"; exit 0 ;; - *) vc_die "unknown arg: $1 (try --help)" ;; - esac -done - -vc_log "dist dir: $VC_DIST_DIR" -vc_resolve_vcpkg_root server-release - -vc_step "configure + build (preset: server-release)" -if $DO_CONFIGURE; then - cmake --preset server-release -fi -cmake --build --preset server-release - -BIN_DIR="$VC_REPO_ROOT/build/server-release/bin" -EXE_EXT="" -[[ "$(vc_host_os)" == "windows" ]] && EXE_EXT=".exe" - -vc_log "staging binaries -> $VC_DIST_DIR/server" -STAGE="$(vc_dist_subdir server)" - -for b in voicecat-server voicecat-admin; do - f="$BIN_DIR/$b$EXE_EXT" - if [[ ! -f "$f" ]]; then - # voicecat-admin only builds when vcpkg deps are on (server-release has - # them on), so it should exist — but be lenient about admin on odd configs. - [[ "$b" == "voicecat-admin" ]] && { vc_log "note: $b not found, skipping"; continue; } - vc_die "expected output not found: $f" - fi - cp "$f" "$STAGE/" - vc_ok "$b$EXE_EXT -> $STAGE/$(basename "$f") ($(vc_file_size "$f") bytes)" -done - -vc_ok "done -> $STAGE" diff --git a/scripts/build-windows-client.sh b/scripts/build-windows-client.sh deleted file mode 100755 index 9733228..0000000 --- a/scripts/build-windows-client.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -# -# build-windows-client.sh — build the Windows WinForms client and stage a -# runnable folder into dist/windows-client/. -# -# Two steps: -# 1. Build the native voicecat.dll via the `windows-client` CMake preset -# (MinGW, static runtime — no libgcc_s_seh-1.dll / libstdc++-6.dll deps). -# 2. `dotnet publish` the C# app (clients/windows/VoiceCat.App). The app's -# Directory.Build.props copies voicecat.dll into the output automatically. -# -# The staged folder contains VoiceCat.App.exe + voicecat.dll + all .NET deps, -# ready to run. By default it is framework-dependent (needs the .NET 10 runtime -# installed); pass --self-contained for a folder that runs without .NET. -# -# Usage: -# scripts/build-windows-client.sh # build + stage into ./dist/windows-client -# scripts/build-windows-client.sh --dist /out -# scripts/build-windows-client.sh --no-configure # skip cmake configure (DLL step) -# scripts/build-windows-client.sh --self-contained -# scripts/build-windows-client.sh -h|--help -# -# Windows only (MSYS2/MinGW). Requires VCPKG_ROOT and the .NET 10 SDK. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VC_SCRIPT_NAME="build-windows-client" -source "$SCRIPT_DIR/common.sh" - -DO_CONFIGURE=true -SELF_CONTAINED=false -while [[ $# -gt 0 ]]; do - case "$1" in - --dist) vc_set_dist "$2"; shift 2 ;; - --no-configure) DO_CONFIGURE=false; shift ;; - --self-contained) SELF_CONTAINED=true; shift ;; - -h|--help) vc_print_help "$0"; exit 0 ;; - *) vc_die "unknown arg: $1 (try --help)" ;; - esac -done - -vc_require_windows -vc_log "dist dir: $VC_DIST_DIR" -vc_resolve_vcpkg_root windows-client - -vc_step "build native DLL (preset: windows-client)" -if $DO_CONFIGURE; then - cmake --preset windows-client -fi -cmake --build --preset windows-client -DLL="$VC_REPO_ROOT/build/windows-client/bin/voicecat.dll" -[[ -f "$DLL" ]] || vc_die "expected output not found: $DLL" -vc_ok "voicecat.dll -> $DLL ($(vc_file_size "$DLL") bytes)" - -vc_step "publish C# app (dotnet publish)" -APP_PROJ="$VC_REPO_ROOT/clients/windows/VoiceCat.App/VoiceCat.App.csproj" -PUBLISH_DIR="$VC_REPO_ROOT/build/windows-client/publish" -rm -rf "$PUBLISH_DIR" -mkdir -p "$PUBLISH_DIR" - -PUBLISH_ARGS=(dotnet publish "$APP_PROJ" -c Release -o "$PUBLISH_DIR") -if $SELF_CONTAINED; then - PUBLISH_ARGS+=( -r win-x64 --self-contained true ) -fi -"${PUBLISH_ARGS[@]}" -APP_EXE="$PUBLISH_DIR/VoiceCat.App.exe" -[[ -f "$APP_EXE" ]] || vc_die "publish output missing VoiceCat.App.exe in $PUBLISH_DIR" - -vc_step "stage -> $VC_DIST_DIR/windows-client" -STAGE="$(vc_dist_subdir windows-client)" -cp -R "$PUBLISH_DIR/." "$STAGE/" -vc_ok "VoiceCat.App.exe + deps -> $STAGE (voicecat.dll included)" -vc_ok "done -> $STAGE" diff --git a/dotnet/check-licenses.ps1 b/scripts/check-licenses.ps1 similarity index 90% rename from dotnet/check-licenses.ps1 rename to scripts/check-licenses.ps1 index 1af20a8..a08d27e 100644 --- a/dotnet/check-licenses.ps1 +++ b/scripts/check-licenses.ps1 @@ -1,7 +1,7 @@ $ErrorActionPreference = 'Stop' -$allowed = @('MIT', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', 'ISC', '0BSD') +$allowed = @('MIT', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', 'ISC', '0BSD', 'MPL-2.0') $seen = @{} -foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter 'packages*.lock.json' -Recurse)) { +foreach ($lockPath in (Get-ChildItem -LiteralPath "$PSScriptRoot/.." -Filter 'packages*.lock.json' -Recurse)) { $lock = Get-Content -Raw -LiteralPath $lockPath.FullName | ConvertFrom-Json $assets = Get-Content -Raw -LiteralPath (Join-Path $lockPath.DirectoryName 'obj/project.assets.json') | ConvertFrom-Json foreach ($framework in $lock.dependencies.PSObject.Properties) { @@ -31,4 +31,4 @@ foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter 'package } } } -Write-Output "Checked $($seen.Count) package licenses: permissive allowlist passed." +Write-Output "Checked $($seen.Count) package licenses: approved allowlist passed." diff --git a/scripts/common.sh b/scripts/common.sh deleted file mode 100755 index 0f6e572..0000000 --- a/scripts/common.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env bash -# -# common.sh — shared helpers for the scripts/ build scripts. -# -# Source this from the other scripts/ build scripts (after setting SCRIPT_DIR): -# -# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# VC_SCRIPT_NAME="build-foo" # optional, for nicer log prefixes -# source "$SCRIPT_DIR/common.sh" -# -# What it provides: -# - repo-root + dist-dir resolution (default /dist; override with -# --dist via vc_set_dist, or the VOICECAT_DIST_DIR env var). -# - VCPKG_ROOT detection (env var first, then an existing CMake cache). -# - host-platform guards (vc_require_macos / vc_require_windows / vc_host_os). -# - uniform [tag] logging + a portable file-size helper. -# - vc_dist_subdir : create (clean) and echo $DIST/. -# - vc_print_help : print a script's leading "# " comment block as the -# --help text (so each script's header comment IS its help). -# -# Not meant to be run directly. - -# This file lives in scripts/, so the repo root is one level up. -VC_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VC_REPO_ROOT="$(cd "$VC_SCRIPT_DIR/.." && pwd)" - -# Display name for log lines (defaults to the caller's filename). -VC_SCRIPT_NAME="${VC_SCRIPT_NAME:-$(basename "${BASH_SOURCE[1]:-$0}")}" - -# Dist dir — env override resolved here; --dist updates it via vc_set_dist. -VC_DIST_DIR="${VOICECAT_DIST_DIR:-$VC_REPO_ROOT/dist}" - -# ── Logging ───────────────────────────────────────────────────────────────────── -vc_log() { printf '\033[1;34m[%s]\033[0m %s\n' "$VC_SCRIPT_NAME" "$*"; } -vc_step() { printf '\033[1;36m[%s] === %s ===\033[0m\n' "$VC_SCRIPT_NAME" "$*"; } -vc_ok() { printf '\033[1;32m[%s]\033[0m %s\n' "$VC_SCRIPT_NAME" "$*"; } -vc_err() { printf '\033[1;31m[%s] error:\033[0m %s\n' "$VC_SCRIPT_NAME" "$*" >&2; } -vc_die() { vc_err "$*"; exit 1; } - -# Portable file size (macOS `stat -f%z` vs coreutils `stat -c%s`). -vc_file_size() { - stat -f%z "$1" 2>/dev/null || stat -c%s "$1" 2>/dev/null -} - -# ── Dist dir ──────────────────────────────────────────────────────────────────── -# Set/override the dist directory (called when a script sees --dist ). -vc_set_dist() { - VC_DIST_DIR="$1" - mkdir -p "$VC_DIST_DIR" -} - -# Create (clean) and echo a subdirectory under dist/. -vc_dist_subdir() { - local path="$VC_DIST_DIR/$1" - rm -rf "$path" - mkdir -p "$path" - echo "$path" -} - -# ── Platform helpers ──────────────────────────────────────────────────────────── -vc_host_os() { - case "$(uname -s)" in - Darwin) echo macos ;; - MINGW*|MSYS*|CYGWIN*) echo windows ;; - Linux) echo linux ;; - *) echo unknown ;; - esac -} - -vc_require_macos() { - [[ "$(uname -s)" == "Darwin" ]] \ - || vc_die "this script must be run on macOS (uname -s = $(uname -s))." -} - -vc_require_windows() { - local s; s="$(uname -s)" - [[ "$s" == MINGW* || "$s" == MSYS* || "$s" == CYGWIN* ]] \ - || vc_die "this script must be run on Windows (MSYS2/MinGW). uname -s = $s." -} - -# ── VCPKG_ROOT ────────────────────────────────────────────────────────────────── -# Resolution order: the env var, else an existing CMake cache (Z_VCPKG_ROOT_DIR) so a -# developer who already configured a preset doesn't need VCPKG_ROOT in their shell env, -# else the bundled submodule at /vcpkg. $1 = the preset whose cache to probe -# as a fallback (e.g. "dev" or "apple-dev"). -vc_resolve_vcpkg_root() { - local fallback_preset="${1:-dev}" - if [[ -z "${VCPKG_ROOT:-}" ]]; then - local cache="$VC_REPO_ROOT/build/$fallback_preset/CMakeCache.txt" - if [[ -f "$cache" ]]; then - local detected - detected="$(grep -m1 '^Z_VCPKG_ROOT_DIR:INTERNAL=' "$cache" | cut -d= -f2- || true)" - if [[ -n "$detected" && -d "$detected" ]]; then - export VCPKG_ROOT="$detected" - fi - fi - fi - if [[ -z "${VCPKG_ROOT:-}" ]]; then - local bundled="$VC_REPO_ROOT/vcpkg" - if [[ -f "$bundled/scripts/buildsystems/vcpkg.cmake" ]]; then - export VCPKG_ROOT="$bundled" - fi - fi - if [[ -z "${VCPKG_ROOT:-}" || ! -d "$VCPKG_ROOT" ]]; then - vc_die "VCPKG_ROOT is not set or does not exist. - either init the bundled submodule: - git submodule update --init vcpkg - or point at an external vcpkg checkout: - export VCPKG_ROOT=/path/to/vcpkg" - fi - vc_log "VCPKG_ROOT=$VCPKG_ROOT" -} - -# ── Help ──────────────────────────────────────────────────────────────────────── -# Print a script's leading "# " comment block (skipping the shebang) as help. -vc_print_help() { - awk 'NR==1{next} /^#[[:space:]]?/{sub(/^#[[:space:]]?/,""); print; next} {exit}' "$1" -} diff --git a/scripts/dist-ios-adhoc.sh b/scripts/dist-ios-adhoc.sh deleted file mode 100755 index 50ab4d1..0000000 --- a/scripts/dist-ios-adhoc.sh +++ /dev/null @@ -1,285 +0,0 @@ -#!/usr/bin/env bash -# -# dist-ios-adhoc.sh — build an ad-hoc IPA of the iOS client and the files needed to -# install it on registered devices from an HTTPS web page (itms-services OTA install). -# -# This is for handing the app to a handful of friends BEFORE TestFlight. Ad-hoc builds -# only run on devices whose UDID is registered in your Apple Developer account, so the -# script registers UDIDs (via the App Store Connect API) before it signs. -# -# Pipeline: -# 1. Register each --udid with App Store Connect (idempotent; skip with --skip-register). -# 2. Build VoiceCatCore.xcframework (iOS device slice). -# 3. xcodebuild archive (Release, generic/platform=iOS), letting Xcode auto-create the -# ad-hoc Distribution cert + profile via -allowProvisioningUpdates + the API key. -# 4. xcodebuild -exportArchive with method=release-testing (Xcode's modern name for -# "ad-hoc") -> VoiceCatiOS.ipa. -# 5. Generate manifest.plist (OTA install manifest) + index.html (install page). -# 6. Stage VoiceCatiOS.ipa + manifest.plist + index.html into dist/ios-adhoc/. -# -# You then upload those three files to your HTTPS host and open index.html on the iPhone. -# -# Prerequisites (one-time, not scriptable): -# - Paid Apple Developer Program membership (Team ID is already set in the project). -# - An App Store Connect API "Team Key" (.p8) with Admin or App Manager access: -# App Store Connect -> Users and Access -> Integrations -> App Store Connect API. -# Note its Key ID and Issuer ID. Keep the .p8 out of the repo. -# - Each friend's device UDID (read it in Finder with the iPhone connected to a Mac). -# -# Auth — supply via env vars (or the matching flags): -# ASC_KEY_ID App Store Connect API Key ID (--key-id) -# ASC_ISSUER_ID App Store Connect API Issuer ID (--issuer-id) -# ASC_KEY_PATH path to AuthKey_XXXXXXXXXX.p8 (--key) -# -# Usage: -# scripts/dist-ios-adhoc.sh --udid --name "Friend iPhone" \ -# --base-url https://example.com/voicecat -# scripts/dist-ios-adhoc.sh --udids-file friends.csv --base-url https://example.com/vc -# scripts/dist-ios-adhoc.sh --skip-register --base-url https://example.com/vc # rebuild only -# scripts/dist-ios-adhoc.sh --dist /out --no-configure -# scripts/dist-ios-adhoc.sh -h|--help -# -# Flags: -# --udid device to register (repeatable). Pair with an optional --name. -# --name